From 6ab50dee5834089b6edc84b20cd584afda571bef Mon Sep 17 00:00:00 2001 From: Tom Wright Date: Fri, 8 Dec 2023 18:58:41 +0000 Subject: [PATCH] Initial implementation Signed-off-by: Tom Wright --- .editorconfig | 20 ++ .gitattributes | 11 + .github/workflows/integrate.yml | 130 ++++++++ .gitignore | 8 + CHANGELOG.md | 2 + README.md | 32 ++ composer.json | 56 ++++ ecs.php | 13 + phpstan.neon | 4 + src/Clip/Task/Doctrine.php | 63 ++++ src/Dovetail/Config/Doctrine.php | 153 +++++++++ src/Indoctrination/Config.php | 61 ++++ src/Indoctrination/Context.php | 302 ++++++++++++++++++ src/Indoctrination/Extension.php | 33 ++ src/Indoctrination/Extension/Carbon.php | 34 ++ src/Indoctrination/Extension/Postgres.php | 159 +++++++++ src/Indoctrination/Extension/SchemaIgnore.php | 142 ++++++++ src/Indoctrination/Extension/Uuid.php | 27 ++ src/Indoctrination/ExtensionTrait.php | 46 +++ src/Indoctrination/Generator/Uuid.php | 27 ++ src/Indoctrination/MetadataType.php | 17 + src/Indoctrination/Type/Uuid.php | 117 +++++++ stubs/DecodeLabs/Indoctrination.php | 35 ++ 23 files changed, 1492 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/workflows/integrate.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 composer.json create mode 100644 ecs.php create mode 100644 phpstan.neon create mode 100644 src/Clip/Task/Doctrine.php create mode 100644 src/Dovetail/Config/Doctrine.php create mode 100644 src/Indoctrination/Config.php create mode 100644 src/Indoctrination/Context.php create mode 100644 src/Indoctrination/Extension.php create mode 100644 src/Indoctrination/Extension/Carbon.php create mode 100644 src/Indoctrination/Extension/Postgres.php create mode 100644 src/Indoctrination/Extension/SchemaIgnore.php create mode 100644 src/Indoctrination/Extension/Uuid.php create mode 100644 src/Indoctrination/ExtensionTrait.php create mode 100644 src/Indoctrination/Generator/Uuid.php create mode 100644 src/Indoctrination/MetadataType.php create mode 100644 src/Indoctrination/Type/Uuid.php create mode 100644 stubs/DecodeLabs/Indoctrination.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fc134fc --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +# https://EditorConfig.org + +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +block_comment_start = /* +block_comment = * +block_comment_end = */ + +[*.yml] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..789106a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +.editorconfig export-ignore +.gitattributes export-ignore +.github/ export-ignore +.gitignore export-ignore +CHANGELOG.md export-ignore +ecs.php export-ignore +phpstan.neon export-ignore +phpunit.xml.dist export-ignore +docs/ export-ignore +tests/ export-ignore +stubs/ export-ignore diff --git a/.github/workflows/integrate.yml b/.github/workflows/integrate.yml new file mode 100644 index 0000000..b495e04 --- /dev/null +++ b/.github/workflows/integrate.yml @@ -0,0 +1,130 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow + +name: "Integrate" + +on: + push: + branches: + - "develop" + pull_request: null + +env: + PHP_EXTENSIONS: "intl" + +jobs: + file_consistency: + name: "1️⃣ File consistency" + runs-on: "ubuntu-latest" + steps: + - name: "Set up PHP" + uses: "shivammathur/setup-php@v2" + with: + php-version: "8.1" + extensions: "${{ env.PHP_EXTENSIONS }}" + ini-values: "post_max_size=256M" + + - name: "Checkout code" + uses: "actions/checkout@v3" + + - name: Install Effigy + run: | + composer global config --no-plugins allow-plugins.phpstan/extension-installer true + composer global require decodelabs/effigy + + - name: "Install dependencies" + uses: "ramsey/composer-install@v2" + with: + dependency-versions: "highest" + + - name: "Check file permissions" + run: | + composer global exec effigy check-executable-permissions + + - name: "Check exported files" + run: | + composer global exec effigy check-git-exports + + - name: "Find non-printable ASCII characters" + run: | + composer global exec effigy check-non-ascii + + - name: "Check source code for syntax errors" + run: | + composer global exec effigy lint + + static_analysis: + name: "3️⃣ Static Analysis" + needs: + - "file_consistency" + runs-on: "ubuntu-latest" + strategy: + matrix: + php-version: + - "8.1" + - "8.2" + - "8.3" + steps: + - name: "Set up PHP" + uses: "shivammathur/setup-php@v2" + with: + php-version: "${{ matrix.php-version }}" + extensions: "${{ env.PHP_EXTENSIONS }}" + ini-values: "post_max_size=256M" + + - name: "Checkout code" + uses: "actions/checkout@v3" + + - name: Install Effigy + run: | + composer global config --no-plugins allow-plugins.phpstan/extension-installer true + composer global require decodelabs/effigy + + - name: "Validate Composer configuration" + run: "composer validate --strict" + + - name: "Install dependencies" + uses: "ramsey/composer-install@v2" + with: + dependency-versions: "highest" + + - name: "Execute static analysis" + run: | + composer global exec effigy analyze -- --headless + + + coding_standards: + name: "4️⃣ Coding Standards" + needs: + - "file_consistency" + runs-on: "ubuntu-latest" + steps: + - name: "Set up PHP" + uses: "shivammathur/setup-php@v2" + with: + php-version: "8.1" + extensions: "${{ env.PHP_EXTENSIONS }}" + ini-values: "post_max_size=256M" + + - name: "Checkout code" + uses: "actions/checkout@v3" + + - name: "Check EditorConfig configuration" + run: "test -f .editorconfig" + + - name: "Check adherence to EditorConfig" + uses: "greut/eclint-action@v0" + + - name: Install Effigy + run: | + composer global config --no-plugins allow-plugins.phpstan/extension-installer true + composer global require decodelabs/effigy + + - name: "Install dependencies" + uses: "ramsey/composer-install@v2" + with: + dependency-versions: "highest" + + - name: "Check coding style" + run: | + composer global exec effigy format -- --headless + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b0d60a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/vendor +composer.phar +composer.lock +.DS_Store +Thumbs.db +/phpunit.xml +/.idea +/.env diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d1b2fdb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ +## v0.1.0 (2023-12-08) +* Built initial implementation diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb98332 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Indoctrination + +[![PHP from Packagist](https://img.shields.io/packagist/php-v/decodelabs/indoctrination?style=flat)](https://packagist.org/packages/decodelabs/indoctrination) +[![Latest Version](https://img.shields.io/packagist/v/decodelabs/indoctrination.svg?style=flat)](https://packagist.org/packages/decodelabs/indoctrination) +[![Total Downloads](https://img.shields.io/packagist/dt/decodelabs/indoctrination.svg?style=flat)](https://packagist.org/packages/decodelabs/indoctrination) +[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/decodelabs/indoctrination/integrate.yml?branch=develop)](https://github.com/decodelabs/indoctrination/actions/workflows/integrate.yml) +[![PHPStan](https://img.shields.io/badge/PHPStan-enabled-44CC11.svg?longCache=true&style=flat)](https://github.com/phpstan/phpstan) +[![License](https://img.shields.io/packagist/l/decodelabs/indoctrination?style=flat)](https://packagist.org/packages/decodelabs/indoctrination) + +### Doctrine DBAL and ORM integration tools + +Indoctrination provides ... + +_Get news and updates on the [DecodeLabs blog](https://blog.decodelabs.com)._ + +--- + +## Installation + +Install via Composer: + +```bash +composer require decodelabs/indoctrination +``` + +## Usage + +Coming soon... + +## Licensing + +Indoctrination is licensed under the MIT License. See [LICENSE](./LICENSE) for the full license text. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..cf8146d --- /dev/null +++ b/composer.json @@ -0,0 +1,56 @@ +{ + "name": "decodelabs/indoctrination", + "description": "Doctrine DBAL and ORM integration tools", + "type": "library", + "keywords": [ ], + "license": "MIT", + "authors": [ { + "name": "Tom Wright", + "email": "tom@inflatablecookie.com" + } ], + "require": { + "php": "^8.1", + "decodelabs/dovetail": "^0.2.2", + "decodelabs/exceptional": "^0.4.4", + "decodelabs/guidance": "^0.1.7", + "decodelabs/slingshot": "^0.1.4", + "decodelabs/stash": "^0.3.0", + "decodelabs/veneer": "^0.10.24", + + "doctrine/orm": "^2.17", + "doctrine/dbal": "^3.7", + "doctrine/migrations": "^3.7" + }, + "require-dev": { + "decodelabs/cipher": "^0.1.1", + "decodelabs/clip": "^0.3.10", + "decodelabs/genesis": "^0.8.3", + "decodelabs/glitch": "^0.18.10", + "decodelabs/phpstan-decodelabs": "^0.6.7", + "martin-georgiev/postgresql-for-doctrine": "^2.1", + "nesbot/carbon": "^2.72" + }, + "suggest": { + "decodelabs/cipher": "JWT token support", + "decodelabs/clip": "Command line tools", + "martin-georgiev/postgresql-for-doctrine": "Extended PostgreSQL support for Doctrine", + "nesbot/carbon": "Better Date/time handling" + }, + "autoload": { + "psr-4": { + "DecodeLabs\\Indoctrination\\": "src/Indoctrination/" + }, + "classmap": [ + "src/Clip/", + "src/Dovetail/" + ], + "files": [ + "src/Indoctrination/Context.php" + ] + }, + "extra": { + "branch-alias": { + "dev-develop": "0.1.x-dev" + } + } +} diff --git a/ecs.php b/ecs.php new file mode 100644 index 0000000..56f5fea --- /dev/null +++ b/ecs.php @@ -0,0 +1,13 @@ +paths([__DIR__.'/src']); + $ecsConfig->sets([SetList::CLEAN_CODE, SetList::PSR_12]); +}; diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..16f0d34 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + paths: + - src + level: max diff --git a/src/Clip/Task/Doctrine.php b/src/Clip/Task/Doctrine.php new file mode 100644 index 0000000..b5daea3 --- /dev/null +++ b/src/Clip/Task/Doctrine.php @@ -0,0 +1,63 @@ + $this->runMigrations(), + default => $this->runRoot() + }; + + return true; + } + + protected function runRoot(): void + { + $entityManager = Indoctrination::getEntityManager(); + + ConsoleRunner::run( + new SingleManagerProvider($entityManager), + self::COMMANDS + ); + } + + protected function runMigrations(): void + { + $config = DoctrineConfig::load(); + $migrationConfig = new ConfigurationArray($config->getMigrationsConfig()); + $entityManager = Indoctrination::getEntityManager(); + $dependencyFactory = DependencyFactory::fromEntityManager($migrationConfig, new ExistingEntityManager($entityManager)); + MigrationsConsoleRunner::run([], $dependencyFactory); + } +} diff --git a/src/Dovetail/Config/Doctrine.php b/src/Dovetail/Config/Doctrine.php new file mode 100644 index 0000000..3af740d --- /dev/null +++ b/src/Dovetail/Config/Doctrine.php @@ -0,0 +1,153 @@ + [ + 'sharedConnection' => "{{envString(['DATABASE_SHARED', 'DATABASE'])}}", + 'adminConnection' => "{{envString(['DATABASE_ADMIN', 'DATABASE'])}}", + + 'paths' => [], + 'metadata' => 'attributes', + 'extensions' => [], + 'listeners' => [], + 'ignoreSchemas' => [], + + 'migrations' => [ + 'table_storage' => [ + 'table_name' => 'doctrine_migration_versions', + 'version_column_name' => 'version', + 'version_column_length' => 191, + 'executed_at_column_name' => 'executed_at', + 'execution_time_column_name' => 'execution_time', + ], + + 'migrations_paths' => [], + + 'all_or_nothing' => true, + 'transactional' => true, + 'check_database_platform' => true, + 'organize_migrations' => 'none', + ] + ] + ]; + } + + + /** + * Get poolable connection URL for given name + */ + public function getSharedConnection( + ?string $name = null + ): string { + $name = $this->normalizeName($name); + return $this->data->{$name}->sharedConnection->as('string'); + } + + /** + * Get connection URL for given name + */ + public function getAdminConnection( + ?string $name = null + ): string { + $name = $this->normalizeName($name); + return $this->data->{$name}->adminConnection->as('string'); + } + + + /** + * Get entity config paths + */ + public function getPaths( + ?string $name = null + ): array { + $name = $this->normalizeName($name); + return $this->data->{$name}->paths->as('string[]'); + } + + + /** + * Get metadata type + */ + public function getMetadataType( + ?string $name = null + ): MetadataType { + $name = $this->normalizeName($name); + $output = $this->data->{$name}->metadata->as('string'); + + if (!$output = MetadataType::tryFrom($output)) { + $output = MetadataType::Attributes; + } + + return $output; + } + + + /** + * Get extension config + */ + public function getExtensions( + ?string $name = null + ): array { + $output = $this->data->{$name}->extensions->toArray(); + + foreach ($output as $key => $value) { + if (is_string($value)) { + unset($output[$key]); + $output[$value] = []; + continue; + } + + if (!is_string($key)) { + unset($output[$key]); + continue; + } + } + + return $output; + } + + + /** + * Get migration config for given name + */ + public function getMigrationsConfig( + ?string $name = null + ): array { + $name = $this->normalizeName($name); + return $this->data->{$name}->migrations->toArray(); + } + + /** + * Normalize name + */ + protected function normalizeName(?string $name): string + { + if ( + $name === null || + !isset($this->data->{$name}) + ) { + $name = 'default'; + } + + return $name; + } +} diff --git a/src/Indoctrination/Config.php b/src/Indoctrination/Config.php new file mode 100644 index 0000000..971392b --- /dev/null +++ b/src/Indoctrination/Config.php @@ -0,0 +1,61 @@ + + */ + public function getPaths( + ?string $name = null + ): array; + + /** + * Get metadata type + */ + public function getMetadataType( + ?string $name = null + ): MetadataType; + + /** + * Get extension name list + * + * @return array> + */ + public function getExtensions( + ?string $name = null + ): array; + + /** + * Get migration config for given name + * + * @return array + */ + public function getMigrationsConfig( + ?string $name = null + ): array; +} diff --git a/src/Indoctrination/Context.php b/src/Indoctrination/Context.php new file mode 100644 index 0000000..a7b436a --- /dev/null +++ b/src/Indoctrination/Context.php @@ -0,0 +1,302 @@ + + */ + protected array $entityManagers = []; + protected ?Slingshot $slingshot = null; + + /** + * @var array> + */ + protected array $extensions = []; + + public function __construct() + { + $this->init(); + } + + public function init(): void + { + if (static::$init) { + return; + } + + static::$init = true; + + // Register in Genesis container + if ( + class_exists(Genesis::class) && + !Genesis::$container->has(EntityManager::class) + ) { + Genesis::$container->bindShared( + EntityManager::class, + fn () => $this->getEntityManager() + ); + } + } + + /** + * Get EntityManager for given name + */ + public function getEntityManager( + ?string $name = null + ): EntityManager { + if ($name === null) { + $name = 'default'; + } + + if (!isset($this->entityManagers[$name])) { + $this->entityManagers[$name] = $this->loadEntityManager($name); + } + + return $this->entityManagers[$name]; + } + + /** + * Load entity manager for given name + */ + protected function loadEntityManager( + string $name + ): EntityManager { + $extensions = $this->loadExtenions($name); + $config = $this->loadOrmConfig($name); + $connection = $this->loadConnection($name, $config); + $em = new EntityManager($connection, $config); + + + // Extensions + foreach ($extensions as $extension) { + $extension->loadForEntityManager($em); + } + + return $em; + } + + /** + * Load extensions + * + * @return array + */ + protected function loadExtenions( + string $name + ): array { + $slingshot = $this->getSlingshot(); + $extensions = []; + + foreach (DoctrineConfig::load()->getExtensions($name) as $extName => $extConfig) { + $class = Archetype::resolve(Extension::class, $extName); + $extension = $slingshot->newInstance($class, $extConfig); + $extName = $extension->getName(); + + if (!isset($this->extensions['__GLOBAL__'][$extName])) { + $this->extensions['__GLOBAL__'][$extName] = $extension; + $extension->loadGlobal(); + } + + $extensions[$extension->getName()] = $extension; + } + + $this->extensions[$name] = $extensions; + return $extensions; + } + + /** + * Load ORM config for given name + */ + protected function loadOrmConfig( + string $name + ): OrmConfig { + $config = DoctrineConfig::load(); + + // Environment + if (class_exists(Genesis::class)) { + $devMode = !Genesis::$environment->isProduction(); + $appPath = Genesis::$hub->getApplicationPath(); + } else { + $devMode = Dovetail::envString('ENV_MODE', 'production') !== 'production'; + $appPath = dirname(Dovetail::getFinder()->findEnv()?->getPath() ?? ''); + } + + // Paths + $paths = []; + + foreach ($config->getPaths($name) as $path) { + $paths[] = $appPath . '/' . $path; + } + + + // Orm Config + $method = match ($config->getMetadataType($name)) { + MetadataType::Attributes => 'createAttributeMetadataConfiguration', + MetadataType::Annotations => 'createAnnotationMetadataConfiguration', + MetadataType::Xml => 'createXMLMetadataConfiguration' + }; + + $output = ORMSetup::$method( + paths: $paths, + isDevMode: $devMode, + cache: Stash::load(__CLASS__) + ); + + + // Schema filter + $output->setSchemaAssetsFilter(function ( + string|AbstractAsset $asset + ) use ($name): bool { + foreach ($this->extensions[$name] ?? [] as $extension) { + if (null !== ($result = $extension->filterSchemaAsset($asset))) { + return $result; + } + } + + return true; + }); + + + // Extensions + foreach ($this->extensions[$name] ?? [] as $extension) { + $extension->loadForOrmConfig($output); + } + + return $output; + } + + + /** + * Load connection for given name + */ + protected function loadConnection( + string $name, + OrmConfig $ormConfig + ): Connection { + $config = DoctrineConfig::load(); + + if (isset($_SERVER['HTTP_HOST'])) { + // Shared connection for web + $dsn = $config->getSharedConnection($name); + } else { + // Direct connection for cli + $dsn = $config->getAdminConnection($name); + } + + $connectionParams = (new DsnParser())->parse($dsn); + return DriverManager::getConnection($connectionParams, $ormConfig); + } + + /** + * Get slingshot instance + */ + protected function getSlingshot(): Slingshot + { + if (!$this->slingshot) { + $this->slingshot = new Slingshot( + class_exists(Genesis::class) ? Genesis::$container : null + ); + } + + return $this->slingshot; + } + + + /** + * Clear cache + */ + public function clearCache(): void + { + Stash::load(__CLASS__)->clear(); + } + + + + /** + * Transaction with RLS + */ + public function withJwt( + CipherPayload $payload, + Closure $callback, + string|EntityManager|null $entityManager = null + ): mixed { + if (!$entityManager instanceof EntityManager) { + $entityManager = $this->getEntityManager($entityManager); + } + + return $entityManager->getConnection()->transactional(function () use ($entityManager, $callback, $payload) { + $jwt = json_encode($payload); + $jwtKey = 'request.jwt.claims'; + + $entityManager->getConnection()->executeStatement( + <<getEntityManager($entityManager); + } + + return $entityManager->getConnection()->transactional(function () use ($entityManager, $callback) { + $bypassKey = 'request.jwt.bypass'; + + $entityManager->getConnection()->executeStatement( + <<addCustomStringFunction('CONTAINS', Functions\Contains::class); + $ormConfig->addCustomStringFunction('IS_CONTAINED_BY', Functions\IsContainedBy::class); + $ormConfig->addCustomStringFunction('OVERLAPS', Functions\Overlaps::class); + $ormConfig->addCustomStringFunction('JSON_GET_FIELD', Functions\JsonGetField::class); + $ormConfig->addCustomStringFunction('JSON_GET_FIELD_AS_TEXT', Functions\JsonGetFieldAsText::class); + $ormConfig->addCustomStringFunction('JSON_GET_OBJECT', Functions\JsonGetObject::class); + $ormConfig->addCustomStringFunction('JSON_GET_OBJECT_AS_TEXT', Functions\JsonGetObjectAsText::class); + $ormConfig->addCustomStringFunction('ILIKE', Functions\Ilike::class); + $ormConfig->addCustomStringFunction('SIMILAR_TO', Functions\SimilarTo::class); + $ormConfig->addCustomStringFunction('NOT_SIMILAR_TO', Functions\NotSimilarTo::class); + $ormConfig->addCustomStringFunction('REGEXP', Functions\Regexp::class); + $ormConfig->addCustomStringFunction('IREGEXP', Functions\IRegexp::class); + $ormConfig->addCustomStringFunction('NOT_REGEXP', Functions\NotRegexp::class); + $ormConfig->addCustomStringFunction('NOT_IREGEXP', Functions\NotIRegexp::class); + $ormConfig->addCustomStringFunction('TSMATCH', Functions\Tsmatch::class); + + $ormConfig->addCustomStringFunction('ALL_OF', Functions\All::class); + $ormConfig->addCustomStringFunction('ANY_OF', Functions\Any::class); + $ormConfig->addCustomStringFunction('ARRAY_AGG', Functions\ArrayAgg::class); + $ormConfig->addCustomStringFunction('ARRAY_APPEND', Functions\ArrayAppend::class); + $ormConfig->addCustomStringFunction('ARRAY_CAT', Functions\ArrayCat::class); + $ormConfig->addCustomStringFunction('ARRAY_DIMENSIONS', Functions\ArrayDimensions::class); + $ormConfig->addCustomStringFunction('ARRAY_LENGTH', Functions\ArrayLength::class); + $ormConfig->addCustomStringFunction('ARRAY_NUMBER_OF_DIMENSIONS', Functions\ArrayNumberOfDimensions::class); + $ormConfig->addCustomStringFunction('ARRAY_PREPEND', Functions\ArrayPrepend::class); + $ormConfig->addCustomStringFunction('ARRAY_REMOVE', Functions\ArrayRemove::class); + $ormConfig->addCustomStringFunction('ARRAY_REPLACE', Functions\ArrayReplace::class); + $ormConfig->addCustomStringFunction('ARRAY_TO_JSON', Functions\ArrayToJson::class); + $ormConfig->addCustomStringFunction('ARRAY_TO_STRING', Functions\ArrayToString::class); + $ormConfig->addCustomStringFunction('ARRAY_CARDINALITY', Functions\ArrayCardinality::class); + $ormConfig->addCustomStringFunction('CAST', Functions\Cast::class); + $ormConfig->addCustomStringFunction('DATE_EXTRACT', Functions\DateExtract::class); + $ormConfig->addCustomStringFunction('GREATEST', Functions\Greatest::class); + $ormConfig->addCustomStringFunction('JSON_AGG', Functions\JsonAgg::class); + $ormConfig->addCustomStringFunction('JSON_ARRAY_LENGTH', Functions\JsonArrayLength::class); + $ormConfig->addCustomStringFunction('JSON_EACH', Functions\JsonEach::class); + $ormConfig->addCustomStringFunction('JSON_EACH_TEXT', Functions\JsonEachText::class); + $ormConfig->addCustomStringFunction('JSON_OBJECT_AGG', Functions\JsonObjectAgg::class); + $ormConfig->addCustomStringFunction('JSON_OBJECT_KEYS', Functions\JsonObjectKeys::class); + $ormConfig->addCustomStringFunction('JSON_STRIP_NULLS', Functions\JsonStripNulls::class); + $ormConfig->addCustomStringFunction('JSON_TYPEOF', Functions\JsonTypeof::class); + $ormConfig->addCustomStringFunction('JSONB_ARRAY_ELEMENTS', Functions\JsonbArrayElements::class); + $ormConfig->addCustomStringFunction('JSONB_AGG', Functions\JsonbAgg::class); + $ormConfig->addCustomStringFunction('JSONB_ARRAY_ELEMENTS_TEXT', Functions\JsonbArrayElementsText::class); + $ormConfig->addCustomStringFunction('JSONB_ARRAY_LENGTH', Functions\JsonbArrayLength::class); + $ormConfig->addCustomStringFunction('JSONB_EACH', Functions\JsonbEach::class); + $ormConfig->addCustomStringFunction('JSONB_EACH_TEXT', Functions\JsonbEachText::class); + $ormConfig->addCustomStringFunction('JSONB_EXISTS', Functions\JsonbExists::class); + $ormConfig->addCustomStringFunction('JSONB_INSERT', Functions\JsonbInsert::class); + $ormConfig->addCustomStringFunction('JSONB_OBJECT_AGG', Functions\JsonbObjectAgg::class); + $ormConfig->addCustomStringFunction('JSONB_OBJECT_KEYS', Functions\JsonbObjectKeys::class); + $ormConfig->addCustomStringFunction('JSONB_PRETTY', Functions\JsonbPretty::class); + $ormConfig->addCustomStringFunction('JSONB_SET', Functions\JsonbSet::class); + $ormConfig->addCustomStringFunction('JSONB_SET_LAX', Functions\JsonbSetLax::class); + $ormConfig->addCustomStringFunction('JSONB_STRIP_NULLS', Functions\JsonbStripNulls::class); + $ormConfig->addCustomStringFunction('LEAST', Functions\Least::class); + $ormConfig->addCustomStringFunction('DATE_OVERLAPS', Functions\DateOverlaps::class); + $ormConfig->addCustomStringFunction('FLAGGED_REGEXP_LIKE', Functions\FlaggedRegexpLike::class); + $ormConfig->addCustomStringFunction('REGEXP_LIKE', Functions\RegexpLike::class); + $ormConfig->addCustomStringFunction('FLAGGED_REGEXP_MATCH', Functions\FlaggedRegexpMatch::class); + $ormConfig->addCustomStringFunction('REGEXP_MATCH', Functions\RegexpMatch::class); + $ormConfig->addCustomStringFunction('ROW_TO_JSON', Functions\RowToJson::class); + $ormConfig->addCustomStringFunction('STRING_AGG', Functions\StringAgg::class); + $ormConfig->addCustomStringFunction('STRING_TO_ARRAY', Functions\StringToArray::class); + $ormConfig->addCustomStringFunction('TO_JSON', Functions\ToJson::class); + $ormConfig->addCustomStringFunction('TO_JSONB', Functions\ToJsonb::class); + $ormConfig->addCustomStringFunction('TO_TSQUERY', Functions\ToTsquery::class); + $ormConfig->addCustomStringFunction('TO_TSVECTOR', Functions\ToTsvector::class); + $ormConfig->addCustomStringFunction('UNACCENT', Functions\Unaccent::class); + $ormConfig->addCustomStringFunction('UNNEST', Functions\Unnest::class); + + $ormConfig->addCustomStringFunction('ARRAY', Functions\Arr::class); + $ormConfig->addCustomStringFunction('IN_ARRAY', Functions\InArray::class); + $ormConfig->addCustomStringFunction('JSON_GET_FIELD_AS_INTEGER', Functions\JsonGetFieldAsInteger::class); + } + + + /** + * Set type mappings for ORM + */ + public function loadForEntityManager( + EntityManager $entityManager + ): void { + $platform = $entityManager->getConnection()->getDatabasePlatform(); + + if (!$platform instanceof PostgreSQLPlatform) { + return; + } + + $platform->registerDoctrineTypeMapping('bool[]', 'bool[]'); + $platform->registerDoctrineTypeMapping('_bool', 'bool[]'); + $platform->registerDoctrineTypeMapping('smallint[]', 'smallint[]'); + $platform->registerDoctrineTypeMapping('_int2', 'smallint[]'); + $platform->registerDoctrineTypeMapping('integer[]', 'integer[]'); + $platform->registerDoctrineTypeMapping('_int4', 'integer[]'); + $platform->registerDoctrineTypeMapping('bigint[]', 'bigint[]'); + $platform->registerDoctrineTypeMapping('_int8', 'bigint[]'); + $platform->registerDoctrineTypeMapping('text[]', 'text[]'); + $platform->registerDoctrineTypeMapping('_text', 'text[]'); + $platform->registerDoctrineTypeMapping('jsonb', 'jsonb'); + $platform->registerDoctrineTypeMapping('jsonb[]', 'jsonb[]'); + $platform->registerDoctrineTypeMapping('_jsonb', 'jsonb[]'); + } +} diff --git a/src/Indoctrination/Extension/SchemaIgnore.php b/src/Indoctrination/Extension/SchemaIgnore.php new file mode 100644 index 0000000..8809f24 --- /dev/null +++ b/src/Indoctrination/Extension/SchemaIgnore.php @@ -0,0 +1,142 @@ + + */ + protected array $namespaces = []; + + /** + * @var array>>> + */ + protected array $foreignKeys = []; + + private ?Schema $schema = null; + + /** + * @param array $namespaces + * @param array>>> $foreignKeys + */ + public function __construct( + array $namespaces = [], + array $foreignKeys = [] + ) { + $this->foreignKeys = $foreignKeys; + $this->namespaces = []; + + foreach ($namespaces as $namespace => $enabled) { + if (is_int($namespace)) { + $namespace = $enabled; + $enabled = true; + } + + $this->namespaces[(string)$namespace] = (bool)$enabled; + } + } + + + public function loadForEntityManager( + EntityManager $entityManager + ): void { + $entityManager->getEventManager()->addEventListener( + 'postGenerateSchema', + $this + ); + } + + public function filterSchemaAsset( + string|AbstractAsset $asset + ): ?bool { + if ($asset instanceof AbstractAsset) { + $asset = $asset->getName(); + } + + if (!str_contains($asset, '.')) { + $schema = 'public'; + } else { + $schema = explode('.', $asset, 2)[0]; + } + + if (isset($this->namespaces[$schema])) { + return $this->namespaces[$schema]; + } + + return true; + } + + + /** + * Handle generate schema event + */ + public function postGenerateSchema( + GenerateSchemaEventArgs $event + ): void { + $this->schema = $event->getSchema(); + + $this->addNamespaces(); + $this->addForeignKeys(); + } + + /** + * Add schema namespaces + */ + protected function addNamespaces(): void + { + $schema = $this->getSchema(); + + foreach ($this->namespaces as $namespace => $enabled) { + $schema->createNamespace($namespace); + } + } + + /** + * Add custom foreign keys to schema + */ + protected function addForeignKeys(): void + { + $schema = $this->getSchema(); + + foreach ($this->foreignKeys as $table => $keys) { + $table = $schema->getTable($table); + + foreach ($keys as $target => $key) { + $table->addForeignKeyConstraint( + $target, + ...$key + ); + } + } + } + + /** + * Get schema + */ + protected function getSchema(): Schema + { + if ($this->schema === null) { + throw Exceptional::Setup('Schema has not been captured yet'); + } + + return $this->schema; + } +} diff --git a/src/Indoctrination/Extension/Uuid.php b/src/Indoctrination/Extension/Uuid.php new file mode 100644 index 0000000..99e2406 --- /dev/null +++ b/src/Indoctrination/Extension/Uuid.php @@ -0,0 +1,27 @@ +getShortName(); + } + + public function loadGlobal(): void + { + // no-op + } + + public function loadForEntityManager( + EntityManager $entityManager + ): void { + // no-op + } + + public function loadForOrmConfig( + OrmConfig $ormConfig + ): void { + // no-op + } + + public function filterSchemaAsset( + string|AbstractAsset $asset + ): bool { + return true; + } +} diff --git a/src/Indoctrination/Generator/Uuid.php b/src/Indoctrination/Generator/Uuid.php new file mode 100644 index 0000000..faa98e9 --- /dev/null +++ b/src/Indoctrination/Generator/Uuid.php @@ -0,0 +1,27 @@ +hasNativeGuidType($platform)) { + return $platform->getGuidTypeDeclarationSQL($column); + } + + return $platform->getBinaryTypeDeclarationSQL([ + 'length' => 16, + 'fixed' => true, + ]); + } + + /** + * Convert to PHP value + */ + public function convertToPHPValue( + mixed $value, + AbstractPlatform $platform + ): ?UuidObject { + if ( + $value instanceof UuidObject || + null === $value + ) { + return $value; + } + + if ( + !is_string($value) && + !$value instanceof Stringable + ) { + throw Exceptional::InvalidType([ + 'message' => 'Invalid type: ' . gettype($value), + 'data' => $value + ]); + } + + return Guidance::fromString($value); + } + + /** + * Convert to database value + */ + public function convertToDatabaseValue( + mixed $value, + AbstractPlatform $platform + ): ?string { + $toString = $this->hasNativeGuidType($platform) ? '__toString' : 'getBytes'; + + if ($value instanceof UuidObject) { + return $value->$toString(); + } + + if ( + $value === null || + $value === '' + ) { + return null; + } + + if ( + !is_string($value) && + !$value instanceof Stringable + ) { + throw Exceptional::InvalidType([ + 'message' => 'Invalid type: ' . gettype($value), + 'data' => $value + ]); + } + + return Guidance::fromString($value)->$toString(); + } + + public function requiresSQLCommentHint( + AbstractPlatform $platform + ): bool { + return true; + } + + private function hasNativeGuidType( + AbstractPlatform $platform + ): bool { + return $platform->getGuidTypeDeclarationSQL([]) !== $platform->getStringTypeDeclarationSQL(['fixed' => true, 'length' => 36]); + } +} diff --git a/stubs/DecodeLabs/Indoctrination.php b/stubs/DecodeLabs/Indoctrination.php new file mode 100644 index 0000000..4018ff8 --- /dev/null +++ b/stubs/DecodeLabs/Indoctrination.php @@ -0,0 +1,35 @@ +getEntityManager(...func_get_args()); + } + public static function clearCache(): void {} + public static function withJwt(Ref1 $payload, Ref2 $callback, Ref0|string|null $entityManager = NULL): mixed { + return static::$instance->withJwt(...func_get_args()); + } + public static function bypassJwt(Ref2 $callback, Ref0|string|null $entityManager = NULL): mixed { + return static::$instance->bypassJwt(...func_get_args()); + } +};