diff --git a/README.md b/README.md index b3e2eff7..0886c1fc 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,7 @@ extension for [PostgreSQL](https://www.postgresql.org/). * [Supported Versions](#supported-versions) * [Installation](#installation) -* [Setup](#setup) - * [Symfony](#symfony) +* [Symfony Setup](#symfony-setup) * [Property Mapping](#property-mapping) * [Spatial Indexes](#spatial-indexes) * [Schema Tool](#schema-tool) @@ -25,12 +24,12 @@ Supported Versions The following table shows the versions which are officially supported by this library. -| Dependency | Supported Versions | -|:--------------|:--------------------| -| PostGIS | 3.0 and 3.1 | -| PostgreSQL | 11, 12 and 13 | -| Doctrine ORM | ^2.9 | -| Doctrine DBAL | ^2.13 and ^3.1 | +| Dependency | Supported Versions | +|:--------------|:-------------------| +| PostGIS | 3.0 and 3.1 | +| PostgreSQL | < 15 | +| Doctrine ORM | ^3.0.0 | +| Doctrine DBAL | ^4.1.1 | Installation -- @@ -44,30 +43,29 @@ composer require jsor/doctrine-postgis Check the [Packagist page](https://packagist.org/packages/jsor/doctrine-postgis) for all available versions. -Setup +Symfony Setup -- -To use the library with the Doctrine ORM, register the -`ORMSchemaEventSubscriber` event subscriber. +**Manual Bundle Registration** -```php -use Jsor\Doctrine\PostGIS\Event\ORMSchemaEventSubscriber; +If Symfony Flex does not automatically register the bundle, you can manually add it to your ``config/bundles.php`` file: -$entityManager->getEventManager()->addEventSubscriber(new ORMSchemaEventSubscriber()); +```php +return [ + // Other bundles... + Jsor\Doctrine\PostGIS\JsorDoctrinePostgisBundle::class => ['all' => true], +]; ``` -To use it with the DBAL only, register the `DBALSchemaEventSubscriber` event -subscriber. +For integrating this library into a Symfony project, configure the schema manager factory in your ``doctrine.yaml``: ```php -use Jsor\Doctrine\PostGIS\Event\DBALSchemaEventSubscriber; - -$connection->getEventManager()->addEventSubscriber(new DBALSchemaEventSubscriber()); +# config/packages/doctrine.yaml +doctrine: + dbal: + schema_manager_factory: Jsor\Doctrine\PostGIS\Schema\PostGISSchemaManagerFactory + # rest of your configuration... ``` -### Symfony - -For integrating this library into a Symfony project, read the dedicated -[Symfony Documentation](docs/symfony.md). Property Mapping -- @@ -95,10 +93,10 @@ class MyEntity There are 2 options to configure the geometry. * `geometry_type` - This defines the type of the geometry, like `POINT`, `LINESTRING` etc. - If you omit this option, the generic type `GEOMETRY` is used. + This defines the type of the geometry, like `POINT`, `LINESTRING` etc. + If you omit this option, the generic type `GEOMETRY` is used. * `srid` - This defines the Spatial Reference System Identifier (SRID) of the geometry. + This defines the Spatial Reference System Identifier (SRID) of the geometry. ### Example @@ -177,15 +175,18 @@ class MyEntity Schema Tool -- -Full support for the [ORM Schema Tool](https://docs.doctrine-project.org/projects/doctrine-orm/en/latest/reference/tools.html) -and the [DBAL Schema Manager](https://docs.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/schema-manager.html) +Full support for +the [ORM Schema Tool](https://docs.doctrine-project.org/projects/doctrine-orm/en/latest/reference/tools.html) +and +the [DBAL Schema Manager](https://docs.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/schema-manager.html) is provided. DQL Functions -- Most [PostGIS functions](https://postgis.net/docs/reference.html) are also -available for the [Doctrine Query Language](https://www.doctrine-project.org/projects/doctrine-orm/en/latest/reference/dql-doctrine-query-language.html) +available for +the [Doctrine Query Language](https://www.doctrine-project.org/projects/doctrine-orm/en/latest/reference/dql-doctrine-query-language.html) (DQL) under the `Jsor\Doctrine\PostGIS\Functions` namespace. For a full list of all supported functions, see the @@ -195,7 +196,7 @@ For a full list of all supported functions, see the > how to configure the functions with Symfony. The functions must be registered with the `Doctrine\ORM\Configuration` instance. - + ```php $configuration = new Doctrine\ORM\Configuration(); @@ -313,7 +314,7 @@ PHP container connected to specific database containers. The script names follow the pattern `run--.sh`. -To run the test suite against PostgreSQL 13 with PostGIS 3.1, use the script +To run the test suite against PostgreSQL 13 with PostGIS 3.1, use the script `./docker/run-13-31.sh`. ```bash diff --git a/composer.json b/composer.json index 695b85c6..028787d8 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,6 @@ { "name": "jsor/doctrine-postgis", + "type": "symfony-bundle", "description": "Spatial and Geographic Data with PostGIS and Doctrine.", "keywords": [ "spatial", @@ -23,15 +24,20 @@ ], "require": { "php": "^8.0", - "doctrine/dbal": "^2.13 || ^3.1" + "doctrine/dbal": "^4.1.1", + "symfony/framework-bundle": "^7.2", + "symfony/runtime": "^7.2" }, "require-dev": { - "doctrine/orm": "^2.9", + "doctrine/orm": "^3.0.0", "friendsofphp/php-cs-fixer": "^3.13", "phpunit/phpunit": "^9.5", "vimeo/psalm": "^5.9", "symfony/doctrine-bridge": "^5.4 || ^6.0", - "symfony/doctrine-messenger": "^5.4 || ^6.0" + "symfony/doctrine-messenger": "^5.4 || ^6.0", + "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0", + "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0", + "symfony/config": "^5.4 || ^6.0 || ^7.0" }, "suggest": { "doctrine/orm": "For using with the Doctrine ORM" @@ -50,5 +56,10 @@ "branch-alias": { "dev-main": "2.x-dev" } + }, + "config": { + "allow-plugins": { + "symfony/runtime": true + } } } diff --git a/docs/symfony.md b/docs/symfony.md index 0ddd8e90..74d9920a 100644 --- a/docs/symfony.md +++ b/docs/symfony.md @@ -5,33 +5,8 @@ Before integrating this library into a Symfony project, read the general [installation instructions](../README.md#installation) and install the library via Composer. -* [Setup](#setup) * [Known Problems](#known-problems) -Setup --- - -To use the library with the Doctrine ORM (version 2.9 or higher is supported), -register a [Doctrine event subscriber](https://symfony.com/doc/current/doctrine/event_listeners_subscribers.html) -in `config/services.yml`. - -```yaml -services: - Jsor\Doctrine\PostGIS\Event\ORMSchemaEventSubscriber: - tags: - - { name: doctrine.event_subscriber, connection: default } -``` - -The library can also be used with DBAL only (versions 2.13 or higher and 3.1 or -higher are supported). - -```yaml -services: - Jsor\Doctrine\PostGIS\Event\DBALSchemaEventSubscriber: - tags: - - { name: doctrine.event_subscriber, connection: default } -``` - ### Database Types Register the DBAL types in the diff --git a/src/DependencyInjection/DoctrineSchemaManagerPass.php b/src/DependencyInjection/DoctrineSchemaManagerPass.php new file mode 100644 index 00000000..db310cc1 --- /dev/null +++ b/src/DependencyInjection/DoctrineSchemaManagerPass.php @@ -0,0 +1,40 @@ +register('app.postgis_schema_manager_factory', PostGISSchemaManagerFactory::class) + ->setPublic(true) + ; + + foreach ($container->findTaggedServiceIds('doctrine.dbal.connection') as $id => $tags) { + $connectionDef = $container->getDefinition($id); + $params = $connectionDef->getArgument(0); + + if (is_array($params)) { + $params['schema_manager_factory'] = new Reference('app.postgis_schema_manager_factory'); + $connectionDef->replaceArgument(0, $params); + } + } + + $doctrinePrefix = 'doctrine.dbal.default_connection.'; + + if ($container->hasParameter($doctrinePrefix . 'configuration')) { + $config = $container->getParameter($doctrinePrefix . 'configuration'); + + if (is_array($config)) { + $config['schema_manager_factory'] = 'app.postgis_schema_manager_factory'; + + $container->setParameter($doctrinePrefix . 'configuration', $config); + } + } + } +} diff --git a/src/Event/DBALSchemaEventSubscriber.php b/src/Event/DBALSchemaEventSubscriber.php deleted file mode 100644 index b686470f..00000000 --- a/src/Event/DBALSchemaEventSubscriber.php +++ /dev/null @@ -1,238 +0,0 @@ -getTable(); - - $spatialIndexes = []; - - foreach ($table->getIndexes() as $index) { - if (!$index->hasFlag('spatial')) { - continue; - } - - $spatialIndexes[] = $index; - $table->dropIndex($index->getName()); - } - - if (0 === count($spatialIndexes)) { - return; - } - - // Avoid this listener from creating a loop on this table when calling - // $platform->getCreateTableSQL() later - if ($table->hasOption(self::PROCESSING_TABLE_FLAG)) { - return; - } - - $table->addOption(self::PROCESSING_TABLE_FLAG, true); - - $platform = $args->getPlatform(); - - foreach ($platform->getCreateTableSQL($table) as $sql) { - $args->addSql($sql); - } - - $spatialIndexSqlGenerator = new SpatialIndexSqlGenerator($platform); - - foreach ($spatialIndexes as $index) { - $args->addSql($spatialIndexSqlGenerator->getSql($index, $table)); - } - - $args->preventDefault(); - } - - public function onSchemaAlterTable(SchemaAlterTableEventArgs $args): void - { - $platform = $args->getPlatform(); - $diff = $args->getTableDiff(); - - $spatialIndexes = []; - $addedIndexes = []; - $changedIndexes = []; - - foreach ($diff->addedIndexes as $index) { - if (!$index->hasFlag('spatial')) { - $addedIndexes[] = $index; - } else { - $spatialIndexes[] = $index; - } - } - - foreach ($diff->changedIndexes as $index) { - if (!$index->hasFlag('spatial')) { - $changedIndexes[] = $index; - } else { - $diff->removedIndexes[] = $index; - $spatialIndexes[] = $index; - } - } - - $diff->addedIndexes = $addedIndexes; - $diff->changedIndexes = $changedIndexes; - - $spatialIndexSqlGenerator = new SpatialIndexSqlGenerator($platform); - - $table = new Identifier(false !== $diff->newName ? $diff->newName : $diff->name); - - foreach ($spatialIndexes as $index) { - $args - ->addSql( - $spatialIndexSqlGenerator->getSql($index, $table) - ) - ; - } - } - - public function onSchemaAlterTableChangeColumn(SchemaAlterTableChangeColumnEventArgs $args): void - { - $columnDiff = $args->getColumnDiff(); - $column = $columnDiff->column; - - if (!$column->getType() instanceof PostGISType) { - return; - } - - $diff = $args->getTableDiff(); - $table = new Identifier(false !== $diff->newName ? $diff->newName : $diff->name); - - if ($columnDiff->hasChanged('type')) { - throw new RuntimeException('The type of a spatial column cannot be changed (Requested changing type from "' . ($columnDiff->fromColumn?->getType()?->getName() ?? 'N/A') . '" to "' . $column->getType()->getName() . '" for column "' . $column->getName() . '" in table "' . $table->getName() . '")'); - } - - if ($columnDiff->hasChanged('geometry_type')) { - throw new RuntimeException('The geometry_type of a spatial column cannot be changed (Requested changing type from "' . strtoupper((string) ($columnDiff->fromColumn?->getPlatformOption('geometry_type') ?? 'N/A')) . '" to "' . strtoupper((string) $column->getPlatformOption('geometry_type')) . '" for column "' . $column->getName() . '" in table "' . $table->getName() . '")'); - } - - if ($columnDiff->hasChanged('srid')) { - $args->addSql(sprintf( - "SELECT UpdateGeometrySRID('%s', '%s', %d)", - $table->getName(), - $column->getName(), - (int) $column->getPlatformOption('srid') - )); - } - } - - public function onSchemaColumnDefinition(SchemaColumnDefinitionEventArgs $args): void - { - /** @var array{type: string, default: string, field: string, isnotnull: int|bool, comment: string|null} $tableColumn */ - $tableColumn = array_change_key_case($args->getTableColumn(), CASE_LOWER); - $table = $args->getTable(); - - $schemaManager = new SchemaManager($args->getConnection()); - $info = null; - - if ('geometry' === $tableColumn['type']) { - $info = $schemaManager->getGeometrySpatialColumnInfo($table, $tableColumn['field']); - } elseif ('geography' === $tableColumn['type']) { - $info = $schemaManager->getGeographySpatialColumnInfo($table, $tableColumn['field']); - } - - if (null === $info) { - return; - } - - $default = null; - - if (isset($tableColumn['default']) - && 'NULL::geometry' !== $tableColumn['default'] - && 'NULL::geography' !== $tableColumn['default']) { - $default = $tableColumn['default']; - } - - $options = [ - 'notnull' => (bool) $tableColumn['isnotnull'], - 'default' => $default, - 'comment' => $tableColumn['comment'] ?? null, - ]; - - $column = new Column($tableColumn['field'], PostGISType::getType($tableColumn['type']), $options); - - $column - ->setPlatformOption('geometry_type', $info['type']) - ->setPlatformOption('srid', $info['srid']) - ; - - $args - ->setColumn($column) - ->preventDefault(); - } - - public function onSchemaIndexDefinition(SchemaIndexDefinitionEventArgs $args): void - { - /** @var array{name: string, columns: array, unique: bool, primary: bool, flags: array} $index */ - $index = $args->getTableIndex(); - - $schemaManager = new SchemaManager($args->getConnection()); - $spatialIndexes = $schemaManager->listSpatialIndexes($args->getTable()); - - if (!isset($spatialIndexes[$index['name']])) { - return; - } - - $spatialIndex = new Index( - $index['name'], - $index['columns'], - $index['unique'], - $index['primary'], - array_merge($index['flags'], ['spatial']) - ); - - $args - ->setIndex($spatialIndex) - ->preventDefault() - ; - } -} diff --git a/src/Event/ORMSchemaEventSubscriber.php b/src/Event/ORMSchemaEventSubscriber.php index fb51a38f..f4361cd1 100644 --- a/src/Event/ORMSchemaEventSubscriber.php +++ b/src/Event/ORMSchemaEventSubscriber.php @@ -5,21 +5,10 @@ namespace Jsor\Doctrine\PostGIS\Event; use Doctrine\ORM\Tools\Event\GenerateSchemaTableEventArgs; -use Doctrine\ORM\Tools\ToolEvents; use Jsor\Doctrine\PostGIS\Types\PostGISType; -class ORMSchemaEventSubscriber extends DBALSchemaEventSubscriber +class ORMSchemaEventSubscriber { - public function getSubscribedEvents(): array - { - return array_merge( - parent::getSubscribedEvents(), - [ - ToolEvents::postGenerateSchemaTable, - ] - ); - } - public function postGenerateSchemaTable(GenerateSchemaTableEventArgs $args): void { $table = $args->getClassTable(); diff --git a/src/JsorDoctrinePostgisBundle.php b/src/JsorDoctrinePostgisBundle.php new file mode 100644 index 00000000..45975f44 --- /dev/null +++ b/src/JsorDoctrinePostgisBundle.php @@ -0,0 +1,34 @@ +register(PostGISSchemaManagerFactory::class, PostGISSchemaManagerFactory::class) + ->setPublic(true) + ; + $container->register('jsor_postgis.orm_schema_subscriber', ORMSchemaEventSubscriber::class) + ->addTag( + 'doctrine.event_listener', + [ + 'event' => 'postGenerateSchemaTable', + 'method' => 'postGenerateSchemaTable', + ] + ) + ; + $container->addCompilerPass(new DoctrineSchemaManagerPass()); + } +} diff --git a/src/Kernel.php b/src/Kernel.php new file mode 100644 index 00000000..284f32c8 --- /dev/null +++ b/src/Kernel.php @@ -0,0 +1,26 @@ +getProjectDir() . '/config/bundles.php'; + + if (!isset($bundles[JsorDoctrinePostgisBundle::class])) { + $bundles[JsorDoctrinePostgisBundle::class] = ['all' => true]; + } + + foreach ($bundles as $class => $envs) { + if ($envs[$this->environment] ?? $envs['all'] ?? false) { + yield new $class(); + } + } + } +} diff --git a/src/Schema/PostGISSchemaManagerFactory.php b/src/Schema/PostGISSchemaManagerFactory.php new file mode 100644 index 00000000..bfc9f063 --- /dev/null +++ b/src/Schema/PostGISSchemaManagerFactory.php @@ -0,0 +1,26 @@ +getDatabasePlatform(); + + if ($platform instanceof PostgreSQLPlatform) { + return new PostgreSQLSchemaManager($connection, $platform); + } + + return $connection->createSchemaManager(); + } +} diff --git a/src/Schema/PostgreSQLSchemaManager.php b/src/Schema/PostgreSQLSchemaManager.php new file mode 100644 index 00000000..af808b90 --- /dev/null +++ b/src/Schema/PostgreSQLSchemaManager.php @@ -0,0 +1,144 @@ +registerPostGISTypes(); + + $geometryType = null; + $srid = null; + + if (isset($tableColumn['complete_type'])) { + if (preg_match('/geometry\(([^,]+),\s*(\d+)\)/i', $tableColumn['complete_type'], $matches)) { + $geometryType = strtoupper($matches[1]); + $srid = (int)$matches[2]; + } + } + + $column = new Column( + $tableColumn['field'], + Type::getType(PostGISType::GEOMETRY) + ); + + if (isset($tableColumn['default'])) { + $column->setDefault($tableColumn['default']); + } + + $column->setNotnull(isset($tableColumn['isnotnull']) ? (bool)$tableColumn['isnotnull'] : false); + + if (isset($tableColumn['comment'])) { + $column->setComment($tableColumn['comment']); + } + + if ($geometryType !== null) { + $column->setPlatformOption('geometry_type', $geometryType); + } + + if ($srid !== null) { + $column->setPlatformOption('srid', $srid); + } + + return $column; + } + + return parent::_getPortableTableColumnDefinition($tableColumn); + } + + /** + * Get PostGIS column information from the database + */ + private function getPostGISColumnInfo(string $table, string $column): ?array + { + try { + // Improved regex pattern for extracting geometry type and SRID + $sql = " + SELECT + format_type(a.atttypid, a.atttypmod) as full_type, + CASE + WHEN format_type(a.atttypid, a.atttypmod) ~ 'geometry\\(([^,]+),\\s*(\\d+)\\)' + THEN (regexp_matches(format_type(a.atttypid, a.atttypmod), 'geometry\\(([^,]+),\\s*(\\d+)\\)'))[1] + END as geometry_type, + CASE + WHEN format_type(a.atttypid, a.atttypmod) ~ 'geometry\\(([^,]+),\\s*(\\d+)\\)' + THEN (regexp_matches(format_type(a.atttypid, a.atttypmod), 'geometry\\(([^,]+),\\s*(\\d+)\\)'))[2]::integer + END as srid + FROM pg_attribute a + JOIN pg_class t ON a.attrelid = t.oid + JOIN pg_namespace n ON t.relnamespace = n.oid + WHERE t.relname = ? + AND a.attname = ? + AND NOT a.attisdropped"; + + $columnInfo = $this->_conn->fetchAssociative($sql, [ + $this->trimQuotes($table), + $this->trimQuotes($column), + ]); + + if ($columnInfo && $columnInfo['geometry_type'] !== null) { + return [ + 'geometry_type' => strtoupper($columnInfo['geometry_type']), + 'srid' => (int)$columnInfo['srid'], + ]; + } + + // Try fallback to geometry_columns/geography_columns + $schemaManager = new SchemaManager($this->_conn); + + $spatialInfo = $schemaManager->getGeometrySpatialColumnInfo($table, $column); + if ($spatialInfo) { + return $spatialInfo; + } + + $spatialInfo = $schemaManager->getGeographySpatialColumnInfo($table, $column); + if ($spatialInfo) { + return $spatialInfo; + } + } catch (\Exception $e) { + // Log error but continue + error_log('[PostGIS] Error fetching column info: ' . $e->getMessage()); + } + + return null; + } + + /** + * Register PostGIS types if not already registered + */ + private function registerPostGISTypes(): void + { + if (!Type::hasType(PostGISType::GEOMETRY)) { + Type::addType(PostGISType::GEOMETRY, GeometryType::class); + } + + if (!Type::hasType(PostGISType::GEOGRAPHY)) { + Type::addType(PostGISType::GEOGRAPHY, GeographyType::class); + } + } + + /** + * Helper method to trim quotes from identifiers + */ + private function trimQuotes(string $identifier): string + { + return str_replace(['`', '"', '[', ']'], '', $identifier); + } +}