From 15607d3986181d864c043f35b1e89d085537e3a6 Mon Sep 17 00:00:00 2001 From: Tacxticx88 <12997062+tacxticx88@users.noreply.github.com> Date: Mon, 29 Apr 2019 18:43:35 +0000 Subject: [PATCH] Base for MongoDb in Php #2 --- src/Bootstrap.php | 192 +++++++++++++ src/Config/Adapter/Env.php | 161 +++++++++++ src/Mvc/Collection.php | 260 ++++++++++++++++++ .../Collection/Document/ArrayStructure.php | 157 +++++++++++ src/Mvc/ControllerApi.php | 41 +++ src/Provider.php | 81 ++++++ 6 files changed, 892 insertions(+) create mode 100644 src/Bootstrap.php create mode 100644 src/Config/Adapter/Env.php create mode 100644 src/Mvc/Collection.php create mode 100644 src/Mvc/Collection/Document/ArrayStructure.php create mode 100644 src/Mvc/ControllerApi.php create mode 100644 src/Provider.php diff --git a/src/Bootstrap.php b/src/Bootstrap.php new file mode 100644 index 0000000..7c7a1d4 --- /dev/null +++ b/src/Bootstrap.php @@ -0,0 +1,192 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +namespace Phalconator; + +use DirectoryIterator; +use Exception; +use Phalcon\Application; +use Phalcon\Config; +use Phalcon\Config\Adapter\Php as PhpConfig; +use Phalcon\Config\Exception as ExceptionConfig; +use Phalcon\Di; +use Phalcon\DiInterface; +use Phalconator\Config\Adapter\Env as EnvConfig; +use function implode; + +/** + * Class Bootstrap + * + * @package Phalconator + */ +final class Bootstrap +{ + /** @var DiInterface $di */ + protected $di; + + /** @var string $basePath */ + protected $basePath; + + /** @var Application $app */ + protected $app; + + /** @var Config $services */ + protected $services; + + /** @var array $options */ + protected $options = []; + + const ENV_PREFIX = 'TWA_'; + const ENV_DELIMITER = '_'; + const ENV_FILE_PATH = '/.env'; + const API_MODULE_PREFIX = 'ApiModule'; + const MODULES_DEFAULT_PATH = '/src/modules/'; + const ENV_DEFAULT_PATH = '/src/config/env_default.php'; + const SERVICES_DEFAULT_FILE = '/src/config/services_default.php'; + + /** + * Bootstrap constructor. + * + * @param string $basePath + * @param Application $app + * @param Config $services + * @param array $options + */ + public function __construct(string $basePath, Application $app, ?Config $services, array $options = []) + { + defined('STARTTIME') OR define('STARTTIME', microtime(true)); + + $this->di = new Di; + + $this->basePath = $basePath; + $this->app = $app; + $this->services = $services; + + $this->options = array_merge([ + 'envPrefix' => self::ENV_PREFIX, + 'envDelimiter' => self::ENV_DELIMITER, + 'envFilePath' => self::ENV_FILE_PATH, + 'envDefaultPath' => self::ENV_DEFAULT_PATH, + 'apiModulePrefix' => self::API_MODULE_PREFIX, + 'modulesDefaultPath' => self::MODULES_DEFAULT_PATH, + 'servicesDefaultFile' => self::SERVICES_DEFAULT_FILE + ], $options); + } + + /** + * Démarre l'application + * + * @return Application + */ + public function run(): Application + { + $this->configureApplication(); + $this->importProviders(); + + if (!is_null($this->services)) { + $this->registerModules(); + } + + $this->app->setDI($this->di); + return $this->app; + } + + /** + * Configure l'application + * + * @return self + */ + protected function configureApplication(): self + { + $basePath = $this->basePath; + $options = $this->options; + + $this->di->setShared('config', function () use ($basePath, $options) { + $configPath = implode(DIRECTORY_SEPARATOR, [$basePath, $options['envDefaultPath']]); + + $config = new PhpConfig($configPath); + + try { + $envPath = implode(DIRECTORY_SEPARATOR, [$basePath, $options['envFilePath']]); + + if (file_exists($envPath)) { + $envConfig = new EnvConfig($envPath, $options['envPrefix'], $options['envDelimiter']); + $config->merge($envConfig); + } + + // TODO: Gérer un warning log pour prévenir que l'application n'utilise pas de fichier de configuration + + } catch (ExceptionConfig $e) { + //TODO: Gérer l'exception de configuration + } + + return $config; + }); + + return $this; + } + + /** + * Importe les providers + * + * @return self + */ + protected function importProviders(): self + { + $providerPath = implode(DIRECTORY_SEPARATOR, [$this->basePath, $this->options['servicesDefaultFile']]); + + $providerServices = new PhpConfig($providerPath); + $providerServices->merge($this->services); + + // TODO: Gérer un warning pour prévenir que l'application n'implémente pas de service personalisés + + try { + (new Provider($this->di)) + ->load($providerServices->toArray()) + ->register(); + + } catch (Exception $e) { + //TODO: Gérer l'exception de génération du provider + } + + return $this; + } + + /** + * Enregistre les modules au sein de l'application + * + * @return self + */ + protected function registerModules(): self + { + $registeredModules = []; + $modulesPath = implode(DIRECTORY_SEPARATOR, [$this->basePath, $this->options['modulesDefaultPath']]); + + foreach (new DirectoryIterator($modulesPath) as $module) { + if ($module->isDot()) continue; + if (!$module->isDir()) continue; + + if (($strpos = strpos($module->getBasename(), $this->options['apiModulePrefix'])) !== false) { + $moduleName = substr($module->getBasename(), $strpos + strlen($this->options['apiModulePrefix'])); + + $registeredModules[strtolower($moduleName)] = [ + 'className' => 'Toroia\Modules\\' . ucfirst($moduleName) . '\Module', + 'path' => implode(DIRECTORY_SEPARATOR, [$module->getRealPath(), 'Module.php']) + ]; + } + + // TODO: Gérer un warning quand un dossier ne contient pas le préfix + } + $this->app->registerModules($registeredModules); + + return $this; + } +} diff --git a/src/Config/Adapter/Env.php b/src/Config/Adapter/Env.php new file mode 100644 index 0000000..4aef0d6 --- /dev/null +++ b/src/Config/Adapter/Env.php @@ -0,0 +1,161 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +namespace Phalconator\Config\Adapter; + +use Phalcon\Config; +use Phalcon\Config\Exception; + +/** + * Class Env + * + * @package Phalconator\Config\Adapter + */ +class Env extends Config +{ + const DEFAULT_DELIMITER = '_'; + + /** + * Env constructor. + * + * @param string|null $filePath + * @param string|null $prefix + * @param string|null $delimiter + * @throws Exception + */ + public function __construct(?string $filePath, ?string $prefix, ?string $delimiter) + { + if (empty($delimiter)) { + $delimiter = self::DEFAULT_DELIMITER; + } + + $envConfig = $this->parseEnvConfig($prefix, $delimiter); + + if (!is_null($filePath)) { + $envFile = $this->parseEnvFile($filePath, $prefix, $delimiter); + + if ($envFile === false) { + throw new Exception("Configuration file " . basename($filePath) . " can't be loaded"); + } + } + + $config = array_merge($envConfig, $envFile ?? []); + + parent::__construct($config); + } + + private function parseEnvConfig($prefix, $delimiter) + { + $config = []; + + foreach (getenv() as $key => $value) { + if (strpos($key, $prefix) !== false) { + $key = str_replace($prefix, '', $key); + $keys = explode($delimiter, $key); + + $value = $this->assignValueType($value); + + $this->assignArrayKeys($config, $keys, $value); + } + } + + return $config; + } + + /** + * Envfile parser + * + * @param string $filePath + * @param string $prefix + * @param null|string $delimiter + * @return array|bool + */ + private function parseEnvFile(string $filePath, string $prefix, ?string $delimiter) + { + if ($content = file_get_contents($filePath)) { + $config = []; + $lines = explode(PHP_EOL, $content); + $re = '/^(?:' . $prefix . ')\s*([\w\.\-]+)\s*=\s*(.*)?\s*$/'; + + foreach ($lines as $line) { + if (preg_match($re, $line, $keysValue)) { + $keys = explode($delimiter, $keysValue[1]); + $value = $keysValue[2] ?? null; + $length = strlen($value) ?? 0; + + if ($length > 0 && strpos($value, '"') === 0 && substr($value, -1) === '"') { + $value = preg_replace('/\\n/gm', "\n", $value); + } + + $value = trim(preg_replace('/(^[\'"]|[\'"]$)/', '', $value)); + + $value = $this->assignValueType($value); + + $this->assignArrayKeys($config, $keys, $value); + } + } + + return $config; + } + + return false; + } + + /** + * Assigne une chaine à son type respectif + * + * @param string|null $value + * @return mixed + */ + private function assignValueType(?string $value) + { + switch (true) { + // Integer + case ctype_digit($value): + return intval($value); + + // Float + case !ctype_digit($value) && is_numeric($value): + return floatval($value); + + // Boolean + case $value == "true": + return true; + case $value == "false": + return false; + + // Null + case $value == "null": + case empty($value): + return null; + + // String + default: + return $value; + } + } + + /** + * Assigne en cascade un tableau clé/valeurs + * + * @param array $arr + * @param array $keys + * @param mixed $value + */ + private function assignArrayKeys(array &$arr, array $keys, $value) + { + foreach ($keys as $key) { + $arr = &$arr[strtolower($key)]; + } + + $arr = $value; + } +} diff --git a/src/Mvc/Collection.php b/src/Mvc/Collection.php new file mode 100644 index 0000000..d86f4ef --- /dev/null +++ b/src/Mvc/Collection.php @@ -0,0 +1,260 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +namespace Phalconator\Mvc; + +use MongoDB\BSON\ObjectId; +use MongoDB\BSON\Unserializable; +use Phalcon\Di; +use Phalcon\Mvc\Collection as CollectionBase; +use Phalcon\Mvc\Collection\Document; +use Phalcon\Mvc\Collection\Exception; +use Phalcon\Mvc\CollectionInterface; + +/** + * Class ControllerApi + * + * @package Phalconator\Mvc + */ +abstract class Collection extends CollectionBase implements Unserializable +{ + public function setId($id) + { + $objectId = null; + + if (!is_object($id)) { + + /** + * Check if the model use implicit ids + */ + if ($this->_modelsManager->isUsingImplicitObjectIds($this)) { + $objectId = new ObjectId($id); + } else { + $objectId = $id; + } + + } else { + + $objectId = $id; + } + + $this->_id = $objectId; + } + + protected function _exists($collection) + { + $objectId = null; + + if (!$id = $this->_id) { + return false; + } + if (!$this->_dirtyState) { + return true; + } + if (is_object($id)) { + $objectId = $id; + } else { + /** + * Check if the model use implicit ids + */ + if ($this->_modelsManager->isUsingImplicitObjectIds($this)) { + $objectId = new ObjectID($id); + } else { + $objectId = $id; + } + } + /** + * Perform the count using the function provided by the driver + */ + $exists = $collection->count(["_id" => $objectId]) > 0; + if ($exists) { + $this->_dirtyState = self::DIRTY_STATE_PERSISTENT; + } else { + $this->_dirtyState = self::DIRTY_STATE_TRANSIENT; + } + + return $exists; + } + + public function save() + { + $dependencyInjector = $this->_dependencyInjector; + if (!is_object($dependencyInjector)) { + throw new Exception( + "A dependency injector container is required to obtain the services related to the ODM" + ); + } + $source = $this->getSource(); + if (empty($source)) { + throw new Exception("Method getSource() returns empty string"); + } + $connection = $this->getConnection(); + $collection = $connection->selectCollection($source); + $exists = $this->_exists($collection); + if (false === $exists) { + $this->_operationMade = self::OP_CREATE; + } else { + $this->_operationMade = self::OP_UPDATE; + } + /** + * The messages added to the validator are reset here + */ + $this->_errorMessages = []; + $disableEvents = self::$_disableEvents; + /** + * Execute the preSave hook + */ + if (false === $this->_preSave($dependencyInjector, $disableEvents, $exists)) { + return false; + } + $data = $this->toArray(); + /** + * We always use safe stores to get the success state + * Save the document + */ + switch ($this->_operationMade) { + case self::OP_CREATE: + $status = $collection->insertOne($data); + break; + case self::OP_UPDATE: + unset($data['_id']); + $status = $collection->updateOne(['_id' => $this->_id], ['$set' => $data]); + break; + default: + throw new Exception('Invalid operation requested for ' . __METHOD__); + } + $success = false; + if ($status->isAcknowledged()) { + $success = true; + if (false === $exists) { + $this->_id = $status->getInsertedId(); + $this->_dirtyState = self::DIRTY_STATE_PERSISTENT; + } + } + /** + * Call the postSave hooks + */ + return $this->_postSave($disableEvents, $success, $exists); + } + + protected static function _getResultset($params, CollectionInterface $collection, $connection, $unique) + { + /** + * @codingStandardsIgnoreEnd + * Check if "class" clause was defined + */ + if (isset($params['class'])) { + $classname = $params['class']; + $base = new $classname(); + if (!$base instanceof CollectionInterface || $base instanceof Document) { + throw new Exception( + sprintf( + 'Object of class "%s" must be an implementation of %s or an instance of %s', + get_class($base), + CollectionInterface::class, + Document::class + ) + ); + } + } else { + $base = $collection; + } + if ($base instanceof Collection) { + $base->setDirtyState(Collection::DIRTY_STATE_PERSISTENT); + } + $source = $collection->getSource(); + if (empty($source)) { + throw new Exception("Method getSource() returns empty string"); + } + /** + * @var \Phalcon\Db\Adapter\MongoDB\Collection $mongoCollection + */ + $mongoCollection = $connection->selectCollection($source); + if (!is_object($mongoCollection)) { + throw new Exception("Couldn't select mongo collection"); + } + $conditions = []; + if (isset($params[0])||isset($params['conditions'])) { + $conditions = (isset($params[0]))?$params[0]:$params['conditions']; + } + /** + * Convert the string to an array + */ + if (!is_array($conditions)) { + throw new Exception("Find parameters must be an array"); + } + $options = []; + /** + * Check if a "limit" clause was defined + */ + if (isset($params['limit'])) { + $limit = $params['limit']; + $options['limit'] = (int)$limit; + if ($unique) { + $options['limit'] = 1; + } + } + /** + * Check if a "sort" clause was defined + */ + if (isset($params['sort'])) { + $sort = $params["sort"]; + $options['sort'] = $sort; + } + /** + * Check if a "skip" clause was defined + */ + if (isset($params['skip'])) { + $skip = $params["skip"]; + $options['skip'] = (int)$skip; + } + if (isset($params['fields']) && is_array($params['fields']) && !empty($params['fields'])) { + $options['projection'] = []; + foreach ($params['fields'] as $key => $show) { + $options['projection'][$key] = $show; + } + } + /** + * Perform the find + */ + $cursor = $mongoCollection->find($conditions, $options); + $cursor->setTypeMap(['root' => get_class($base), 'document' => 'array']); + if (true === $unique) { + /** + * Looking for only the first result. + */ + return current($cursor->toArray()); + } + /** + * Requesting a complete resultset + */ + $collections = []; + foreach ($cursor as $document) { + /** + * Assign the values to the base object + */ + $collections[] = $document; + } + return $collections; + } + + public function bsonUnserialize(array $data) + { + $this->setDI(Di::getDefault()); + $this->_modelsManager = Di::getDefault()->getShared('collectionManager'); + foreach ($data as $key => $val) { + $this->{$key} = $val; + } + if (method_exists($this, "afterFetch")) { + $this->afterFetch(); + } + } +} diff --git a/src/Mvc/Collection/Document/ArrayStructure.php b/src/Mvc/Collection/Document/ArrayStructure.php new file mode 100644 index 0000000..fcb14ef --- /dev/null +++ b/src/Mvc/Collection/Document/ArrayStructure.php @@ -0,0 +1,157 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +namespace Phalconator\Mvc\Collection\Document; + +use ArrayAccess; +use Countable; +use function strval; + +/** + * Class ArrayStructure + * + * @package Phalconator\Mvc\Collection\Document + */ +abstract class ArrayStructure implements ArrayAccess, Countable +{ + /** + * ArrayStructure constructor. + * + * @param array|null $array + */ + public function __construct(?array $array = null) + { + foreach ($array as $key => $value) { + $this->offsetSet($key, $value); + } + } + + /** + * Restore l'état d'une structure de tableau pour un document + * + * @param array|null $array + * @return ArrayStructure + */ + public static function __set_state(?array $array): ArrayStructure + { + $calledClass = get_called_class(); + + return new $calledClass($array); + } + + /** + * Recherche et retourne le contenu d'un enregistrement, si celui-ci existe pas, renvoie null + * Si la valeur n'est pas définie, la valeur par défaut sera retournée + * + * @param string|int $index + * @param mixed|null $defaultValue + * @return mixed|null + */ + public function get($index, $defaultValue = null) + { + $index = strval($index); + + if (isset($this->$index)) { + return $this->$index; + } + + return $defaultValue; + } + + /** + * Convertit récursivement les structures de tableaux en tableaux + * + * @return array + */ + public function toArray(): array + { + $array = []; + foreach (get_object_vars($this) as $key => $value) { + if (is_object($value)) { + if (method_exists($value, "toArray")) { + $array[$key] = $value->toArray(); + } else { + $array[$key] = $value; + } + } else { + $array[$key] = $value; + } + } + + return $array; + } + + /** + * Vérifie si un enregistrement existe + * + * @param string|int $index + * @return bool + */ + public function offsetExists($index): bool + { + $index = strval($index); + + return isset($this->$index); + } + + /** + * Récupère et retourne la valeur d'un enregistrement + * + * @param string|int $index + * @return mixed + */ + public function offsetGet($index) + { + $index = strval($index); + + return $this->$index; + } + + /** + * Créée un nouvel enregistrement dans la structure + * + * @param string|int $index + * @param mixed $value + */ + public function offsetSet($index, $value): void + { + $index = strval($index); + $calledClass = get_called_class(); + + if (is_array($value)) { + $this->$index = new $calledClass($value); + } else { + $this->$index = $value; + } + } + + /** + * Vide le contenu d'un enregistrement + * + * @param string|int $index + */ + public function offsetUnset($index): void + { + $index = strval($index); + + $this->$index = null; + } + + /** + * Compte et retourne le nombre d'enregistrements + * + * @return int + */ + public function count(): int + { + return count(get_object_vars($this)); + } +} diff --git a/src/Mvc/ControllerApi.php b/src/Mvc/ControllerApi.php new file mode 100644 index 0000000..278c12b --- /dev/null +++ b/src/Mvc/ControllerApi.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +namespace Phalconator\Mvc; + +use Phalcon\Dispatcher; +use Phalcon\Mvc\Controller; + +/** + * Class ControllerApi + * + * @package Phalconator\Mvc + */ +abstract class ControllerApi extends Controller +{ + /** + * @inheritdoc + */ + public function afterExecuteRoute(Dispatcher $dispatcher) + { + $this->response->setContentType('application/json', 'UTF-8'); + + if (is_array($response = $dispatcher->getReturnedValue())) { + $requestTime = $this->request->getServer('REQUEST_TIME_FLOAT') ?? STARTTIME; + + $response += [ + 'latency' => round((microtime(true) - $requestTime) * 1000) + ]; + + $this->response->setJsonContent($response); + } + } +} diff --git a/src/Provider.php b/src/Provider.php new file mode 100644 index 0000000..14b2eae --- /dev/null +++ b/src/Provider.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +namespace Phalconator; + +use Exception; +use Phalcon\Di\ServiceProviderInterface; +use Phalcon\DiInterface; +use ReflectionClass; +use ReflectionException; + +/** + * Class Provider + * + * @package Phalconator + */ +class Provider +{ + /** @var \Phalcon\Di $di */ + protected $di; + + /** @var array $services */ + protected $services = []; + + /** + * Provider constructor. + * + * @param DiInterface $di + */ + public function __construct(DiInterface $di) + { + $this->di = $di; + } + + /** + * Charge les services afin de les préparer à l'enregistrement + * + * @param array $services + * @return Provider + * @throws Exception + */ + public function load(array $services): self + { + foreach ($services as $service) { + try { + $class = new ReflectionClass($service); + + if ($class->implementsInterface(ServiceProviderInterface::class)) { + $this->services[] = $service; + } + + // TODO: Gérer le fait que le service soit mal construit et n'"implémente pas l'interface + + } catch (ReflectionException $e) { + + throw new Exception("L'application na pas pu démarrer, le service <$service> est mal implémenté."); + } + } + + return $this; + } + + /** + * Enregistre les services dans l'injecteur de dépendances (Di) + */ + public function register(): void + { + foreach ($this->services as $service) { + + $this->di->register(new $service); + } + } +}