From 5d0c51f74dd183ee0f2f296163c0805dcfc34072 Mon Sep 17 00:00:00 2001 From: Gabriel Roldan Date: Mon, 5 Jun 2023 14:39:48 -0300 Subject: [PATCH 1/2] Create project gs-cloud-catalog-backend-pgsql --- src/catalog/backends/pgsql/pom.xml | 43 ++++++++++++++++++++++++++++++ src/catalog/backends/pom.xml | 1 + 2 files changed, 44 insertions(+) create mode 100644 src/catalog/backends/pgsql/pom.xml diff --git a/src/catalog/backends/pgsql/pom.xml b/src/catalog/backends/pgsql/pom.xml new file mode 100644 index 000000000..6c99c1de6 --- /dev/null +++ b/src/catalog/backends/pgsql/pom.xml @@ -0,0 +1,43 @@ + + + 4.0.0 + + org.geoserver.cloud.catalog.backend + gs-cloud-catalog-backends + ${revision} + + gs-cloud-catalog-backend-pgsql + jar + PostgreSQL catalog backend + + + javax.servlet + javax.servlet-api + provided + + + org.geoserver.cloud.catalog.backend + gs-cloud-catalog-backend-common + + + org.geoserver.cloud.catalog + gs-cloud-catalog-events + true + + + org.springframework.boot + spring-boot-starter-actuator + true + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.boot + spring-boot-autoconfigure-processor + true + + + diff --git a/src/catalog/backends/pom.xml b/src/catalog/backends/pom.xml index 2ceb94004..6f995cf87 100644 --- a/src/catalog/backends/pom.xml +++ b/src/catalog/backends/pom.xml @@ -14,6 +14,7 @@ common datadir jdbcconfig + pgsql From d787395c5956a17c359287426bf065dc736b1d2e Mon Sep 17 00:00:00 2001 From: Gabriel Roldan Date: Mon, 5 Jun 2023 15:20:18 -0300 Subject: [PATCH 2/2] New Catalog/Config backend for PostgreSQL 15+ Introduce a new catalog backend for PostgreSQL 15+, enabled through the `pgconfig` spring profile. --- .github/workflows/build.yaml | 2 +- config | 2 +- docker-compose-pgconfig.yml | 72 ++++ pom.xml | 5 + .../gwc/src/main/resources/bootstrap.yml | 1 + .../src/main/resources/bootstrap.yml | 1 + .../wcs/src/main/resources/bootstrap.yml | 1 + .../webui/src/main/resources/bootstrap.yml | 1 + .../wfs/src/main/resources/bootstrap.yml | 1 + .../wms/src/main/resources/bootstrap.yml | 1 + .../wps/src/main/resources/bootstrap.yml | 1 + ...efaultUpdateSequenceAutoConfiguration.java | 5 +- .../DataDirectoryUpdateSequenceTest.java | 38 +- src/catalog/backends/jdbcconfig/pom.xml | 7 + .../JDBCConfigBackendConfigurer.java | 4 +- .../jdbcconfig/JdbcConfigUpdateSequence.java | 15 +- .../JdbcConfigUpdateSequenceTest.java | 43 +- .../src/test/resources/application.yml | 5 - src/catalog/backends/pgsql/pom.xml | 68 +++ .../ConditionalOnPgsqlBackendEnabled.java | 26 ++ .../pgsql/PgsqlBackendAutoConfiguration.java | 17 + .../PgsqlDataSourceAutoConfiguration.java | 18 + .../PgsqlMigrationAutoConfiguration.java | 17 + .../backend/pgsql/PgsqlBackendBuilder.java | 76 ++++ .../pgsql/catalog/PgsqlCatalogFacade.java | 34 ++ .../filter/PgsqlCatalogFilterSplitter.java | 49 +++ .../filter/PgsqlFilterCapabilities.java | 115 +++++ .../catalog/filter/PgsqlFilterToSQL.java | 289 +++++++++++++ .../catalog/filter/PgsqlQueryBuilder.java | 56 +++ .../ToPgsqlCompatibleFilterDuplicator.java | 199 +++++++++ .../repository/CatalogInfoRowMapper.java | 402 ++++++++++++++++++ .../PgsqlCatalogInfoRepository.java | 367 ++++++++++++++++ .../repository/PgsqlLayerGroupRepository.java | 85 ++++ .../repository/PgsqlLayerRepository.java | 97 +++++ .../repository/PgsqlNamespaceRepository.java | 83 ++++ .../repository/PgsqlResourceRepository.java | 121 ++++++ .../repository/PgsqlStoreRepository.java | 146 +++++++ .../repository/PgsqlStyleRepository.java | 85 ++++ .../repository/PgsqlWorkspaceRepository.java | 66 +++ .../repository/UncheckedSqlException.java | 26 ++ .../pgsql/config/PgsqlConfigRepository.java | 331 ++++++++++++++ .../pgsql/config/PgsqlGeoServerFacade.java | 24 ++ .../pgsql/config/PgsqlUpdateSequence.java | 76 ++++ .../gwc/GeoServerTileLayerInfoRowMapper.java | 22 + .../pgsql/gwc/PgsqlTileLayerCatalog.java | 221 ++++++++++ .../backend/pgsql/gwc/PgsqlTileLayerInfo.java | 25 ++ .../FileSystemResourceStoreCache.java | 149 +++++++ .../pgsql/resource/PgsqlLockProvider.java | 46 ++ .../backend/pgsql/resource/PgsqlResource.java | 185 ++++++++ .../resource/PgsqlResourceRowMapper.java | 48 +++ .../pgsql/resource/PgsqlResourceStore.java | 326 ++++++++++++++ .../pgsql/DatabaseMigrationConfiguration.java | 48 +++ .../pgsql/GeoServerConfigInitializer.java | 75 ++++ .../pgsql/PgsqlBackendConfiguration.java | 167 ++++++++ .../backend/pgsql/PgsqlBackendProperties.java | 29 ++ .../pgsql/PgsqlDataSourceConfiguration.java | 49 +++ .../pgsql/PgsqlDatabaseMigrations.java | 55 +++ .../backend/pgsql/PgsqlGeoServerLoader.java | 203 +++++++++ .../pgsql/PgsqlGeoServerResourceLoader.java | 27 ++ .../main/resources/META-INF/spring.factories | 5 + .../postgresql/V1_0__Catalog_Tables.sql | 219 ++++++++++ .../postgresql/V1_1__Catalog_Query_Tables.sql | 208 +++++++++ .../V1_2__Catalog_Full_Text_Search.sql | 14 + .../postgresql/V1_3__Config_Tables.sql | 57 +++ .../postgresql/V1_4__ResourceStore_Tables.sql | 139 ++++++ .../PgsqlBackendAutoConfigurationTest.java | 76 ++++ .../PgsqlDataSourceAutoConfigurationTest.java | 127 ++++++ .../PgsqlMigrationAutoConfigurationTest.java | 154 +++++++ .../PgsqlCatalogBackendConformanceTest.java | 80 ++++ .../PgsqlWorkspaceRepositoryTest.java | 69 +++ .../PgsqlConfigRepositoryConformanceTest.java | 76 ++++ .../pgsql/config/PgsqlUpdateSequenceTest.java | 83 ++++ .../resource/PgsqlResourceStoreTest.java | 142 +++++++ .../pgsql/resource/PgsqlResourceTest.java | 191 +++++++++ .../src/test/resources/application-test.yml | 10 + .../pgsql/src/test/resources/logback-test.xml | 17 + ...oteEventCacheEvictorTestConfiguration.java | 4 +- .../catalog/server/service/ProxyResolver.java | 6 +- ...AbstractReactiveCatalogControllerTest.java | 6 +- src/catalog/event-bus/pom.xml | 5 - .../cloud/event/bus/InfoEventResolver.java | 5 +- .../TestConfigurationAutoConfiguration.java | 4 +- .../TestConfigurationAutoConfiguration.java | 4 +- .../databind/catalog/dto/Namespace.java | 2 +- .../catalog/mapper/NamespaceMapper.java | 3 + .../jackson/databind/config/dto/Service.java | 7 +- .../dto/mapper/GeoServerConfigMapper.java | 10 + .../jackson/databind/mapper/PatchMapper.java | 2 +- .../catalog/GeoServerCatalogModuleTest.java | 4 +- .../catalog/PatchSerializationTest.java | 4 +- .../config/GeoServerConfigModuleTest.java | 6 +- .../org/geoserver/catalog/plugin/Patch.java | 7 +- .../resolving/CatalogPropertyResolver.java | 49 +++ .../catalog/plugin/resolving}/ProxyUtils.java | 15 +- .../resolving/ResolvingProxyResolver.java | 7 +- .../config/DefaultUpdateSequence.java | 15 +- .../plugin/CatalogConformanceTest.java | 17 +- .../config/DefaultUpdateSequenceTest.java | 36 ++ .../config/UpdateSequenceConformanceTest.java | 53 +++ .../JNDIDataSourceAutoConfiguration.java | 108 +---- .../jndidatasource/JNDIDatasourceConfig.java | 5 + .../jndidatasource/JNDIInitializer.java | 120 ++++++ src/pom.xml | 14 + src/starters/catalog-backend/pom.xml | 4 + 104 files changed, 6733 insertions(+), 209 deletions(-) create mode 100644 docker-compose-pgconfig.yml create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/ConditionalOnPgsqlBackendEnabled.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/PgsqlBackendAutoConfiguration.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/PgsqlDataSourceAutoConfiguration.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/PgsqlMigrationAutoConfiguration.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/PgsqlBackendBuilder.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/PgsqlCatalogFacade.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/filter/PgsqlCatalogFilterSplitter.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/filter/PgsqlFilterCapabilities.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/filter/PgsqlFilterToSQL.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/filter/PgsqlQueryBuilder.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/filter/ToPgsqlCompatibleFilterDuplicator.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/CatalogInfoRowMapper.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlCatalogInfoRepository.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlLayerGroupRepository.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlLayerRepository.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlNamespaceRepository.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlResourceRepository.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlStoreRepository.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlStyleRepository.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlWorkspaceRepository.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/UncheckedSqlException.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/config/PgsqlConfigRepository.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/config/PgsqlGeoServerFacade.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/config/PgsqlUpdateSequence.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/gwc/GeoServerTileLayerInfoRowMapper.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/gwc/PgsqlTileLayerCatalog.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/gwc/PgsqlTileLayerInfo.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/resource/FileSystemResourceStoreCache.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/resource/PgsqlLockProvider.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/resource/PgsqlResource.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/resource/PgsqlResourceRowMapper.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/resource/PgsqlResourceStore.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/DatabaseMigrationConfiguration.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/GeoServerConfigInitializer.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/PgsqlBackendConfiguration.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/PgsqlBackendProperties.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/PgsqlDataSourceConfiguration.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/PgsqlDatabaseMigrations.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/PgsqlGeoServerLoader.java create mode 100644 src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/PgsqlGeoServerResourceLoader.java create mode 100644 src/catalog/backends/pgsql/src/main/resources/META-INF/spring.factories create mode 100644 src/catalog/backends/pgsql/src/main/resources/db/pgsqlcatalog/migration/postgresql/V1_0__Catalog_Tables.sql create mode 100644 src/catalog/backends/pgsql/src/main/resources/db/pgsqlcatalog/migration/postgresql/V1_1__Catalog_Query_Tables.sql create mode 100644 src/catalog/backends/pgsql/src/main/resources/db/pgsqlcatalog/migration/postgresql/V1_2__Catalog_Full_Text_Search.sql create mode 100644 src/catalog/backends/pgsql/src/main/resources/db/pgsqlcatalog/migration/postgresql/V1_3__Config_Tables.sql create mode 100644 src/catalog/backends/pgsql/src/main/resources/db/pgsqlcatalog/migration/postgresql/V1_4__ResourceStore_Tables.sql create mode 100644 src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/PgsqlBackendAutoConfigurationTest.java create mode 100644 src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/PgsqlDataSourceAutoConfigurationTest.java create mode 100644 src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/PgsqlMigrationAutoConfigurationTest.java create mode 100644 src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/backend/pgsql/catalog/PgsqlCatalogBackendConformanceTest.java create mode 100644 src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlWorkspaceRepositoryTest.java create mode 100644 src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/backend/pgsql/config/PgsqlConfigRepositoryConformanceTest.java create mode 100644 src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/backend/pgsql/config/PgsqlUpdateSequenceTest.java create mode 100644 src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/backend/pgsql/resource/PgsqlResourceStoreTest.java create mode 100644 src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/backend/pgsql/resource/PgsqlResourceTest.java create mode 100644 src/catalog/backends/pgsql/src/test/resources/application-test.yml create mode 100644 src/catalog/backends/pgsql/src/test/resources/logback-test.xml rename src/catalog/{jackson-bindings/geoserver/src/main/java/org/geoserver/jackson/databind/catalog => plugin/src/main/java/org/geoserver/catalog/plugin/resolving}/ProxyUtils.java (95%) create mode 100644 src/catalog/plugin/src/test/java/org/geoserver/platform/config/DefaultUpdateSequenceTest.java create mode 100644 src/catalog/plugin/src/test/java/org/geoserver/platform/config/UpdateSequenceConformanceTest.java create mode 100644 src/library/spring-boot-simplejndi/src/main/java/org/geoserver/cloud/config/jndidatasource/JNDIInitializer.java diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index b138cb98f..64477167d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -4,7 +4,7 @@ name: Build on any branch on: push: branches: - - '*' + - '**' - "!main" paths: - "Makefile" diff --git a/config b/config index 53034be5c..1c0a53c66 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 53034be5c53fb0364377bff67b29b4cc58bb9afa +Subproject commit 1c0a53c6609f1b5c81f5710e33bcf0409866b07e diff --git a/docker-compose-pgconfig.yml b/docker-compose-pgconfig.yml new file mode 100644 index 000000000..3ed57e41f --- /dev/null +++ b/docker-compose-pgconfig.yml @@ -0,0 +1,72 @@ +version: "3.8" + +volumes: + pgconfig_data: # volume for postgresql data, used to store the geoserver config through pgsqlconfig backend + +# +# Configures all geoserver services to use the postgresql database server with jdbcconfig as catalog backend. +# Run with `docker-compose --compatibility -f docker-compose.yml -f docker-compose-jdbcconfig.yml up -d` +# + +services: + pgconfig: + image: postgres:15 + shm_size: 2g + environment: + POSTGRES_DB: pgconfig + POSTGRES_USER: pgconfig + POSTGRES_PASSWORD: pgconfig + ports: + - 54322:5432 + networks: + - gs-cloud-network + volumes: + - pgconfig_data:/var/lib/postgresql/data + deploy: + resources: + limits: + cpus: '4.0' + memory: 4G + + + wfs: + environment: + SPRING_PROFILES_ACTIVE: "${DEFAULT_PROFILES},pgconfig" + depends_on: + - pgconfig + + wms: + environment: + SPRING_PROFILES_ACTIVE: "${DEFAULT_PROFILES},pgconfig" + depends_on: + - pgconfig + + wcs: + environment: + SPRING_PROFILES_ACTIVE: "${DEFAULT_PROFILES},pgconfig" + depends_on: + - pgconfig + + rest: + environment: + SPRING_PROFILES_ACTIVE: "${DEFAULT_PROFILES},pgconfig" + depends_on: + - pgconfig + + webui: + environment: + SPRING_PROFILES_ACTIVE: "${DEFAULT_PROFILES},pgconfig" + depends_on: + - pgconfig + + gwc: + environment: + SPRING_PROFILES_ACTIVE: "${DEFAULT_PROFILES},pgconfig" + depends_on: + - pgconfig + + wps: + environment: + SPRING_PROFILES_ACTIVE: "${DEFAULT_PROFILES},pgconfig" + depends_on: + - pgconfig diff --git a/pom.xml b/pom.xml index 172d6fd58..be557b1bb 100644 --- a/pom.xml +++ b/pom.xml @@ -136,6 +136,11 @@ gs-cloud-catalog-backend-catalog-service ${project.version} + + org.geoserver.cloud.catalog.backend + gs-cloud-catalog-backend-pgsql + ${project.version} + org.geoserver.cloud gwc-cloud-core diff --git a/src/apps/geoserver/gwc/src/main/resources/bootstrap.yml b/src/apps/geoserver/gwc/src/main/resources/bootstrap.yml index 7adb72e0a..869612237 100644 --- a/src/apps/geoserver/gwc/src/main/resources/bootstrap.yml +++ b/src/apps/geoserver/gwc/src/main/resources/bootstrap.yml @@ -61,6 +61,7 @@ spring: - org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration - org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSecurityAutoConfiguration + - org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration # override default of true, this service does not use the registry (when eureka client is enabled) eureka.client.fetch-registry: false diff --git a/src/apps/geoserver/restconfig/src/main/resources/bootstrap.yml b/src/apps/geoserver/restconfig/src/main/resources/bootstrap.yml index 689b159bc..d56d0b036 100644 --- a/src/apps/geoserver/restconfig/src/main/resources/bootstrap.yml +++ b/src/apps/geoserver/restconfig/src/main/resources/bootstrap.yml @@ -35,6 +35,7 @@ spring: - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration - org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSecurityAutoConfiguration - org.springframework.boot.autoconfigure.freemarker.FreeMarkerAutoConfiguration + - org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration # override default of true, this service does not use the registry (when eureka client is enabled) diff --git a/src/apps/geoserver/wcs/src/main/resources/bootstrap.yml b/src/apps/geoserver/wcs/src/main/resources/bootstrap.yml index 608de9aef..85f367dde 100644 --- a/src/apps/geoserver/wcs/src/main/resources/bootstrap.yml +++ b/src/apps/geoserver/wcs/src/main/resources/bootstrap.yml @@ -34,6 +34,7 @@ spring: - org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration - org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSecurityAutoConfiguration + - org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration # override default of true, this service does not use the registry (when eureka client is enabled) eureka.client.fetch-registry: false diff --git a/src/apps/geoserver/webui/src/main/resources/bootstrap.yml b/src/apps/geoserver/webui/src/main/resources/bootstrap.yml index f05591207..b490e6e03 100644 --- a/src/apps/geoserver/webui/src/main/resources/bootstrap.yml +++ b/src/apps/geoserver/webui/src/main/resources/bootstrap.yml @@ -42,6 +42,7 @@ spring: - org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSecurityAutoConfiguration - org.springframework.boot.autoconfigure.ldap.LdapAutoConfiguration - org.springframework.boot.autoconfigure.freemarker.FreeMarkerAutoConfiguration + - org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration # Force disabling GWC UI, it's embedded in gwc-core, so can't have a ConditionalOnClass - org.geoserver.cloud.autoconfigure.web.gwc.GeoWebCacheUIAutoConfiguration diff --git a/src/apps/geoserver/wfs/src/main/resources/bootstrap.yml b/src/apps/geoserver/wfs/src/main/resources/bootstrap.yml index 16453c78b..0058360d1 100644 --- a/src/apps/geoserver/wfs/src/main/resources/bootstrap.yml +++ b/src/apps/geoserver/wfs/src/main/resources/bootstrap.yml @@ -36,6 +36,7 @@ spring: - org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration - org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSecurityAutoConfiguration + - org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration # override default of true, this service does not use the registry (when eureka client is enabled) eureka.client.fetch-registry: false diff --git a/src/apps/geoserver/wms/src/main/resources/bootstrap.yml b/src/apps/geoserver/wms/src/main/resources/bootstrap.yml index dffc9acc0..de1cc548e 100644 --- a/src/apps/geoserver/wms/src/main/resources/bootstrap.yml +++ b/src/apps/geoserver/wms/src/main/resources/bootstrap.yml @@ -43,6 +43,7 @@ spring: - org.springframework.boot.autoconfigure.freemarker.FreeMarkerAutoConfiguration # Force disabling GWC UI, it's embedded in gwc-core, so can't have a ConditionalOnClass - org.geoserver.cloud.autoconfigure.web.gwc.GeoWebCacheUIAutoConfiguration + - org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration # override default of true, this service does not use the registry (when eureka client is enabled) eureka.client.fetch-registry: false diff --git a/src/apps/geoserver/wps/src/main/resources/bootstrap.yml b/src/apps/geoserver/wps/src/main/resources/bootstrap.yml index 61fd82456..2760031d3 100644 --- a/src/apps/geoserver/wps/src/main/resources/bootstrap.yml +++ b/src/apps/geoserver/wps/src/main/resources/bootstrap.yml @@ -34,6 +34,7 @@ spring: - org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration - org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSecurityAutoConfiguration + - org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration # override default of true, this service does not use the registry (when eureka client is enabled) eureka.client.fetch-registry: false diff --git a/src/catalog/backends/common/src/main/java/org/geoserver/cloud/autoconfigure/catalog/backend/core/DefaultUpdateSequenceAutoConfiguration.java b/src/catalog/backends/common/src/main/java/org/geoserver/cloud/autoconfigure/catalog/backend/core/DefaultUpdateSequenceAutoConfiguration.java index c7aa3f5ee..98ae410f4 100644 --- a/src/catalog/backends/common/src/main/java/org/geoserver/cloud/autoconfigure/catalog/backend/core/DefaultUpdateSequenceAutoConfiguration.java +++ b/src/catalog/backends/common/src/main/java/org/geoserver/cloud/autoconfigure/catalog/backend/core/DefaultUpdateSequenceAutoConfiguration.java @@ -4,6 +4,7 @@ */ package org.geoserver.cloud.autoconfigure.catalog.backend.core; +import org.geoserver.config.GeoServer; import org.geoserver.platform.config.DefaultUpdateSequence; import org.geoserver.platform.config.UpdateSequence; import org.springframework.boot.autoconfigure.AutoConfiguration; @@ -14,7 +15,7 @@ @ConditionalOnMissingBean(UpdateSequence.class) public class DefaultUpdateSequenceAutoConfiguration { @Bean - UpdateSequence defaultUpdateSequence() { - return new DefaultUpdateSequence(); + UpdateSequence defaultUpdateSequence(GeoServer gs) { + return new DefaultUpdateSequence(gs); } } diff --git a/src/catalog/backends/datadir/src/test/java/org/geoserver/cloud/autoconfigure/catalog/backend/datadir/DataDirectoryUpdateSequenceTest.java b/src/catalog/backends/datadir/src/test/java/org/geoserver/cloud/autoconfigure/catalog/backend/datadir/DataDirectoryUpdateSequenceTest.java index 8ddfb6607..5e6aa9f8b 100644 --- a/src/catalog/backends/datadir/src/test/java/org/geoserver/cloud/autoconfigure/catalog/backend/datadir/DataDirectoryUpdateSequenceTest.java +++ b/src/catalog/backends/datadir/src/test/java/org/geoserver/cloud/autoconfigure/catalog/backend/datadir/DataDirectoryUpdateSequenceTest.java @@ -4,17 +4,15 @@ */ package org.geoserver.cloud.autoconfigure.catalog.backend.datadir; -import static org.junit.jupiter.api.Assertions.assertEquals; - import org.geoserver.cloud.config.catalog.backend.datadirectory.DataDirectoryBackendConfiguration; import org.geoserver.cloud.config.catalog.backend.datadirectory.DataDirectoryUpdateSequence; -import org.junit.jupiter.api.Test; +import org.geoserver.config.GeoServer; +import org.geoserver.platform.config.UpdateSequence; +import org.geoserver.platform.config.UpdateSequenceConformanceTest; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; -import java.util.stream.IntStream; - /** * Test {@link DataDirectoryBackendConfiguration} through {@link DataDirectoryAutoConfiguration} * when {@code geoserver.backend.data-directory.enabled=true} @@ -26,32 +24,18 @@ "geoserver.backend.dataDirectory.location=/tmp/data_dir_autoconfiguration_test" }) @ActiveProfiles("test") -public class DataDirectoryUpdateSequenceTest { +public class DataDirectoryUpdateSequenceTest implements UpdateSequenceConformanceTest { private @Autowired DataDirectoryUpdateSequence updateSequence; + private @Autowired GeoServer geoserver; - public @Test void sequentialTest() { - final long initial = updateSequence.currValue(); - long v = updateSequence.currValue(); - assertEquals(initial, v); - v = updateSequence.nextValue(); - assertEquals(1 + initial, v); - v = updateSequence.currValue(); - assertEquals(1 + initial, v); - v = updateSequence.nextValue(); - assertEquals(2 + initial, v); - v = updateSequence.currValue(); - assertEquals(2 + initial, v); + @Override + public UpdateSequence getUpdataSequence() { + return updateSequence; } - public @Test void multiThreadedTest() { - final int incrementCount = 10_000; - final long initial = updateSequence.currValue(); - final long expected = initial + incrementCount; - - IntStream.range(0, incrementCount).parallel().forEach(i -> updateSequence.nextValue()); - - long v = updateSequence.currValue(); - assertEquals(expected, v); + @Override + public GeoServer getGeoSever() { + return geoserver; } } diff --git a/src/catalog/backends/jdbcconfig/pom.xml b/src/catalog/backends/jdbcconfig/pom.xml index 8e31d83dd..6476eaaa4 100644 --- a/src/catalog/backends/jdbcconfig/pom.xml +++ b/src/catalog/backends/jdbcconfig/pom.xml @@ -83,5 +83,12 @@ 1.4.200 test + + org.geoserver.cloud.catalog + gs-cloud-catalog-plugin + ${project.version} + test-jar + test + diff --git a/src/catalog/backends/jdbcconfig/src/main/java/org/geoserver/cloud/config/catalog/backend/jdbcconfig/JDBCConfigBackendConfigurer.java b/src/catalog/backends/jdbcconfig/src/main/java/org/geoserver/cloud/config/catalog/backend/jdbcconfig/JDBCConfigBackendConfigurer.java index bd33bfc3b..ebe5e35f6 100644 --- a/src/catalog/backends/jdbcconfig/src/main/java/org/geoserver/cloud/config/catalog/backend/jdbcconfig/JDBCConfigBackendConfigurer.java +++ b/src/catalog/backends/jdbcconfig/src/main/java/org/geoserver/cloud/config/catalog/backend/jdbcconfig/JDBCConfigBackendConfigurer.java @@ -136,7 +136,9 @@ public JDBCConfigBackendConfigurer( public @Override UpdateSequence updateSequence() { DataSource dataSource = jdbcConfigDataSource(); CloudJdbcConfigProperties props = jdbcConfigProperties(); - return new JdbcConfigUpdateSequence(dataSource, props); + GeoServerFacade geoserverFacade = geoserverFacade(); + ConfigDatabase db = jdbcConfigDB(); + return new JdbcConfigUpdateSequence(dataSource, props, geoserverFacade, db); } @Bean diff --git a/src/catalog/backends/jdbcconfig/src/main/java/org/geoserver/cloud/config/catalog/backend/jdbcconfig/JdbcConfigUpdateSequence.java b/src/catalog/backends/jdbcconfig/src/main/java/org/geoserver/cloud/config/catalog/backend/jdbcconfig/JdbcConfigUpdateSequence.java index 9250119c6..82189aca0 100644 --- a/src/catalog/backends/jdbcconfig/src/main/java/org/geoserver/cloud/config/catalog/backend/jdbcconfig/JdbcConfigUpdateSequence.java +++ b/src/catalog/backends/jdbcconfig/src/main/java/org/geoserver/cloud/config/catalog/backend/jdbcconfig/JdbcConfigUpdateSequence.java @@ -9,6 +9,9 @@ import lombok.NonNull; import lombok.RequiredArgsConstructor; +import org.geoserver.config.GeoServerFacade; +import org.geoserver.config.GeoServerInfo; +import org.geoserver.jdbcconfig.internal.ConfigDatabase; import org.geoserver.platform.config.UpdateSequence; import org.springframework.beans.factory.InitializingBean; @@ -29,6 +32,8 @@ public class JdbcConfigUpdateSequence implements UpdateSequence, InitializingBea private final @NonNull DataSource dataSource; private final @NonNull CloudJdbcConfigProperties props; + private final @NonNull GeoServerFacade geoServer; + private final @NonNull ConfigDatabase db; private String incrementAndGetQuery; private String getQuery; @@ -39,8 +44,14 @@ public long currValue() { } @Override - public long nextValue() { - return runAndGetLong(this.incrementAndGetQuery); + public synchronized long nextValue() { + long nextValue = runAndGetLong(this.incrementAndGetQuery); + GeoServerInfo global = geoServer.getGlobal(); + if (global != null) { + global.setUpdateSequence(nextValue); + db.save(global); + } + return nextValue; } @Override diff --git a/src/catalog/backends/jdbcconfig/src/test/java/org/geoserver/cloud/autoconfigure/catalog/backend/jdbcconfig/JdbcConfigUpdateSequenceTest.java b/src/catalog/backends/jdbcconfig/src/test/java/org/geoserver/cloud/autoconfigure/catalog/backend/jdbcconfig/JdbcConfigUpdateSequenceTest.java index e6f31e011..35435e109 100644 --- a/src/catalog/backends/jdbcconfig/src/test/java/org/geoserver/cloud/autoconfigure/catalog/backend/jdbcconfig/JdbcConfigUpdateSequenceTest.java +++ b/src/catalog/backends/jdbcconfig/src/test/java/org/geoserver/cloud/autoconfigure/catalog/backend/jdbcconfig/JdbcConfigUpdateSequenceTest.java @@ -4,47 +4,36 @@ */ package org.geoserver.cloud.autoconfigure.catalog.backend.jdbcconfig; -import static org.junit.jupiter.api.Assertions.assertEquals; - import org.geoserver.cloud.config.catalog.backend.jdbcconfig.JdbcConfigUpdateSequence; +import org.geoserver.config.GeoServer; +import org.geoserver.platform.config.UpdateSequence; +import org.geoserver.platform.config.UpdateSequenceConformanceTest; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import java.util.stream.IntStream; - @SpringBootTest( classes = AutoConfigurationTestConfiguration.class, properties = "geoserver.backend.jdbcconfig.enabled=true") -public class JdbcConfigUpdateSequenceTest extends JDBCConfigTest { +public class JdbcConfigUpdateSequenceTest extends JDBCConfigTest + implements UpdateSequenceConformanceTest { private @Autowired JdbcConfigUpdateSequence updateSequence; - @Disabled( - "Couldn't get rid of the DB closed error if running more than one test, so better run the parallel one") - public @Test void testUpdateSequence() { - final long initial = updateSequence.currValue(); - long v = updateSequence.currValue(); - assertEquals(initial, v); - v = updateSequence.nextValue(); - assertEquals(1 + initial, v); - v = updateSequence.currValue(); - assertEquals(1 + initial, v); - v = updateSequence.nextValue(); - assertEquals(2 + initial, v); - v = updateSequence.currValue(); - assertEquals(2 + initial, v); + @Override + public UpdateSequence getUpdataSequence() { + return updateSequence; } - public @Test void multiThreadedTest() { - final int incrementCount = 10_000; - final long initial = updateSequence.currValue(); - final long expected = initial + incrementCount; - - IntStream.range(0, incrementCount).parallel().forEach(i -> updateSequence.nextValue()); + @Override + public GeoServer getGeoSever() { + return super.geoServer; + } - long v = updateSequence.currValue(); - assertEquals(expected, v); + @Disabled( + "Couldn't get rid of the DB closed error if running more than one test, so better just run the parallel one") + public @Override @Test void testUpdateSequence() { + // no-op } } diff --git a/src/catalog/backends/jdbcconfig/src/test/resources/application.yml b/src/catalog/backends/jdbcconfig/src/test/resources/application.yml index 9c5741934..c2fad0b0f 100644 --- a/src/catalog/backends/jdbcconfig/src/test/resources/application.yml +++ b/src/catalog/backends/jdbcconfig/src/test/resources/application.yml @@ -5,19 +5,14 @@ spring: allow-bean-definition-overriding: true # false by default since spring-boot 2.6.0, breaks geoserver initialization allow-circular-references: true - cloud.bus.enabled: false autoconfigure: exclude: - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration - -eureka.client.enabled: false - geoserver: backend: jdbcconfig: enabled: false - web.enabled: false initdb: true cache-directory: ${java.io.tmpdir}/geoserver-jdbcconfig-cache datasource: diff --git a/src/catalog/backends/pgsql/pom.xml b/src/catalog/backends/pgsql/pom.xml index 6c99c1de6..288ff5760 100644 --- a/src/catalog/backends/pgsql/pom.xml +++ b/src/catalog/backends/pgsql/pom.xml @@ -24,6 +24,39 @@ gs-cloud-catalog-events true + + org.springframework.boot + spring-boot-starter-jdbc + + + + org.springframework.integration + spring-integration-jdbc + + + org.geoserver.cloud + spring-boot-simplejndi + true + + + org.geoserver.cloud.catalog.jackson + gs-jackson-bindings + + + org.flywaydb + flyway-core + + + org.postgresql + postgresql + + + + + org.geoserver + gs-gwc + true + org.springframework.boot spring-boot-starter-actuator @@ -39,5 +72,40 @@ spring-boot-autoconfigure-processor true + + org.springframework.boot + spring-boot-starter-test + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + + + org.geoserver + gs-main + ${gs.version} + test-jar + test + + + org.geoserver + gs-platform + ${gs.version} + test-jar + test + + + + org.junit.vintage + junit-vintage-engine + test + diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/ConditionalOnPgsqlBackendEnabled.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/ConditionalOnPgsqlBackendEnabled.java new file mode 100644 index 000000000..4ec3c4358 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/ConditionalOnPgsqlBackendEnabled.java @@ -0,0 +1,26 @@ +/* + * (c) 2020 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.autoconfigure.catalog.backend.pgsql; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * @since 1.4 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD, ElementType.TYPE}) +@Documented +@ConditionalOnProperty( + prefix = "geoserver.backend.pgconfig", + name = "enabled", + havingValue = "true", + matchIfMissing = false) +public @interface ConditionalOnPgsqlBackendEnabled {} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/PgsqlBackendAutoConfiguration.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/PgsqlBackendAutoConfiguration.java new file mode 100644 index 000000000..11f202123 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/PgsqlBackendAutoConfiguration.java @@ -0,0 +1,17 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.autoconfigure.catalog.backend.pgsql; + +import org.geoserver.cloud.config.catalog.backend.pgsql.PgsqlBackendConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Import; + +/** + * @since 1.4 + */ +@AutoConfiguration(after = PgsqlMigrationAutoConfiguration.class) +@ConditionalOnPgsqlBackendEnabled +@Import(PgsqlBackendConfiguration.class) +public class PgsqlBackendAutoConfiguration {} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/PgsqlDataSourceAutoConfiguration.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/PgsqlDataSourceAutoConfiguration.java new file mode 100644 index 000000000..ce17ca279 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/PgsqlDataSourceAutoConfiguration.java @@ -0,0 +1,18 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.autoconfigure.catalog.backend.pgsql; + +import org.geoserver.cloud.config.catalog.backend.pgsql.PgsqlDataSourceConfiguration; +import org.geoserver.cloud.config.jndidatasource.JNDIDataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Import; + +/** + * @since 1.4 + */ +@AutoConfiguration(after = JNDIDataSourceAutoConfiguration.class) +@ConditionalOnPgsqlBackendEnabled +@Import(PgsqlDataSourceConfiguration.class) +public class PgsqlDataSourceAutoConfiguration {} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/PgsqlMigrationAutoConfiguration.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/PgsqlMigrationAutoConfiguration.java new file mode 100644 index 000000000..1360c3323 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/PgsqlMigrationAutoConfiguration.java @@ -0,0 +1,17 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.autoconfigure.catalog.backend.pgsql; + +import org.geoserver.cloud.config.catalog.backend.pgsql.DatabaseMigrationConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Import; + +/** + * @since 1.4 + */ +@AutoConfiguration(after = PgsqlDataSourceAutoConfiguration.class) +@ConditionalOnPgsqlBackendEnabled +@Import(DatabaseMigrationConfiguration.class) +public class PgsqlMigrationAutoConfiguration {} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/PgsqlBackendBuilder.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/PgsqlBackendBuilder.java new file mode 100644 index 000000000..a2a16fabf --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/PgsqlBackendBuilder.java @@ -0,0 +1,76 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +import org.geoserver.catalog.Catalog; +import org.geoserver.catalog.CatalogInfo; +import org.geoserver.catalog.impl.CatalogImpl; +import org.geoserver.catalog.plugin.CatalogPlugin; +import org.geoserver.catalog.plugin.ExtendedCatalogFacade; +import org.geoserver.catalog.plugin.forwarding.ResolvingCatalogFacadeDecorator; +import org.geoserver.catalog.plugin.resolving.CatalogPropertyResolver; +import org.geoserver.catalog.plugin.resolving.CollectionPropertiesInitializer; +import org.geoserver.catalog.plugin.resolving.ResolvingProxyResolver; +import org.geoserver.cloud.backend.pgsql.catalog.PgsqlCatalogFacade; +import org.geoserver.cloud.backend.pgsql.config.PgsqlGeoServerFacade; +import org.geoserver.config.plugin.GeoServerImpl; +import org.geoserver.config.plugin.RepositoryGeoServerFacade; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.function.Function; + +import javax.sql.DataSource; + +/** + * @since 1.4 + */ +@RequiredArgsConstructor +public class PgsqlBackendBuilder { + + private final @NonNull DataSource dataSource; + + public CatalogPlugin createCatalog() { + return initCatalog(new CatalogPlugin()); + } + + public GeoServerImpl createGeoServer(Catalog catalog) { + RepositoryGeoServerFacade facade = createGeoServerFacade(); + GeoServerImpl gs = new GeoServerImpl(facade); + gs.setCatalog(catalog); + return gs; + } + + public RepositoryGeoServerFacade createGeoServerFacade() { + return new PgsqlGeoServerFacade(new JdbcTemplate(dataSource)); + } + + public ExtendedCatalogFacade createCatalogFacade(Catalog catalog) { + JdbcTemplate template = new JdbcTemplate(dataSource); + PgsqlCatalogFacade facade = new PgsqlCatalogFacade(template); + + return createResolvingCatalogFacade(catalog, facade); + } + + public static ExtendedCatalogFacade createResolvingCatalogFacade( + Catalog catalog, PgsqlCatalogFacade rawFacade) { + Function resolvingFunction = + CatalogPropertyResolver.of(catalog) + .andThen(ResolvingProxyResolver.of(catalog)) + .andThen(CollectionPropertiesInitializer.instance()); + + ResolvingCatalogFacadeDecorator resolving = new ResolvingCatalogFacadeDecorator(rawFacade); + resolving.setOutboundResolver(resolvingFunction); + return resolving; + } + + public C initCatalog(C catalog) { + ExtendedCatalogFacade facade = createCatalogFacade(catalog); + catalog.setFacade(facade); + return catalog; + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/PgsqlCatalogFacade.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/PgsqlCatalogFacade.java new file mode 100644 index 000000000..5e7f15bee --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/PgsqlCatalogFacade.java @@ -0,0 +1,34 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.catalog; + +import lombok.NonNull; + +import org.geoserver.catalog.plugin.RepositoryCatalogFacadeImpl; +import org.geoserver.cloud.backend.pgsql.catalog.repository.PgsqlLayerGroupRepository; +import org.geoserver.cloud.backend.pgsql.catalog.repository.PgsqlLayerRepository; +import org.geoserver.cloud.backend.pgsql.catalog.repository.PgsqlNamespaceRepository; +import org.geoserver.cloud.backend.pgsql.catalog.repository.PgsqlResourceRepository; +import org.geoserver.cloud.backend.pgsql.catalog.repository.PgsqlStoreRepository; +import org.geoserver.cloud.backend.pgsql.catalog.repository.PgsqlStyleRepository; +import org.geoserver.cloud.backend.pgsql.catalog.repository.PgsqlWorkspaceRepository; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * @since 1.4 + */ +public class PgsqlCatalogFacade extends RepositoryCatalogFacadeImpl { + + public PgsqlCatalogFacade(@NonNull JdbcTemplate template) { + super.setNamespaceRepository(new PgsqlNamespaceRepository(template)); + super.setWorkspaceRepository(new PgsqlWorkspaceRepository(template)); + super.setStoreRepository(new PgsqlStoreRepository(template)); + super.setResourceRepository(new PgsqlResourceRepository(template)); + super.setLayerRepository(new PgsqlLayerRepository(template)); + super.setLayerGroupRepository(new PgsqlLayerGroupRepository(template)); + super.setStyleRepository(new PgsqlStyleRepository(template)); + // super.setMapRepository(new PgsqlCatalogInfoRepository(template) {}); + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/filter/PgsqlCatalogFilterSplitter.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/filter/PgsqlCatalogFilterSplitter.java new file mode 100644 index 000000000..2693d0ef6 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/filter/PgsqlCatalogFilterSplitter.java @@ -0,0 +1,49 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.catalog.filter; + +import org.geotools.filter.visitor.PostPreProcessFilterSplittingVisitor; +import org.opengis.filter.Filter; +import org.opengis.filter.expression.PropertyName; + +import java.util.Set; + +/** + * Splits a {@link Filter} into supported and unsupported filters for SQL encoding and + * post-filtering, based on the supported column names provided in the constructor. + * + * @since 1.4 + */ +class PgsqlCatalogFilterSplitter extends PostPreProcessFilterSplittingVisitor { + + private Set supportedPropertyNames; + + public PgsqlCatalogFilterSplitter(Set supportedPropertyNames) { + super(PgsqlFilterCapabilities.capabilities(), null, null); + this.supportedPropertyNames = supportedPropertyNames; + } + + public static PgsqlCatalogFilterSplitter split( + Filter filter, Set supportedPropertyNames) { + PgsqlCatalogFilterSplitter splitter = + new PgsqlCatalogFilterSplitter(supportedPropertyNames); + filter.accept(splitter, null); + return splitter; + } + /** + * If the property name is supported, proceeds with the splitting, otherwise aborts splitting + * the current filter making it part of the unsupported filter result. + */ + @Override + public Object visit(PropertyName expression, Object notUsed) { + final String propertyName = expression.getPropertyName(); + if (supportedPropertyNames.contains(propertyName)) { + return super.visit(expression, notUsed); + } + + postStack.push(expression); + return null; + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/filter/PgsqlFilterCapabilities.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/filter/PgsqlFilterCapabilities.java new file mode 100644 index 000000000..6f522b2ea --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/filter/PgsqlFilterCapabilities.java @@ -0,0 +1,115 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.catalog.filter; + +import org.geotools.filter.FilterCapabilities; +import org.geotools.filter.LengthFunction; +import org.geotools.filter.function.DateDifferenceFunction; +import org.geotools.filter.function.FilterFunction_equalTo; +import org.geotools.filter.function.FilterFunction_strConcat; +import org.geotools.filter.function.FilterFunction_strEndsWith; +import org.geotools.filter.function.FilterFunction_strEqualsIgnoreCase; +import org.geotools.filter.function.FilterFunction_strIndexOf; +import org.geotools.filter.function.FilterFunction_strLength; +import org.geotools.filter.function.FilterFunction_strReplace; +import org.geotools.filter.function.FilterFunction_strStartsWith; +import org.geotools.filter.function.FilterFunction_strSubstring; +import org.geotools.filter.function.FilterFunction_strSubstringStart; +import org.geotools.filter.function.FilterFunction_strToLowerCase; +import org.geotools.filter.function.FilterFunction_strToUpperCase; +import org.geotools.filter.function.FilterFunction_strTrim; +import org.geotools.filter.function.FilterFunction_strTrim2; +import org.geotools.filter.function.InArrayFunction; +import org.geotools.filter.function.InFunction; +import org.geotools.filter.function.math.FilterFunction_abs; +import org.geotools.filter.function.math.FilterFunction_abs_2; +import org.geotools.filter.function.math.FilterFunction_abs_3; +import org.geotools.filter.function.math.FilterFunction_abs_4; +import org.geotools.filter.function.math.FilterFunction_ceil; +import org.geotools.filter.function.math.FilterFunction_floor; +import org.opengis.filter.ExcludeFilter; +import org.opengis.filter.Id; +import org.opengis.filter.IncludeFilter; +import org.opengis.filter.PropertyIsBetween; +import org.opengis.filter.PropertyIsLike; +import org.opengis.filter.PropertyIsNull; +import org.opengis.filter.expression.Add; +import org.opengis.filter.expression.Divide; +import org.opengis.filter.expression.Literal; +import org.opengis.filter.expression.Multiply; +import org.opengis.filter.expression.PropertyName; +import org.opengis.filter.expression.Subtract; + +/** + * @since 1.4 + */ +class PgsqlFilterCapabilities { + + private static final FilterCapabilities INSTANCE = createFilterCapabilities(); + + public static FilterCapabilities capabilities() { + return INSTANCE; + } + + static FilterCapabilities createFilterCapabilities() { + FilterCapabilities caps = new FilterCapabilities(); + + // basic expressions + caps.addType(Add.class); + caps.addType(Subtract.class); + caps.addType(Divide.class); + caps.addType(Multiply.class); + caps.addType(PropertyName.class); + caps.addType(Literal.class); + + // basic filters + caps.addAll(FilterCapabilities.LOGICAL_OPENGIS); + caps.addAll(FilterCapabilities.SIMPLE_COMPARISONS_OPENGIS); + caps.addType(PropertyIsNull.class); + caps.addType(PropertyIsBetween.class); + caps.addType(PropertyIsLike.class); + caps.addType(Id.class); + caps.addType(IncludeFilter.class); + caps.addType(ExcludeFilter.class); + + // supported functions + caps.addAll(InFunction.getInCapabilities()); + + // add support for string functions + caps.addType(FilterFunction_strConcat.class); + caps.addType(FilterFunction_strEndsWith.class); + caps.addType(FilterFunction_strStartsWith.class); + caps.addType(FilterFunction_strEqualsIgnoreCase.class); + caps.addType(FilterFunction_strIndexOf.class); + caps.addType(FilterFunction_strLength.class); + caps.addType(LengthFunction.class); + caps.addType(FilterFunction_strToLowerCase.class); + caps.addType(FilterFunction_strToUpperCase.class); + caps.addType(FilterFunction_strReplace.class); + caps.addType(FilterFunction_strSubstring.class); + caps.addType(FilterFunction_strSubstringStart.class); + caps.addType(FilterFunction_strTrim.class); + caps.addType(FilterFunction_strTrim2.class); + + // add support for math functions + caps.addType(FilterFunction_abs.class); + caps.addType(FilterFunction_abs_2.class); + caps.addType(FilterFunction_abs_3.class); + caps.addType(FilterFunction_abs_4.class); + caps.addType(FilterFunction_ceil.class); + caps.addType(FilterFunction_floor.class); + + // time related functions + caps.addType(DateDifferenceFunction.class); + + // array functions + caps.addType(InArrayFunction.class); + + // compare functions + caps.addType(FilterFunction_equalTo.class); + + return caps; + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/filter/PgsqlFilterToSQL.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/filter/PgsqlFilterToSQL.java new file mode 100644 index 000000000..e1815612d --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/filter/PgsqlFilterToSQL.java @@ -0,0 +1,289 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.catalog.filter; + +import lombok.Value; + +import org.geotools.filter.LengthFunction; +import org.geotools.filter.LikeFilterImpl; +import org.geotools.filter.function.FilterFunction_strConcat; +import org.geotools.filter.function.FilterFunction_strEndsWith; +import org.geotools.filter.function.FilterFunction_strEqualsIgnoreCase; +import org.geotools.filter.function.FilterFunction_strIndexOf; +import org.geotools.filter.function.FilterFunction_strLength; +import org.geotools.filter.function.FilterFunction_strStartsWith; +import org.geotools.filter.function.FilterFunction_strSubstring; +import org.geotools.filter.function.FilterFunction_strSubstringStart; +import org.geotools.filter.function.FilterFunction_strToLowerCase; +import org.geotools.filter.function.FilterFunction_strToUpperCase; +import org.geotools.filter.function.FilterFunction_strTrim; +import org.geotools.filter.function.math.FilterFunction_abs; +import org.geotools.filter.function.math.FilterFunction_abs_2; +import org.geotools.filter.function.math.FilterFunction_abs_3; +import org.geotools.filter.function.math.FilterFunction_abs_4; +import org.geotools.jdbc.PreparedFilterToSQL; +import org.opengis.filter.Filter; +import org.opengis.filter.PropertyIsLike; +import org.opengis.filter.expression.Expression; +import org.opengis.filter.expression.Function; + +import java.io.IOException; +import java.io.StringWriter; +import java.io.Writer; +import java.util.Date; +import java.util.List; + +/** + * @since 1.4 + */ +class PgsqlFilterToSQL extends PreparedFilterToSQL { + + public static @Value class Result { + String whereClause; + List literalValues; + + @SuppressWarnings("rawtypes") + List literalTypes; + } + + /** + * @param dialect + */ + public PgsqlFilterToSQL(Writer out) { + super(out); + setSqlNameEscape("\""); + super.setCapabilities(PgsqlFilterCapabilities.capabilities()); + } + + public static Result evaluate(Filter filter) { + StringWriter out = new StringWriter(); + PgsqlFilterToSQL filterToPreparedStatement = new PgsqlFilterToSQL(out); + filterToPreparedStatement.setSqlNameEscape("\""); + filter.accept(filterToPreparedStatement, null); + out.flush(); + + String whereClause = out.toString(); + List literalValues = filterToPreparedStatement.getLiteralValues(); + @SuppressWarnings("rawtypes") + List literalTypes = filterToPreparedStatement.getLiteralTypes(); + return new Result(whereClause, literalValues, literalTypes); + } + + /** + * Writes the SQL for the Like Filter. Assumes the current java implemented wildcards for the + * Like Filter: . for multi and .? for single. And replaces them with the SQL % and _, + * respectively. + * + *

Uses ILIKE if {@link PropertyIsLike#isMatchingCase()} is {@code false} + * + * @param filter the Like Filter to be visited. + * @task REVISIT: Need to think through the escape char, so it works right when Java uses one, + * and escapes correctly with an '_'. + */ + @Override + public Object visit(PropertyIsLike filter, Object extraData) { + char esc = filter.getEscape().charAt(0); + char multi = filter.getWildCard().charAt(0); + char single = filter.getSingleChar().charAt(0); + boolean matchCase = filter.isMatchingCase(); + + String literal = filter.getLiteral(); + Expression att = filter.getExpression(); + + // JD: hack for date values, we append some additional padding to handle + // the matching of time/timezone/etc... + Class attributeType = getExpressionType(att); + // null check if returnType of expression is Object, null is returned + // from getExpressionType + if (attributeType != null && Date.class.isAssignableFrom(attributeType)) { + literal += multi; + } + + String pattern = + LikeFilterImpl.convertToSQL92(esc, multi, single, matchCase, literal, false); + + try { + att.accept(this, extraData); + + if (!matchCase) { + out.write(" ILIKE "); + } else { + out.write(" LIKE "); + } + + writeLiteral(pattern); + } catch (java.io.IOException ioe) { + throw new RuntimeException(IO_ERROR, ioe); + } + return extraData; + } + + @Override + public Object visit(Function function, Object extraData) throws RuntimeException { + super.encodingFunction = true; + boolean encoded; + try { + encoded = visitFunction(function, extraData); + } catch (IOException e) { + throw new RuntimeException(e); + } + super.encodingFunction = false; + + if (encoded) { + return extraData; + } + return super.visit(function, extraData); + } + + /** + * Maps a function to its native db equivalent + * + *

+ * + * @implNote copied from org.geotools.data.postgis.PostgisFilterToSQL + */ + @Override + protected String getFunctionName(Function function) { + if (function instanceof FilterFunction_strLength || function instanceof LengthFunction) { + return "char_length"; + } else if (function instanceof FilterFunction_strToLowerCase) { + return "lower"; + } else if (function instanceof FilterFunction_strToUpperCase) { + return "upper"; + } else if (function instanceof FilterFunction_abs + || function instanceof FilterFunction_abs_2 + || function instanceof FilterFunction_abs_3 + || function instanceof FilterFunction_abs_4) { + return "abs"; + } + return super.getFunctionName(function); + } + + /** + * Performs custom visits for functions that cannot be encoded as + * functionName(p1, p2, ... pN). + * + *

+ * + * @implNote copied and adapted from org.geotools.data.postgis.FilterToSqlHelper + */ + protected boolean visitFunction(Function function, Object extraData) throws IOException { + if (function instanceof FilterFunction_strConcat) { + Expression s1 = getParameter(function, 0, true); + Expression s2 = getParameter(function, 1, true); + out.write("("); + s1.accept(this, String.class); + out.write(" || "); + s2.accept(this, String.class); + out.write(")"); + return true; + } + if (function instanceof FilterFunction_strEndsWith) { + Expression str = getParameter(function, 0, true); + Expression end = getParameter(function, 1, true); + + out.write("("); + str.accept(this, String.class); + out.write(" LIKE ('%' || "); + end.accept(this, String.class); + out.write("))"); + return true; + } + if (function instanceof FilterFunction_strStartsWith) { + Expression str = getParameter(function, 0, true); + Expression start = getParameter(function, 1, true); + + out.write("("); + str.accept(this, String.class); + out.write(" LIKE ("); + start.accept(this, String.class); + out.write(" || '%'))"); + return true; + } + if (function instanceof FilterFunction_strEqualsIgnoreCase) { + Expression first = getParameter(function, 0, true); + Expression second = getParameter(function, 1, true); + + out.write("(lower("); + first.accept(this, String.class); + out.write(") = lower("); + second.accept(this, String.class); + out.write("::text))"); + return true; + } + // if (function instanceof FilterFunction_strToLowerCase) { + // Expression first = getParameter(function, 0, true); + // out.write("(lower("); + // first.accept(this, String.class); + // out.write("::text))"); + // return true; + // } + // if (function instanceof FilterFunction_strToUpperCase) { + // Expression first = getParameter(function, 0, true); + // out.write("(upper("); + // first.accept(this, String.class); + // out.write("::text))"); + // return true; + // } + if (function instanceof FilterFunction_strIndexOf) { + Expression first = getParameter(function, 0, true); + Expression second = getParameter(function, 1, true); + + // would be a simple call, but strIndexOf returns zero based indices + out.write("(strpos("); + first.accept(this, String.class); + out.write(", "); + second.accept(this, String.class); + out.write(") - 1)"); + return true; + } + if (function instanceof FilterFunction_strSubstring) { + Expression string = getParameter(function, 0, true); + Expression start = getParameter(function, 1, true); + Expression end = getParameter(function, 2, true); + + // postgres does sub(string, start, count)... count instead of end, and 1 based indices + out.write("substr("); + string.accept(this, String.class); + out.write(", "); + start.accept(this, Integer.class); + out.write(" + 1, ("); + end.accept(this, Integer.class); + out.write(" - "); + start.accept(this, Integer.class); + out.write("))"); + return true; + } + if (function instanceof FilterFunction_strSubstringStart) { + Expression string = getParameter(function, 0, true); + Expression start = getParameter(function, 1, true); + + // postgres does sub(string, start, count)... count instead of end, and 1 based indices + out.write("substr("); + string.accept(this, String.class); + out.write(", "); + start.accept(this, Integer.class); + out.write(" + 1)"); + } + if (function instanceof FilterFunction_strTrim) { + Expression string = getParameter(function, 0, true); + + out.write("trim(both ' ' from "); + string.accept(this, String.class); + out.write(")"); + return true; + } + // if (function instanceof JsonPointerFunction) { + // encodeJsonPointer(function, extraData); + // return true; + // } + // if (function instanceof JsonArrayContainsFunction) { + // encodeJsonArrayContains(function); + // return true; + // } + // function not supported + return false; + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/filter/PgsqlQueryBuilder.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/filter/PgsqlQueryBuilder.java new file mode 100644 index 000000000..32ef6beb3 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/filter/PgsqlQueryBuilder.java @@ -0,0 +1,56 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.catalog.filter; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import org.geoserver.cloud.backend.pgsql.catalog.filter.PgsqlFilterToSQL.Result; +import org.geotools.filter.visitor.SimplifyingFilterVisitor; +import org.opengis.filter.Filter; + +import java.util.List; +import java.util.Set; + +/** + * @since 1.4 + */ +@RequiredArgsConstructor +public class PgsqlQueryBuilder { + + private final Filter filter; + private final Set supportedPropertyNames; + + private @Getter Filter supportedFilter = Filter.INCLUDE; + private @Getter Filter unsupportedFilter = Filter.INCLUDE; + private @Getter String whereClause = "TRUE"; + + private @Getter List literalValues; + + @SuppressWarnings("rawtypes") + private @Getter List literalTypes; + + public PgsqlQueryBuilder build() { + if (Filter.INCLUDE.equals(filter)) { + return this; + } + PgsqlCatalogFilterSplitter splitter = + PgsqlCatalogFilterSplitter.split(filter, supportedPropertyNames); + + supportedFilter = splitter.getFilterPre(); + unsupportedFilter = splitter.getFilterPost(); + + supportedFilter = ToPgsqlCompatibleFilterDuplicator.adapt(supportedFilter); + supportedFilter = SimplifyingFilterVisitor.simplify(supportedFilter); + + unsupportedFilter = SimplifyingFilterVisitor.simplify(unsupportedFilter); + + Result encodeResult = PgsqlFilterToSQL.evaluate(supportedFilter); + whereClause = encodeResult.getWhereClause(); + literalValues = encodeResult.getLiteralValues(); + literalTypes = encodeResult.getLiteralTypes(); + return this; + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/filter/ToPgsqlCompatibleFilterDuplicator.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/filter/ToPgsqlCompatibleFilterDuplicator.java new file mode 100644 index 000000000..16de6124f --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/filter/ToPgsqlCompatibleFilterDuplicator.java @@ -0,0 +1,199 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.catalog.filter; + +import org.geotools.filter.visitor.DuplicatingFilterVisitor; +import org.opengis.filter.BinaryComparisonOperator; +import org.opengis.filter.Filter; +import org.opengis.filter.MultiValuedFilter.MatchAction; +import org.opengis.filter.PropertyIsBetween; +import org.opengis.filter.PropertyIsEqualTo; +import org.opengis.filter.PropertyIsGreaterThan; +import org.opengis.filter.PropertyIsGreaterThanOrEqualTo; +import org.opengis.filter.PropertyIsLessThan; +import org.opengis.filter.PropertyIsLessThanOrEqualTo; +import org.opengis.filter.PropertyIsLike; +import org.opengis.filter.PropertyIsNotEqualTo; +import org.opengis.filter.expression.Expression; +import org.opengis.filter.expression.Literal; +import org.opengis.filter.expression.PropertyName; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.function.BiFunction; +import java.util.function.Function; + +/** + * Duplicates a supported filter making it directly translatable to SQL taking care of subtleties + * like {@link MatchAction} and case matching. + * + * @since 1.4 + */ +class ToPgsqlCompatibleFilterDuplicator extends DuplicatingFilterVisitor { + + public static Filter adapt(Filter filter) { + ToPgsqlCompatibleFilterDuplicator adaptor = new ToPgsqlCompatibleFilterDuplicator(); + return (Filter) filter.accept(adaptor, null); + } + + @Override + public Object visit(PropertyName expression, Object extraData) { + boolean matchCase = (extraData instanceof Boolean) ? (Boolean) extraData : true; + if (!matchCase) { + return getFactory(null).function("strToLowerCase", expression); + } + return super.visit(expression, extraData); + } + + @Override + public Object visit(Literal expression, Object extraData) { + boolean matchCase = (extraData instanceof Boolean) ? (Boolean) extraData : true; + if (!matchCase) { + return getFactory(null).function("strToLowerCase", expression); + } + return super.visit(expression, extraData); + } + + @Override + public Object visit(PropertyIsBetween filter, Object extraData) { + Expression expr = visit(filter.getExpression(), extraData); + Expression lower = visit(filter.getLowerBoundary(), extraData); + Expression upper = visit(filter.getUpperBoundary(), extraData); + return getFactory(extraData).between(expr, lower, upper, filter.getMatchAction()); + } + + @Override + public Object visit(PropertyIsEqualTo filter, Object extraData) { + BinaryComparisonOperator dup = + (BinaryComparisonOperator) super.visit(filter, filter.isMatchingCase()); + return visitBinaryComparisonOperator(dup); + } + + @Override + public Object visit(PropertyIsNotEqualTo filter, Object extraData) { + BinaryComparisonOperator dup = + (BinaryComparisonOperator) super.visit(filter, filter.isMatchingCase()); + return visitBinaryComparisonOperator(dup); + } + + @Override + public Object visit(PropertyIsGreaterThan filter, Object extraData) { + BinaryComparisonOperator dup = + (BinaryComparisonOperator) super.visit(filter, filter.isMatchingCase()); + return visitBinaryComparisonOperator(dup); + } + + @Override + public Object visit(PropertyIsGreaterThanOrEqualTo filter, Object extraData) { + BinaryComparisonOperator dup = + (BinaryComparisonOperator) super.visit(filter, filter.isMatchingCase()); + return visitBinaryComparisonOperator(dup); + } + + @Override + public Object visit(PropertyIsLessThan filter, Object extraData) { + BinaryComparisonOperator dup = + (BinaryComparisonOperator) super.visit(filter, filter.isMatchingCase()); + return visitBinaryComparisonOperator(dup); + } + + @Override + public Object visit(PropertyIsLessThanOrEqualTo filter, Object extraData) { + BinaryComparisonOperator dup = + (BinaryComparisonOperator) super.visit(filter, filter.isMatchingCase()); + return visitBinaryComparisonOperator(dup); + } + + @Override + public Object visit(PropertyIsLike filter, Object extraData) { + return super.visit(filter, extraData); + } + + protected Filter visitBinaryComparisonOperator(BinaryComparisonOperator filter) { + Expression e1 = filter.getExpression1(); + Expression e2 = filter.getExpression2(); + + Literal literal = + e1 instanceof Literal + ? (Literal) e1 + : (e2 instanceof Literal ? (Literal) e2 : null); + + Object value = null == literal ? null : literal.getValue(); + if (!(value instanceof Collection)) { + return filter; + } + + List values = new ArrayList<>((Collection) value); + final MatchAction matchAction = filter.getMatchAction(); + final BiFunction filterBuilder = filterBuilder(filter); + Function, Filter> aggregateBuilder; + final Expression left = literal == e1 ? e2 : e1; + switch (matchAction) { + case ALL: + // only if all of the possible combinations match, the result is true (aggregated + // AND) + aggregateBuilder = ff::and; + break; + case ANY: + // if any of the possible combinations match, the result is true (aggregated OR) + aggregateBuilder = ff::or; + break; + case ONE: + // only if exactly one of the possible combinations match, the result is true + // (aggregated XOR) + aggregateBuilder = ff::or; + List xor = new ArrayList<>(); + for (int i = 0; i < values.size(); i++) { + Filter tomatch = filterBuilder.apply(left, ff.literal(values.get(i))); + List tomiss = new ArrayList<>(); + for (int j = 0; j < values.size(); j++) { + if (j == i) continue; + tomiss.add(filterBuilder.apply(left, ff.literal(values.get(j)))); + } + xor.add(ff.and(tomatch, ff.not(ff.or(tomiss)))); + } + Filter xored = ff.or(xor); + xored.accept(this, null); + return xored; + default: + throw new IllegalStateException(); + } + + List subfilters = new ArrayList<>(); + for (Object v : values) { + Literal right = ff.literal(v); + Filter subfilter = filterBuilder.apply(left, right); + subfilters.add(subfilter); + } + Filter replacement = aggregateBuilder.apply(subfilters); + replacement.accept(this, null); + return replacement; + } + + private BiFunction filterBuilder( + BinaryComparisonOperator orig) { + + if (orig instanceof PropertyIsEqualTo) + return (e1, e2) -> ff.equal(e1, e2, orig.isMatchingCase()); + + if (orig instanceof PropertyIsGreaterThan) + return (e1, e2) -> ff.greater(e1, e2, orig.isMatchingCase()); + + if (orig instanceof PropertyIsGreaterThanOrEqualTo) + return (e1, e2) -> ff.greaterOrEqual(e1, e2, orig.isMatchingCase()); + + if (orig instanceof PropertyIsLessThan) + return (e1, e2) -> ff.less(e1, e2, orig.isMatchingCase()); + + if (orig instanceof PropertyIsLessThanOrEqualTo) + return (e1, e2) -> ff.lessOrEqual(e1, e2, orig.isMatchingCase()); + + if (orig instanceof PropertyIsNotEqualTo) + return (e1, e2) -> ff.notEqual(e1, e2, orig.isMatchingCase()); + + throw new IllegalArgumentException("Unknown BinaryComparisonOperator: " + orig); + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/CatalogInfoRowMapper.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/CatalogInfoRowMapper.java new file mode 100644 index 000000000..a6e20857e --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/CatalogInfoRowMapper.java @@ -0,0 +1,402 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.catalog.repository; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.Setter; + +import org.geoserver.catalog.CatalogInfo; +import org.geoserver.catalog.LayerGroupInfo; +import org.geoserver.catalog.LayerInfo; +import org.geoserver.catalog.NamespaceInfo; +import org.geoserver.catalog.ResourceInfo; +import org.geoserver.catalog.StoreInfo; +import org.geoserver.catalog.StyleInfo; +import org.geoserver.catalog.WorkspaceInfo; +import org.geoserver.catalog.impl.ClassMappings; +import org.geoserver.catalog.impl.LayerInfoImpl; +import org.geoserver.catalog.impl.ModificationProxy; +import org.geotools.jackson.databind.util.ObjectMapperUtil; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +/** + * @since 1.4 + */ +class CatalogInfoRowMapper { + + protected static final ObjectMapper infoMapper = ObjectMapperUtil.newObjectMapper(); + + // TODO: limit the amount of cached objects + protected Map cache = new HashMap<>(); + + protected @Setter Function> styleLoader; + + protected T resolveCached( + String id, Class clazz, ResultSet rs, Function loader) { + + return resolveCached(id, clazz, idd -> loader.apply(rs)); + } + + protected T resolveCached( + String id, Class clazz, Function loader) { + if (null == id) return null; + CatalogInfo info = cache.get(id); + if (!clazz.isInstance(info)) { + info = loader.apply(id); + cache.put(id, info); + } + return clazz.cast(info); + } + + /** + * {@link RowMapper} function for {@link WorkspaceInfo} + * + *

Expects the following columns: + * + *

{@code
+     *    Column        |   Type   | Collation | Nullable | Default
+     * ----------------------+----------+-----------+----------+---------
+     * workspace        | jsonb    |           |          |
+     * }
+ */ + public WorkspaceInfo mapWorkspace(ResultSet rs, int rowNum) throws SQLException { + try { + return mapWorkspace(rs); + } catch (UncheckedSqlException e) { + throw e.getCause(); + } + } + + protected WorkspaceInfo mapWorkspace(ResultSet rs) { + try { + return decode(rs, "workspace", WorkspaceInfo.class); + } catch (SQLException e) { + throw UncheckedSqlException.of(e); + } + } + + protected WorkspaceInfo mapWorkspace(String id, ResultSet rs) { + return resolveCached(id, WorkspaceInfo.class, rs, this::mapWorkspace); + } + + /** + * Expects the following columns: + * + *
{@code
+     *    Column        |   Type   | Collation | Nullable | Default
+     * ----------------------+----------+-----------+----------+---------
+     * namespace        | jsonb    |           |          |
+     * }
+ */ + public NamespaceInfo mapNamespace(ResultSet rs, int rowNum) throws SQLException { + try { + return mapNamespace(rs); + } catch (UncheckedSqlException e) { + throw e.getCause(); + } + } + + protected NamespaceInfo mapNamespace(ResultSet rs) { + try { + return decode(rs, "namespace", NamespaceInfo.class); + } catch (SQLException e) { + throw UncheckedSqlException.of(e); + } + } + + protected NamespaceInfo mapNamespace(String id, ResultSet rs) { + return resolveCached(id, NamespaceInfo.class, rs, this::mapNamespace); + } + + /** + * Expects the following columns: + * + *
{@code
+     *    Column        |   Type   | Collation | Nullable | Default
+     * ----------------------+----------+-----------+----------+---------
+     * style            | jsonb    |           |          |
+     * workspace        | jsonb    |           |          |
+     * }
+ */ + public StyleInfo mapStyle(ResultSet rs, int rowNum) throws SQLException { + try { + return loadStyle(rs); + } catch (UncheckedSqlException e) { + throw e.getCause(); + } + } + + protected StyleInfo loadStyle(ResultSet rs) { + return loadStyle(rs, "style"); + } + + protected StyleInfo loadStyle(ResultSet rs, String columnName) { + StyleInfo style; + try { + style = decode(rs.getString(columnName), StyleInfo.class); + } catch (SQLException e) { + throw UncheckedSqlException.of(e); + } + WorkspaceInfo workspace = style.getWorkspace(); + if (null != workspace) { + String wsid = workspace.getId(); + WorkspaceInfo ws = mapWorkspace(wsid, rs); + style.setWorkspace(ModificationProxy.create(ws, WorkspaceInfo.class)); + } + return style; + } + + protected StyleInfo mapStyle(String id, ResultSet rs) { + return resolveCached(id, StyleInfo.class, rs, this::loadStyle); + } + + protected StyleInfo mapStyle(String id, String column, ResultSet rs) { + return resolveCached(id, StyleInfo.class, rs, r -> loadStyle(r, "defaultStyle")); + } + + /** + * Expects the following columns: + * + *
{@code
+     *    Column        |   Type   | Collation | Nullable | Default
+     * ----------------------+----------+-----------+----------+---------
+     * store            | jsonb    |           |          |
+     * workspace        | jsonb    |           |          |
+     * }
+ */ + public StoreInfo mapStore(ResultSet rs, int rowNum) throws SQLException { + try { + return mapStore(rs); + } catch (UncheckedSqlException e) { + throw e.getCause(); + } + } + + protected StoreInfo mapStore(ResultSet rs) { + StoreInfo store; + try { + store = decode(rs.getString("store"), StoreInfo.class); + } catch (SQLException e) { + throw UncheckedSqlException.of(e); + } + String wsid = store.getWorkspace().getId(); + WorkspaceInfo ws = mapWorkspace(wsid, rs); + store.setWorkspace(ModificationProxy.create(ws, WorkspaceInfo.class)); + return store; + } + + protected StoreInfo mapStore(String id, ResultSet rs) { + return resolveCached(id, StoreInfo.class, rs, this::mapStore); + } + + /** + * Expects the following columns: + * + *
{@code
+     *    Column        |   Type   | Collation | Nullable | Default
+     * ----------------------+----------+-----------+----------+---------
+     * resource         | jsonb    |           |          |
+     * store            | jsonb    |           |          |
+     * workspace        | jsonb    |           |          |
+     * namespace        | jsonb    |           |          |
+     * }
+ */ + public ResourceInfo mapResource(ResultSet rs, int rowNum) throws SQLException { + try { + return mapResource(rs); + } catch (UncheckedSqlException e) { + throw e.getCause(); + } + } + + public ResourceInfo mapResource(ResultSet rs) { + ResourceInfo resource; + try { + resource = decode(rs.getString("resource"), ResourceInfo.class); + } catch (SQLException e) { + throw UncheckedSqlException.of(e); + } + setStore(resource, rs); + setNamespace(rs, resource); + return resource; + } + + public ResourceInfo mapResource(String id, ResultSet rs) { + return resolveCached(id, ResourceInfo.class, rs, this::mapResource); + } + + protected void setStore(ResourceInfo resource, ResultSet rs) { + String storeId = resource.getStore().getId(); + StoreInfo store = mapStore(storeId, rs); + @SuppressWarnings("unchecked") + Class storeType = + (Class) + ClassMappings.fromImpl(store.getClass()).getInterface(); + resource.setStore(ModificationProxy.create(store, storeType)); + } + + protected void setNamespace(ResultSet rs, ResourceInfo resource) { + String nsid = resource.getNamespace().getId(); + NamespaceInfo ns = mapNamespace(nsid, rs); + resource.setNamespace(ModificationProxy.create(ns, NamespaceInfo.class)); + } + + /** + * Expects the following columns: + * + *
{@code
+     *    Column        |   Type   | Collation | Nullable | Default
+     * ----------------------+----------+-----------+----------+---------
+     * publishedinfo    | jsonb    |           |          |
+     * resource         | jsonb    |           |          |
+     * store            | jsonb    |           |          |
+     * workspace        | jsonb    |           |          |
+     * namespace        | jsonb    |           |          |
+     * defaultStyle     | jsonb    |           |          |
+     * }
+ */ + public LayerInfo mapLayer(ResultSet rs, int rowNum) throws SQLException { + try { + return mapLayer(rs); + } catch (UncheckedSqlException e) { + throw e.getCause(); + } + } + + protected LayerInfo mapLayer(ResultSet rs) { + LayerInfo layer; + try { + layer = decode(rs.getString("publishedinfo"), LayerInfo.class); + } catch (SQLException e) { + throw UncheckedSqlException.of(e); + } + setResource(layer, rs); + setDefaultStyle(layer, rs); + setStyles(layer); + return layer; + } + + private void setStyles(LayerInfo layer) { + LayerInfoImpl li = (LayerInfoImpl) ModificationProxy.unwrap(layer); + List styles = + li.getStyles().stream() + .map(StyleInfo::getId) + .map(this::loadStyle) + .map(s -> ModificationProxy.create(s, StyleInfo.class)) + .toList(); + li.setStyles(new HashSet<>(styles)); + } + + private StyleInfo loadStyle(String id) { + Function function = styleLoader.andThen(opt -> opt.orElse(null)); + return resolveCached(id, StyleInfo.class, function); + } + + protected LayerInfo mapLayer(String id, ResultSet rs) { + return resolveCached(id, LayerInfo.class, rs, this::mapLayer); + } + + private void setResource(LayerInfo layer, ResultSet rs) { + String resourceId = layer.getResource().getId(); + ResourceInfo resource = mapResource(resourceId, rs); + layer.setResource(ModificationProxy.create(resource, ResourceInfo.class)); + } + + private void setDefaultStyle(LayerInfo layer, ResultSet rs) { + StyleInfo defaultStyle = layer.getDefaultStyle(); + if (null != defaultStyle) { + String styleId = defaultStyle.getId(); + defaultStyle = mapStyle(styleId, "defaultStyle", rs); + layer.setDefaultStyle(ModificationProxy.create(defaultStyle, StyleInfo.class)); + } + } + + /** + * Expects the following columns: + * + *
{@code
+     *    Column        |   Type   | Collation | Nullable | Default
+     * ----------------------+----------+-----------+----------+---------
+     * publishedinfo    | jsonb    |           |          |
+     * workspace        | jsonb    |           |          |
+     * }
+ */ + public LayerGroupInfo mapLayerGroup(ResultSet rs, int rowNum) throws SQLException { + try { + return mapLayerGroup(rs); + } catch (UncheckedSqlException e) { + throw e.getCause(); + } + } + + protected LayerGroupInfo mapLayerGroup(ResultSet rs) { + LayerGroupInfo layergroup; + try { + layergroup = decode(rs.getString("publishedinfo"), LayerGroupInfo.class); + } catch (SQLException e) { + throw UncheckedSqlException.of(e); + } + WorkspaceInfo workspace = layergroup.getWorkspace(); + if (null != workspace) { + String wsid = workspace.getId(); + WorkspaceInfo ws = mapWorkspace(wsid, rs); + layergroup.setWorkspace(ModificationProxy.create(ws, WorkspaceInfo.class)); + } + return layergroup; + } + + protected V decode(ResultSet rs, String column, Class valueType) throws SQLException { + return decode(rs.getString(column), valueType); + } + + protected V decode(String encoded, Class valueType) { + try { + return null == encoded ? null : infoMapper.readValue(encoded, valueType); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + public static RowMapper workspace() { + return new CatalogInfoRowMapper()::mapWorkspace; + } + + public static RowMapper namespace() { + return new CatalogInfoRowMapper()::mapNamespace; + } + + public static RowMapper store() { + return new CatalogInfoRowMapper()::mapStore; + } + + public static RowMapper resource() { + return new CatalogInfoRowMapper()::mapResource; + } + + public static RowMapper style() { + return new CatalogInfoRowMapper()::mapStyle; + } + + public static RowMapper layer(Function> styleLoader) { + CatalogInfoRowMapper mapper = new CatalogInfoRowMapper(); + mapper.setStyleLoader(styleLoader); + return mapper::mapLayer; + } + + public static RowMapper layerGroup() { + return new CatalogInfoRowMapper()::mapLayerGroup; + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlCatalogInfoRepository.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlCatalogInfoRepository.java new file mode 100644 index 000000000..a86639e42 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlCatalogInfoRepository.java @@ -0,0 +1,367 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.catalog.repository; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; + +import org.geoserver.catalog.CatalogInfo; +import org.geoserver.catalog.impl.ClassMappings; +import org.geoserver.catalog.plugin.CatalogInfoRepository; +import org.geoserver.catalog.plugin.Patch; +import org.geoserver.catalog.plugin.Query; +import org.geoserver.cloud.backend.pgsql.catalog.filter.PgsqlQueryBuilder; +import org.geotools.filter.visitor.SimplifyingFilterVisitor; +import org.geotools.jackson.databind.util.ObjectMapperUtil; +import org.opengis.filter.Filter; +import org.opengis.filter.sort.SortBy; +import org.opengis.filter.sort.SortOrder; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Arrays; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.Predicate; +import java.util.stream.Stream; + +/** + * @since 1.4 + */ +@Slf4j +public abstract class PgsqlCatalogInfoRepository + implements CatalogInfoRepository { + + protected final @NonNull JdbcTemplate template; + + protected static final ObjectMapper infoMapper = ObjectMapperUtil.newObjectMapper(); + + private Set _sortableProperties; + + /** + * @param template + */ + public PgsqlCatalogInfoRepository(@NonNull JdbcTemplate template) { + this.template = template; + } + + protected String getTable() { + return getContentType().getSimpleName().toLowerCase(); + } + + protected abstract String getQueryTable(); + + protected final Set sortableProperties() { + if (null == _sortableProperties) { + _sortableProperties = resolveSortableProperties(); + } + return _sortableProperties; + } + + private Set resolveSortableProperties() { + Set queryableColumns = new TreeSet<>(); + final String queryTable = getQueryTable(); + try (Connection c = template.getDataSource().getConnection()) { + DatabaseMetaData metaData = c.getMetaData(); + try (ResultSet columns = metaData.getColumns(null, null, queryTable, null)) { + while (columns.next()) { + String name = columns.getString("COLUMN_NAME"); + String type = columns.getString("TYPE_NAME"); + if (!"jsonb".equals(type)) { + queryableColumns.add(name); + } + } + } + } catch (SQLException e) { + throw new UncheckedSqlException(e); + } + log.debug( + "resolved queryable/sortable properties for {}: {}", queryTable, queryableColumns); + return Set.copyOf(queryableColumns); + } + + protected abstract RowMapper newRowMapper(); + + @Override + public void add(@NonNull T value) { + String encoded = encode(value); + template.update( + """ + INSERT INTO %s (info) VALUES(to_json(?::json)) + """ + .formatted(getTable()), + encoded); + } + + @Override + public void remove(@NonNull T value) { + template.update( + """ + DELETE FROM %s WHERE id = ? + """ + .formatted(getTable()), + value.getId()); + } + + @SuppressWarnings("unchecked") + @Override + public I update(@NonNull I value, @NonNull Patch patch) { + String id = value.getId(); + T patched = + findById(value.getId()) + .map(patch::applyTo) + .orElseThrow( + () -> + new NoSuchElementException( + "%s with id %s does not exist" + .formatted( + getContentType().getSimpleName(), + value.getId()))); + + String encoded = encode(patched); + template.update( + """ + UPDATE %s SET info = to_json(?::json) WHERE id = ? + """ + .formatted(getTable()), + encoded, + id); + return (I) patched; + } + + @Override + public Stream findAll(Query query) { + Filter filter = query.getFilter(); + + Set sortableProperties = sortableProperties(); + final PgsqlQueryBuilder qb = new PgsqlQueryBuilder(filter, sortableProperties).build(); + final Filter supportedFilter = qb.getSupportedFilter(); + final Filter unsupportedFilter = qb.getUnsupportedFilter(); + final String whereClause = qb.getWhereClause(); + + log.trace( + "supported filter {} translated to {}, unsupported: {}", + supportedFilter, + whereClause, + unsupportedFilter); + + final boolean filterFullySupported = Filter.INCLUDE.equals(unsupportedFilter); + + String sql = "SELECT * FROM %s WHERE TRUE".formatted(getQueryTable()); + sql = applyTypeFilter(sql, query.getType()); + + Object[] prepStatementParams = null; + if (!Filter.INCLUDE.equals(supportedFilter)) { + sql += " AND " + whereClause; + List literalValues = qb.getLiteralValues(); + if (!literalValues.isEmpty()) { + prepStatementParams = qb.getLiteralValues().toArray(); + } + } + + sql = applySortOrder(sql, query.getSortBy()); + + if (filterFullySupported) { + sql = applyOffsetLimit(sql, query.getOffset(), query.getCount()); + } + + if (log.isDebugEnabled()) log.debug("{} / {}", sql, Arrays.toString(prepStatementParams)); + + Stream stream = queryForStream(query.getType(), sql, prepStatementParams); + if (!filterFullySupported) { + filter = SimplifyingFilterVisitor.simplify(unsupportedFilter); + + Predicate predicate = toPredicate(unsupportedFilter); + // Predicate predicate = toPredicate(filter); + stream = + stream.filter(predicate) + .skip(query.offset().orElse(0)) + .limit(query.count().orElse(Integer.MAX_VALUE)); + } + return stream; + } + + protected Stream queryForStream( + Class type, String sql, Object... prepStatementParams) { + RowMapper rowMapper = newRowMapper(); + Stream stream = template.queryForStream(sql, rowMapper, prepStatementParams); + return stream.filter(type::isInstance).map(type::cast); + } + + protected Predicate toPredicate(Filter filter) { + return o -> { + if (null == o) { + return false; + } + return filter.evaluate(o); + }; + } + + private String applyTypeFilter(String sql, @NonNull Class type) { + if (!getContentType().equals(type)) { + String infoType = infoType(type); + sql += " AND \"@type\" = '%s'::infotype".formatted(infoType); + } + return sql; + } + + protected String applyOffsetLimit(String sql, Integer offset, Integer limit) { + if (null != offset) sql += " OFFSET %d".formatted(offset); + if (null != limit) sql += " LIMIT %d".formatted(limit); + return sql; + } + + protected String applySortOrder(String sql, @NonNull List sortBy) { + if (!sortBy.isEmpty()) { + sql += " ORDER BY"; + for (SortBy sort : sortBy) { + String property = sort.getPropertyName().getPropertyName(); + checkCanSortBy(property); + SortOrder sortOrder = sort.getSortOrder(); + sql += " \"%s\" %s".formatted(property, sortOrder.toSQL()); + } + } + return sql; + } + + protected void checkCanSortBy(String property) { + if (!canSortBy(property)) { + throw new IllegalArgumentException( + "Unsupported sort property %s on %s. Supported properties: %s" + .formatted(property, getTable(), sortableProperties())); + } + } + + @Override + public long count(Class of, final Filter filter) { + + final PgsqlQueryBuilder qb = new PgsqlQueryBuilder(filter, sortableProperties()).build(); + final Filter supportedFilter = qb.getSupportedFilter(); + final Filter unsupportedFilter = qb.getUnsupportedFilter(); + final String whereClause = qb.getWhereClause(); + + log.trace( + "supported filter {} translated to {}, unsupported: {}", + supportedFilter, + whereClause, + unsupportedFilter); + + final boolean filterFullySupported = Filter.INCLUDE.equals(unsupportedFilter); + if (filterFullySupported) { + String sql = "SELECT count(*) FROM %s WHERE TRUE"; + Object[] prepStatementParams = null; + if (Filter.INCLUDE.equals(supportedFilter)) { + sql = sql.formatted(getTable()); + sql = applyTypeFilter(sql, of); + } else { + sql = sql.formatted(getQueryTable()); + sql = applyTypeFilter(sql, of); + sql += " AND " + whereClause; + List literalValues = qb.getLiteralValues(); + if (!literalValues.isEmpty()) { + prepStatementParams = qb.getLiteralValues().toArray(); + } + } + if (log.isDebugEnabled()) + log.debug("{} / {}", sql, Arrays.toString(prepStatementParams)); + return template.queryForObject(sql, Long.class, prepStatementParams); + } + + try (Stream stream = findAll(Query.valueOf(of, filter))) { + return stream.count(); + } + } + + @Override + public Optional findById(@NonNull String id, Class clazz) { + String query = + """ + SELECT * FROM %s WHERE id = ? + """ + .formatted(getQueryTable()); + return findOne(query, clazz, id); + } + + public Optional findById(@NonNull String id) { + return findById(id, getContentType()); + } + + @Override + public Optional findFirstByName(@NonNull String name, Class clazz) { + String query = + """ + SELECT * FROM %s WHERE name = ? ORDER BY id + """ + .formatted(getQueryTable()); + return findOne(query, clazz, newRowMapper(), name); + } + + protected Optional findOne(@NonNull String query, Object... args) { + return findOne(query, getContentType(), args); + } + + protected Optional findOne( + @NonNull String query, Class clazz, Object... args) { + + return findOne(query, clazz, newRowMapper(), args); + } + + protected Optional findOne( + @NonNull String query, Class clazz, RowMapper rowMapper, Object... args) { + + try { + T object = template.queryForObject(query, rowMapper, args); + return Optional.ofNullable(clazz.isInstance(object) ? clazz.cast(object) : null); + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } + + @Override + public boolean canSortBy(@NonNull String propertyName) { + return sortableProperties().contains(propertyName); + } + + @Override + public void syncTo(@NonNull CatalogInfoRepository target) { + throw new UnsupportedOperationException("implement"); + } + + @Override + public void dispose() {} + + protected String encode(T info) { + try { + return infoMapper.writeValueAsString(info); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + protected String infoType(CatalogInfo value) { + Class clazz = value.getClass(); + return infoType(clazz); + } + + protected @NonNull String infoType(Class clazz) { + ClassMappings cm; + if (clazz.isInterface()) cm = ClassMappings.fromInterface(clazz); + else cm = ClassMappings.fromImpl(clazz); + + String infotype = cm.getInterface().getSimpleName(); + return infotype; + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlLayerGroupRepository.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlLayerGroupRepository.java new file mode 100644 index 000000000..6492afae3 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlLayerGroupRepository.java @@ -0,0 +1,85 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.catalog.repository; + +import lombok.Getter; +import lombok.NonNull; + +import org.geoserver.catalog.LayerGroupInfo; +import org.geoserver.catalog.WorkspaceInfo; +import org.geoserver.catalog.plugin.CatalogInfoRepository.LayerGroupRepository; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.util.Optional; +import java.util.stream.Stream; + +/** + * @since 1.4 + */ +public class PgsqlLayerGroupRepository extends PgsqlCatalogInfoRepository + implements LayerGroupRepository { + + private final @Getter Class contentType = LayerGroupInfo.class; + private final @Getter String queryTable = "layergroupinfos"; + + /** + * @param template + */ + public PgsqlLayerGroupRepository(@NonNull JdbcTemplate template) { + super(template); + } + + @Override + public Optional findByNameAndWorkspaceIsNull(@NonNull String name) { + String sql = + """ + SELECT publishedinfo, workspace + FROM layergroupinfos + WHERE "workspace.id" IS NULL AND name = ? + """; + return findOne(sql, LayerGroupInfo.class, newRowMapper(), name); + } + + @Override + public Optional findByNameAndWorkspace( + @NonNull String name, @NonNull WorkspaceInfo workspace) { + + String sql = + """ + SELECT publishedinfo, workspace + FROM layergroupinfos + WHERE "workspace.id" = ? AND name = ? + """; + return findOne(sql, LayerGroupInfo.class, newRowMapper(), workspace.getId(), name); + } + + @Override + public Stream findAllByWorkspaceIsNull() { + String sql = + """ + SELECT publishedinfo, workspace + FROM layergroupinfos + WHERE "workspace.id" IS NULL + """; + return template.queryForStream(sql, newRowMapper()); + } + + @Override + public Stream findAllByWorkspace(WorkspaceInfo workspace) { + String sql = + """ + SELECT publishedinfo, workspace + FROM layergroupinfos + WHERE "workspace.id" = ? + """; + return template.queryForStream(sql, newRowMapper(), workspace.getId()); + } + + @Override + protected RowMapper newRowMapper() { + return CatalogInfoRowMapper.layerGroup(); + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlLayerRepository.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlLayerRepository.java new file mode 100644 index 000000000..501d7f3d5 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlLayerRepository.java @@ -0,0 +1,97 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.catalog.repository; + +import lombok.Getter; +import lombok.NonNull; + +import org.geoserver.catalog.LayerInfo; +import org.geoserver.catalog.ResourceInfo; +import org.geoserver.catalog.StyleInfo; +import org.geoserver.catalog.plugin.CatalogInfoRepository.LayerRepository; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Stream; + +/** + * @since 1.4 + */ +public class PgsqlLayerRepository extends PgsqlCatalogInfoRepository + implements LayerRepository { + + private final @Getter Class contentType = LayerInfo.class; + private final @Getter String queryTable = "layerinfos"; + + /** + * @param template + */ + public PgsqlLayerRepository(@NonNull JdbcTemplate template) { + super(template); + } + + @Override + public Optional findOneByName(@NonNull String possiblyPrefixedName) { + String sql = + """ + SELECT publishedinfo, resource, store, workspace, namespace, "defaultStyle" + FROM layerinfos + WHERE "%s" = ? + """; + if (possiblyPrefixedName.contains(":")) { + // two options here, it's either a prefixed name like in :, or the + // ResourceInfo name actually contains a colon + Optional found = + findOne( + sql.formatted("prefixedName"), + LayerInfo.class, + newRowMapper(), + possiblyPrefixedName); + if (found.isPresent()) return found; + } + + // no colon in name or name actually contains a colon + return findOne( + sql.formatted("name"), LayerInfo.class, newRowMapper(), possiblyPrefixedName); + } + + // TODO: optimize + @Override + public Stream findAllByDefaultStyleOrStyles(@NonNull StyleInfo style) { + return findAll().filter(styleFilter(style)); + } + + private Predicate styleFilter(@NonNull StyleInfo style) { + return l -> { + if (matches(style, l.getDefaultStyle())) return true; + return Optional.ofNullable(l.getStyles()).orElse(Set.of()).stream() + .anyMatch(s -> matches(style, s)); + }; + } + + private boolean matches(StyleInfo expected, StyleInfo actual) { + return actual != null && expected.getId().equals(actual.getId()); + } + + @Override + public Stream findAllByResource(@NonNull ResourceInfo resource) { + String sql = + """ + SELECT publishedinfo, resource, store, workspace, namespace, "defaultStyle" + FROM layerinfos + WHERE "resource.id" = ? + """; + return template.queryForStream(sql, newRowMapper(), resource.getId()); + } + + @Override + protected RowMapper newRowMapper() { + PgsqlStyleRepository styleLoader = new PgsqlStyleRepository(template); + return CatalogInfoRowMapper.layer(styleLoader::findById); + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlNamespaceRepository.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlNamespaceRepository.java new file mode 100644 index 000000000..50719c1ec --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlNamespaceRepository.java @@ -0,0 +1,83 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.catalog.repository; + +import lombok.Getter; +import lombok.NonNull; + +import org.geoserver.catalog.NamespaceInfo; +import org.geoserver.catalog.plugin.CatalogInfoRepository.NamespaceRepository; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.util.Optional; +import java.util.stream.Stream; + +/** + * @since 1.4 + */ +public class PgsqlNamespaceRepository extends PgsqlCatalogInfoRepository + implements NamespaceRepository { + + private final @Getter Class contentType = NamespaceInfo.class; + private final @Getter String queryTable = "namespaceinfos"; + + /** + * @param template + */ + public PgsqlNamespaceRepository(@NonNull JdbcTemplate template) { + super(template); + } + + @Override + public void setDefaultNamespace(@NonNull NamespaceInfo namespace) { + unsetDefaultNamespace(); + template.update( + """ + UPDATE namespaceinfo SET default_namespace = TRUE WHERE id = ? + """, + namespace.getId()); + } + + @Override + public void unsetDefaultNamespace() { + template.update( + """ + UPDATE namespaceinfo SET default_namespace = FALSE WHERE default_namespace = TRUE + """); + } + + @Override + public Optional getDefaultNamespace() { + return findOne( + """ + SELECT namespace FROM namespaceinfos WHERE default_namespace = TRUE + """); + } + + @Override + public Optional findOneByURI(@NonNull String uri) { + return findOne( + """ + SELECT namespace FROM namespaceinfos WHERE uri = ? + """, + uri); + } + + @Override + public Stream findAllByURI(@NonNull String uri) { + return template.queryForStream( + """ + SELECT namespace FROM namespaceinfos WHERE uri = ? + """, + newRowMapper(), + uri); + } + + @Override + protected RowMapper newRowMapper() { + return CatalogInfoRowMapper.namespace(); + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlResourceRepository.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlResourceRepository.java new file mode 100644 index 000000000..07ccdd804 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlResourceRepository.java @@ -0,0 +1,121 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.catalog.repository; + +import lombok.Getter; +import lombok.NonNull; + +import org.geoserver.catalog.NamespaceInfo; +import org.geoserver.catalog.ResourceInfo; +import org.geoserver.catalog.StoreInfo; +import org.geoserver.catalog.plugin.CatalogInfoRepository.ResourceRepository; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.util.Optional; +import java.util.stream.Stream; + +/** + * @since 1.4 + */ +public class PgsqlResourceRepository extends PgsqlCatalogInfoRepository + implements ResourceRepository { + + private final @Getter Class contentType = ResourceInfo.class; + private final @Getter String queryTable = "resourceinfos"; + + /** + * @param template + */ + public PgsqlResourceRepository(@NonNull JdbcTemplate template) { + super(template); + } + + @Override + public Optional findByNameAndNamespace( + @NonNull String name, @NonNull NamespaceInfo namespace, @NonNull Class clazz) { + String query = + """ + SELECT resource, store, workspace, namespace + FROM resourceinfos + WHERE "namespace.id" = ? AND name = ? + """; + if (ResourceInfo.class.equals(clazz)) { + return findOne(query, clazz, newRowMapper(), namespace.getId(), name); + } + query += " AND \"@type\" = ?::infotype"; + return findOne(query, clazz, newRowMapper(), namespace.getId(), name, infoType(clazz)); + } + + @Override + public Stream findAllByType(@NonNull Class clazz) { + String query = + """ + SELECT resource, store, workspace, namespace + FROM resourceinfos + """; + if (ResourceInfo.class.equals(clazz)) { + return template.queryForStream(query, newRowMapper()).map(clazz::cast); + } + query += " WHERE \"@type\" = ?::infotype"; + return template.queryForStream(query, newRowMapper(), infoType(clazz)).map(clazz::cast); + } + + @Override + public Stream findAllByNamespace( + @NonNull NamespaceInfo ns, @NonNull Class clazz) { + + String query = + """ + SELECT resource, store, workspace, namespace + FROM resourceinfos + WHERE "namespace.id" = ? + """; + if (ResourceInfo.class.equals(clazz)) { + return template.queryForStream(query, newRowMapper(), ns.getId()).map(clazz::cast); + } + query += " AND \"@type\" = ?::infotype"; + return template.queryForStream(query, newRowMapper(), ns.getId(), infoType(clazz)) + .map(clazz::cast); + } + + @Override + public Optional findByStoreAndName( + @NonNull StoreInfo store, @NonNull String name, @NonNull Class clazz) { + + String query = + """ + SELECT resource, store, workspace, namespace + FROM resourceinfos + WHERE "store.id" = ? AND name = ? + """; + if (ResourceInfo.class.equals(clazz)) { + return findOne(query, clazz, newRowMapper(), store.getId(), name); + } + query += " AND \"@type\" = ?::infotype"; + return findOne(query, clazz, newRowMapper(), store.getId(), name, infoType(clazz)); + } + + @Override + public Stream findAllByStore(StoreInfo store, Class clazz) { + String query = + """ + SELECT resource, store, workspace, namespace + FROM resourceinfos + WHERE "store.id" = ? + """; + if (ResourceInfo.class.equals(clazz)) { + return template.queryForStream(query, newRowMapper(), store.getId()).map(clazz::cast); + } + query += " AND \"@type\" = ?::infotype"; + return template.queryForStream(query, newRowMapper(), store.getId(), infoType(clazz)) + .map(clazz::cast); + } + + @Override + protected RowMapper newRowMapper() { + return CatalogInfoRowMapper.resource(); + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlStoreRepository.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlStoreRepository.java new file mode 100644 index 000000000..460b50f0e --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlStoreRepository.java @@ -0,0 +1,146 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.catalog.repository; + +import lombok.Getter; +import lombok.NonNull; + +import org.geoserver.catalog.DataStoreInfo; +import org.geoserver.catalog.StoreInfo; +import org.geoserver.catalog.WorkspaceInfo; +import org.geoserver.catalog.plugin.CatalogInfoRepository.StoreRepository; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.util.Optional; +import java.util.stream.Stream; + +/** + * @since 1.4 + */ +public class PgsqlStoreRepository extends PgsqlCatalogInfoRepository + implements StoreRepository { + + private final @Getter Class contentType = StoreInfo.class; + private final @Getter String queryTable = "storeinfos"; + + /** + * @param template + */ + public PgsqlStoreRepository(@NonNull JdbcTemplate template) { + super(template); + } + + @Override + public Optional findById(@NonNull String id, Class clazz) { + String sql = + """ + SELECT store, workspace + FROM storeinfos + WHERE id = ? + """; + return findOne(sql, clazz, newRowMapper(), id); + } + + @Override + public void setDefaultDataStore( + @NonNull WorkspaceInfo workspace, @NonNull DataStoreInfo dataStore) { + String sql = "UPDATE workspaceinfo SET default_store = ? WHERE id = ?"; + template.update(sql, dataStore.getId(), workspace.getId()); + } + + @Override + public void unsetDefaultDataStore(@NonNull WorkspaceInfo workspace) { + String sql = "UPDATE workspaceinfo SET default_store = NULL WHERE id = ?"; + template.update(sql, workspace.getId()); + } + + @Override + public Optional getDefaultDataStore(@NonNull WorkspaceInfo workspace) { + String sql = + """ + SELECT store, workspace + FROM storeinfos + WHERE "workspace.id" = ? AND default_store IS NOT NULL + """; + return findOne(sql, DataStoreInfo.class, CatalogInfoRowMapper.store(), workspace.getId()); + } + + @Override + public Stream getDefaultDataStores() { + String sql = + """ + SELECT store, workspace + FROM storeinfos + WHERE default_store IS NOT NULL + """; + return template.queryForStream(sql, CatalogInfoRowMapper.store()) + .filter(DataStoreInfo.class::isInstance) + .map(DataStoreInfo.class::cast); + } + + @Override + public Stream findAllByWorkspace( + @NonNull WorkspaceInfo workspace, @NonNull Class clazz) { + + String sql = + """ + SELECT store, workspace + FROM storeinfos + WHERE "workspace.id" = ? + """; + + Stream stores; + String workspaceId = workspace.getId(); + if (StoreInfo.class.equals(clazz)) { + stores = template.queryForStream(sql, CatalogInfoRowMapper.store(), workspaceId); + } else { + String infotype = infoType(clazz); + sql += " AND \"@type\" = ?::infotype"; + stores = + template.queryForStream( + sql, CatalogInfoRowMapper.store(), workspaceId, infotype); + } + + return stores.filter(clazz::isInstance).map(clazz::cast); + } + + @Override + public Stream findAllByType(@NonNull Class clazz) { + RowMapper rowMapper = CatalogInfoRowMapper.store(); + Stream stores; + + if (StoreInfo.class.equals(clazz)) { + stores = template.queryForStream("SELECT store, workspace FROM storeinfos", rowMapper); + } else { + String type = clazz.getSimpleName(); + stores = + template.queryForStream( + "SELECT store, workspace FROM storeinfos WHERE \"@type\" = ?::infotype", + rowMapper, + type); + } + + return stores.filter(clazz::isInstance).map(clazz::cast); + } + + @Override + public Optional findByNameAndWorkspace( + @NonNull String name, @NonNull WorkspaceInfo workspace, @NonNull Class clazz) { + + return findOne( + """ + SELECT store, workspace FROM storeinfos WHERE "workspace.id" = ? AND name = ? + """, + clazz, + workspace.getId(), + name); + } + + @Override + protected RowMapper newRowMapper() { + return CatalogInfoRowMapper.store(); + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlStyleRepository.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlStyleRepository.java new file mode 100644 index 000000000..57bd84b41 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlStyleRepository.java @@ -0,0 +1,85 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.catalog.repository; + +import lombok.Getter; +import lombok.NonNull; + +import org.geoserver.catalog.StyleInfo; +import org.geoserver.catalog.WorkspaceInfo; +import org.geoserver.catalog.plugin.CatalogInfoRepository.StyleRepository; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.util.Optional; +import java.util.stream.Stream; + +/** + * @since 1.4 + */ +public class PgsqlStyleRepository extends PgsqlCatalogInfoRepository + implements StyleRepository { + + private final @Getter Class contentType = StyleInfo.class; + private final @Getter String queryTable = "styleinfos"; + + /** + * @param template + */ + public PgsqlStyleRepository(@NonNull JdbcTemplate template) { + super(template); + } + + @Override + public Stream findAllByNullWorkspace() { + String query = + """ + SELECT style, workspace + FROM styleinfos + WHERE "workspace.id" IS NULL + """; + return template.queryForStream(query, newRowMapper()); + } + + @Override + public Stream findAllByWorkspace(@NonNull WorkspaceInfo ws) { + String query = + """ + SELECT style, workspace + FROM styleinfos + WHERE "workspace.id" = ? + """; + return template.queryForStream(query, newRowMapper(), ws.getId()); + } + + @Override + public Optional findByNameAndWordkspaceNull(@NonNull String name) { + String query = + """ + SELECT style, workspace + FROM styleinfos + WHERE "workspace.id" IS NULL AND name = ? + """; + return findOne(query, StyleInfo.class, newRowMapper(), name); + } + + @Override + public Optional findByNameAndWorkspace( + @NonNull String name, @NonNull WorkspaceInfo workspace) { + + String query = + """ + SELECT style, workspace + FROM styleinfos + WHERE "workspace.id" = ? AND name = ? + """; + return findOne(query, StyleInfo.class, newRowMapper(), workspace.getId(), name); + } + + @Override + protected RowMapper newRowMapper() { + return CatalogInfoRowMapper.style(); + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlWorkspaceRepository.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlWorkspaceRepository.java new file mode 100644 index 000000000..1fcb81975 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlWorkspaceRepository.java @@ -0,0 +1,66 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.catalog.repository; + +import lombok.Getter; +import lombok.NonNull; + +import org.geoserver.catalog.WorkspaceInfo; +import org.geoserver.catalog.plugin.CatalogInfoRepository.WorkspaceRepository; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +/** + * @since 1.4 + */ +public class PgsqlWorkspaceRepository extends PgsqlCatalogInfoRepository + implements WorkspaceRepository { + + private final @Getter Class contentType = WorkspaceInfo.class; + private final @Getter String queryTable = "workspaceinfos"; + + /** + * @param template + */ + public PgsqlWorkspaceRepository(@NonNull JdbcTemplate template) { + super(template); + } + + @Override + public void unsetDefaultWorkspace() { + template.update( + """ + UPDATE workspaceinfo SET default_workspace = FALSE WHERE default_workspace = TRUE + """); + } + + /** TODO: handle transactions and perform unset/set atomically */ + @Override + @Transactional + public void setDefaultWorkspace(@NonNull WorkspaceInfo workspace) { + unsetDefaultWorkspace(); + template.update( + """ + UPDATE workspaceinfo SET default_workspace = TRUE WHERE id = ? + """, + workspace.getId()); + } + + @Override + public Optional getDefaultWorkspace() { + return findOne( + """ + SELECT workspace FROM workspaceinfos WHERE default_workspace = TRUE + """); + } + + @Override + protected RowMapper newRowMapper() { + return CatalogInfoRowMapper.workspace(); + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/UncheckedSqlException.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/UncheckedSqlException.java new file mode 100644 index 000000000..4d9afa0d2 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/catalog/repository/UncheckedSqlException.java @@ -0,0 +1,26 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.catalog.repository; + +import java.sql.SQLException; + +/** + * @since 1.4 + */ +@SuppressWarnings("serial") +class UncheckedSqlException extends RuntimeException { + + UncheckedSqlException(SQLException cause) { + super(cause); + } + + public @Override SQLException getCause() { + return (SQLException) super.getCause(); + } + + static UncheckedSqlException of(SQLException e) { + return new UncheckedSqlException(e); + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/config/PgsqlConfigRepository.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/config/PgsqlConfigRepository.java new file mode 100644 index 000000000..65f36368e --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/config/PgsqlConfigRepository.java @@ -0,0 +1,331 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.config; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +import org.geoserver.catalog.CatalogInfo; +import org.geoserver.catalog.Info; +import org.geoserver.catalog.WorkspaceInfo; +import org.geoserver.catalog.impl.ClassMappings; +import org.geoserver.catalog.plugin.Patch; +import org.geoserver.config.GeoServerInfo; +import org.geoserver.config.LoggingInfo; +import org.geoserver.config.ServiceInfo; +import org.geoserver.config.SettingsInfo; +import org.geoserver.config.plugin.ConfigRepository; +import org.geotools.jackson.databind.util.ObjectMapperUtil; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.util.Arrays; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.stream.Stream; + +/** + * @since 1.4 + */ +@RequiredArgsConstructor +public class PgsqlConfigRepository implements ConfigRepository { + + private final @NonNull JdbcTemplate template; + protected static final ObjectMapper infoMapper = ObjectMapperUtil.newObjectMapper(); + + private static final RowMapper GeoServerInfoRowMapper = + (rs, rn) -> decode(rs.getString("info"), GeoServerInfo.class); + + private static final RowMapper SettingsInfoRowMapper = + (rs, rn) -> decode(rs.getString("info"), SettingsInfo.class); + + private static final RowMapper ServiceInfoRowMapper = + (rs, rn) -> decode(rs.getString("info"), ServiceInfo.class); + + private static final RowMapper LoggingInfoRowMapper = + (rs, rn) -> decode(rs.getString("info"), LoggingInfo.class); + + @Override + public Optional getGlobal() { + String sql = """ + SELECT info FROM geoserverinfo LIMIT 1 + """; + return findOne(sql, GeoServerInfo.class, GeoServerInfoRowMapper); + } + + @Override + public void setGlobal(GeoServerInfo global) { + String value = encode(global); + getGlobal() + .ifPresentOrElse( + g -> + template.update( + "UPDATE geoserverinfo SET info = to_json(?::json)", value), + () -> + template.update( + "INSERT INTO geoserverinfo(info) VALUES (to_json(?::json))", + value)); + } + + @Override + public Optional getSettingsByWorkspace(WorkspaceInfo workspace) { + String query = + """ + SELECT info, workspace FROM settingsinfos WHERE "workspace.id" = ? + """; + String workspaceId = workspace.getId(); + return findOne(query, SettingsInfo.class, SettingsInfoRowMapper, workspaceId); + } + + @Override + public Optional getSettingsById(String id) { + return findById(id, SettingsInfo.class, "settingsinfos", SettingsInfoRowMapper); + } + + protected Optional findById( + String id, Class clazz, String queryTable, RowMapper mapper) { + String query = + """ + SELECT info, workspace FROM %s WHERE id = ? + """ + .formatted(queryTable); + return findOne(query, clazz, mapper, id); + } + + @Override + public void add(SettingsInfo settings) { + String value = encode(settings); + template.update("INSERT INTO settingsinfo(info) VALUES (to_json(?::json))", value); + } + + @Override + public SettingsInfo update(SettingsInfo settings, Patch patch) { + return update( + settings, + patch, + SettingsInfo.class, + "settingsinfo", + "settingsinfos", + SettingsInfoRowMapper); + } + + private T update( + T value, + Patch patch, + Class clazz, + String table, + String querytable, + RowMapper mapper) { + + String id = value.getId(); + Optional found = findById(id, clazz, querytable, mapper); + T patched = + found.map(patch::applyTo) + .orElseThrow( + () -> + new NoSuchElementException( + "%s with id %s does not exist" + .formatted(clazz.getSimpleName(), id))); + + String encoded = encode(patched); + template.update( + """ + UPDATE %s SET info = to_json(?::json) WHERE id = ? + """ + .formatted(table), + encoded, + id); + + return patched; + } + + @Override + public void remove(SettingsInfo settings) { + template.update("DELETE FROM settingsinfo WHERE id = ?", settings.getId()); + } + + @Override + public Optional getLogging() { + String sql = """ + SELECT info FROM logginginfo LIMIT 1 + """; + return findOne(sql, LoggingInfo.class, LoggingInfoRowMapper); + } + + @Override + public void setLogging(LoggingInfo logging) { + String value = encode(logging); + template.update( + """ + DELETE FROM logginginfo; + INSERT INTO logginginfo(info) VALUES(to_json(?::json)) + """, + value); + } + + @Override + public void add(ServiceInfo service) { + String value = encode(service); + template.update("INSERT INTO serviceinfo(info) VALUES(to_json(?::json))", value); + } + + @Override + public void remove(ServiceInfo service) { + template.update("DELETE FROM serviceinfo WHERE id = ?", service.getId()); + } + + @SuppressWarnings("unchecked") + @Override + public S update(S service, Patch patch) { + return (S) + update( + service, + patch, + ServiceInfo.class, + "serviceinfo", + "serviceinfos", + ServiceInfoRowMapper); + } + + @Override + public Stream getGlobalServices() { + return template.queryForStream( + """ + SELECT info, workspace FROM serviceinfos WHERE "workspace.id" IS NULL + """, + ServiceInfoRowMapper); + } + + @Override + public Stream getServicesByWorkspace(WorkspaceInfo workspace) { + String workspaceId = workspace.getId(); + return template.queryForStream( + """ + SELECT info, workspace FROM serviceinfos WHERE "workspace.id" = ? + """, + ServiceInfoRowMapper, + workspaceId); + } + + @Override + public Optional getGlobalService(Class clazz) { + return findService(""" + "workspace.id" IS NULL + """, clazz); + } + + @Override + public Optional getServiceByWorkspace( + WorkspaceInfo workspace, Class clazz) { + + return findService( + """ + "workspace.id" = ? + """, + clazz, + workspace.getId()); + } + + @Override + public Optional getServiceById(String id, Class clazz) { + return findService(""" + id = ? + """, clazz, id); + } + + private Optional findService( + String whereClause, Class clazz, Object... args) { + + String sql = "SELECT info, workspace FROM serviceinfos WHERE " + whereClause; + if (!ServiceInfo.class.equals(clazz)) { + String servicetype = servicetype(clazz); + sql += + """ + AND "@type" = '%s' + """ + .formatted(servicetype); + } + return findOne(sql, ServiceInfo.class, ServiceInfoRowMapper, args) + .filter(clazz::isInstance) + .map(clazz::cast); + } + + private String servicetype(Class clazz) { + if (ServiceInfo.class.equals(clazz)) return ServiceInfo.class.getSimpleName(); + Class iface = + clazz.isInterface() + ? clazz + : Arrays.stream(clazz.getInterfaces()) + .filter(ServiceInfo.class::isAssignableFrom) + .findFirst() + .orElseThrow(IllegalArgumentException::new); + return iface.getSimpleName(); + } + + @Override + public Optional getServiceByName(String name, Class clazz) { + return findService(""" + name = ? + """, clazz, name); + } + + @Override + public Optional getServiceByNameAndWorkspace( + String name, WorkspaceInfo workspace, Class clazz) { + + return findService( + """ + "workspace.id" = ? AND name = ? + """, + clazz, + workspace.getId(), + name); + } + + @Override + public void dispose() { + // no-op + } + + private String encode(Info info) { + try { + return infoMapper.writeValueAsString(info); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + private static C decode(String value, Class type) { + try { + return infoMapper.readValue(value, type); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + private static @NonNull String infoType(Class clazz) { + ClassMappings cm; + if (clazz.isInterface()) cm = ClassMappings.fromInterface(clazz); + else cm = ClassMappings.fromImpl(clazz); + + String infotype = cm.getInterface().getSimpleName(); + return infotype; + } + + protected Optional findOne( + @NonNull String query, Class clazz, RowMapper rowMapper, Object... args) { + + try { + U object = template.queryForObject(query, rowMapper, args); + return Optional.ofNullable(clazz.isInstance(object) ? clazz.cast(object) : null); + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/config/PgsqlGeoServerFacade.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/config/PgsqlGeoServerFacade.java new file mode 100644 index 000000000..2af146337 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/config/PgsqlGeoServerFacade.java @@ -0,0 +1,24 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.config; + +import lombok.NonNull; + +import org.geoserver.config.plugin.RepositoryGeoServerFacadeImpl; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * @since 1.4 + */ +public class PgsqlGeoServerFacade extends RepositoryGeoServerFacadeImpl { + + public PgsqlGeoServerFacade(@NonNull JdbcTemplate template) { + super(new PgsqlConfigRepository(template)); + } + + public PgsqlGeoServerFacade(@NonNull PgsqlConfigRepository repo) { + super(repo); + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/config/PgsqlUpdateSequence.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/config/PgsqlUpdateSequence.java new file mode 100644 index 000000000..93e5ec7fa --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/config/PgsqlUpdateSequence.java @@ -0,0 +1,76 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.config; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +import org.geoserver.config.GeoServerInfo; +import org.geoserver.config.impl.GeoServerInfoImpl; +import org.geoserver.platform.config.UpdateSequence; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import javax.sql.DataSource; + +/** + * @since 1.4 + */ +@RequiredArgsConstructor +public class PgsqlUpdateSequence implements UpdateSequence { + + private static final String SEQUENCE_NAME = "gs_update_sequence"; + + // not using CURRVAL() to avoid the "currval of sequence "" is not yet defined in this + // session" error + private static final String getQuery = "SELECT last_value FROM %s".formatted(SEQUENCE_NAME); + + private static final String incrementAndGetQuery = + "SELECT NEXTVAL('%s')".formatted(SEQUENCE_NAME); + + private final @NonNull DataSource dataSource; + private final @NonNull PgsqlGeoServerFacade geoServer; + + @Override + public long currValue() { + return runAndGetLong(getQuery); + } + + @Override + public synchronized long nextValue() { + long nextValue = runAndGetLong(incrementAndGetQuery); + GeoServerInfo global = geoServer.getGlobal(); + if (null == global) { + global = new GeoServerInfoImpl(); + global.setUpdateSequence(nextValue); + geoServer.setGlobal(global); + } else { + global.setUpdateSequence(nextValue); + geoServer.save(global); + } + return nextValue; + } + + protected long runAndGetLong(String query) { + try (Connection c = dataSource.getConnection()) { + c.setAutoCommit(false); + c.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE); + try (Statement st = c.createStatement(); + ResultSet rs = st.executeQuery(query)) { + if (rs.next()) { + return rs.getLong(1); + } + throw new IllegalStateException("Query did not return a result: " + getQuery); + } finally { + c.setAutoCommit(true); + } + } catch (SQLException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/gwc/GeoServerTileLayerInfoRowMapper.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/gwc/GeoServerTileLayerInfoRowMapper.java new file mode 100644 index 000000000..24277ae15 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/gwc/GeoServerTileLayerInfoRowMapper.java @@ -0,0 +1,22 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.gwc; + +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * @since 1.4 + */ +public class GeoServerTileLayerInfoRowMapper implements RowMapper { + + @Override + public PgsqlTileLayerInfo mapRow(ResultSet rs, int rowNum) throws SQLException { + // TODO Auto-generated method stub + return null; + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/gwc/PgsqlTileLayerCatalog.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/gwc/PgsqlTileLayerCatalog.java new file mode 100644 index 000000000..51aba91d9 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/gwc/PgsqlTileLayerCatalog.java @@ -0,0 +1,221 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.gwc; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +import org.geoserver.catalog.WorkspaceInfo; +import org.geoserver.gwc.layer.GeoServerTileLayerInfo; +import org.geoserver.gwc.layer.TileLayerCatalog; +import org.geoserver.gwc.layer.TileLayerCatalogListener; +import org.geoserver.ows.LocalWorkspace; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.Set; +import java.util.stream.Collectors; + +/** + * @since 1.4 + */ +@RequiredArgsConstructor +public class PgsqlTileLayerCatalog implements TileLayerCatalog { + + private final @NonNull JdbcTemplate template; + + private GeoServerTileLayerInfoRowMapper rowMapper; + + @Override + public void addListener(TileLayerCatalogListener listener) { + throw new UnsupportedOperationException("implement"); + } + + @Override + public boolean exists(String layerId) { + return template.queryForObject( + """ + SELECT exists(id) FROM tile_layer WHERE id = ? + """, + (rs, rn) -> rs.getBoolean(1), + layerId); + } + + @Override + public Set getLayerIds() { + return template.queryForStream( + """ + SELECT id FROM tile_layers + """, + (rs, rn) -> rs.getString(1)) + .collect(Collectors.toSet()); + } + + @Override + public Set getLayerNames() { + return template.queryForStream( + """ + SELECT name FROM tile_layers ORDER BY name + """, + (rs, rn) -> rs.getString(1)) + .collect(Collectors.toSet()); + } + + @Override + public String getLayerId(String layerName) { + final WorkspaceInfo ws = LocalWorkspace.get(); + final String workspace = workspace(layerName); + final String name = name(layerName); + if (ws != null && !layerName.startsWith(ws.getName() + ":")) { + throw new IllegalArgumentException( + "Local workspace is %s, but requested layer %s" + .formatted(ws.getName(), layerName)); + } + + if (null == workspace) { + return template.queryForObject( + """ + SELECT id FROM tile_layers WHERE workspace IS NULL AND name = ? + """, + (rs, rn) -> rs.getString(1), + name); + } + return template.queryForObject( + """ + SELECT id FROM tile_layers WHERE workspace = ? AND name = ? + """, + (rs, rn) -> rs.getString(1), + workspace, + name); + } + + private String name(String layerName) { + return layerName.indexOf(':') == -1 + ? layerName + : layerName.substring(1 + layerName.indexOf(':')); + } + + private String workspace(String layerName) { + return layerName.indexOf(':') == -1 ? null : layerName.substring(0, layerName.indexOf(':')); + } + + @Override + public String getLayerName(String layerId) { + return template.queryForObject( + """ + SELECT workspace, name FROM tile_layers WHERE id = ? + """, + (rs, rn) -> { + String ws = rs.getString(1); + String name = rs.getString(2); + return ws == null ? name : (ws + ":" + name); + }, + layerId); + } + + @Override + public PgsqlTileLayerInfo getLayerById(String id) { + return template.queryForObject( + """ + SELECT * FROM tile_layers WHERE id = ? + """, + rowMapper, + id); + } + + @Override + public PgsqlTileLayerInfo getLayerByName(String layerName) { + final WorkspaceInfo ws = LocalWorkspace.get(); + final String workspace = workspace(layerName); + final String name = name(layerName); + if (ws != null && !layerName.startsWith(ws.getName() + ":")) { + throw new IllegalArgumentException( + "Local workspace is %s, but requested layer %s" + .formatted(ws.getName(), layerName)); + } + if (null == workspace) { + return template.queryForObject( + """ + SELECT * FROM tile_layers WHERE workspace IS NULL AND name = ? + """, + rowMapper, + workspace, + name); + } + return template.queryForObject( + """ + SELECT * FROM tile_layers WHERE workspace = ? AND name = ? + """, + rowMapper, + workspace, + name); + } + + @Override + public PgsqlTileLayerInfo delete(String tileLayerId) { + PgsqlTileLayerInfo currValue = getLayerById(tileLayerId); + if (null != currValue) { + int updated = + template.update( + """ + DELETE FROM tile_layer WHERE id = ? + """, + tileLayerId); + if (0 == updated) currValue = null; + } + return currValue; + } + + @Override + public PgsqlTileLayerInfo save(GeoServerTileLayerInfo newValue) { + // TODO: make sure name clashes throw a sql exception + // if (oldValue == null) { + // final String duplicateNameId = layersByName.get(newValue.getName()); + // if (null != duplicateNameId) { + // throw new IllegalArgumentException( + // "TileLayer with same name already exists: " + // + newValue.getName() + // + ": <" + // + duplicateNameId + // + ">"); + // } + // } + final String encoded = encode(newValue); + final String tileLayerId = newValue.getId(); + + int updated = + template.update( + """ + UPDATE tile_layer SET info = to_json(?::json) WHERE id = ? + """, + tileLayerId, + encoded); + if (1 == updated) return getLayerById(tileLayerId); + + throw new IllegalArgumentException("TileLayer %s does not exist".formatted(tileLayerId)); + } + + /** + * @param newValue + * @return + */ + private String encode(GeoServerTileLayerInfo newValue) { + throw new UnsupportedOperationException(); + } + + @Override + public void initialize() { + throw new UnsupportedOperationException("implement"); + } + + @Override + public void reset() { + throw new UnsupportedOperationException("implement"); + } + + @Override + public String getPersistenceLocation() { + throw new UnsupportedOperationException("implement"); + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/gwc/PgsqlTileLayerInfo.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/gwc/PgsqlTileLayerInfo.java new file mode 100644 index 000000000..1700da09d --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/gwc/PgsqlTileLayerInfo.java @@ -0,0 +1,25 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.gwc; + +import lombok.Getter; +import lombok.Setter; + +import org.geoserver.gwc.layer.GeoServerTileLayerInfoImpl; + +/** + * @since 1.4 + */ +@SuppressWarnings("serial") +public class PgsqlTileLayerInfo extends GeoServerTileLayerInfoImpl { + + @Getter @Setter private String workspaceId; + + @Override + public GeoServerTileLayerInfoImpl clone() { + PgsqlTileLayerInfo clone = (PgsqlTileLayerInfo) super.clone(); + return clone; + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/resource/FileSystemResourceStoreCache.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/resource/FileSystemResourceStoreCache.java new file mode 100644 index 000000000..de403e0b9 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/resource/FileSystemResourceStoreCache.java @@ -0,0 +1,149 @@ +package org.geoserver.cloud.backend.pgsql.resource; + +import com.google.common.base.Preconditions; + +import lombok.NonNull; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +import org.geoserver.platform.resource.Resource; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.util.FileSystemUtils; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileTime; +import java.util.List; + +@Slf4j +public class FileSystemResourceStoreCache implements DisposableBean { + + private final Path base; + private boolean disposable; + + private FileSystemResourceStoreCache(@NonNull Path cacheDirectory, boolean disposable) { + this.disposable = disposable; + Preconditions.checkArgument( + Files.isDirectory(cacheDirectory), + "Cache directory is not a directory: " + cacheDirectory.toAbsolutePath()); + Preconditions.checkArgument( + Files.isWritable(cacheDirectory), + "Cache directory is not writable: " + cacheDirectory.toAbsolutePath()); + this.base = cacheDirectory; + } + + @SneakyThrows + public static @NonNull FileSystemResourceStoreCache newTempDirInstance() { + boolean disposable = true; + Path tempDirectory = Files.createTempDirectory("pgsql_resourcestore_cache"); + return new FileSystemResourceStoreCache(tempDirectory, disposable); + } + + public static @NonNull FileSystemResourceStoreCache of(@NonNull Path cacheDirectory) { + boolean disposable = false; + return new FileSystemResourceStoreCache(cacheDirectory, disposable); + } + + public @Override void destroy() { + if (disposable && Files.isDirectory(this.base)) { + try { + log.info("Deleting resource store cache directory {}", base); + FileSystemUtils.deleteRecursively(base); + log.info("Resource store cache directory {} deleted", base); + } catch (IOException e) { + log.warn("Error deleting resource cache {}", base, e); + } + } + } + + @SneakyThrows + public File getFile(PgsqlResource resource) { + final Path path = ensureFileExists(resource); + final long fileMtime = getLastmodified(path); + final long resourceMtime = resource.lastmodified(); + if (fileMtime != resourceMtime) { + dump(resource); + } + return path.toFile(); + } + + private long getLastmodified(final Path path) throws IOException { + BasicFileAttributes attr = Files.readAttributes(path, BasicFileAttributes.class); + long fileMtime = attr.lastModifiedTime().toMillis(); + return fileMtime; + } + + public Path ensureFileExists(PgsqlResource resource) throws IOException { + Preconditions.checkArgument(resource.isFile()); + Path path = toPath(resource); + if (!Files.exists(path)) { + ensureDirectoryExists(path.getParent()); + Files.createFile(path); + } + return path; + } + + @SneakyThrows + public File getDirectory(PgsqlResource resource) { + return ensureDirectory(resource).toFile(); + } + + @SneakyThrows + public Path ensureDirectory(PgsqlResource resource) { + Preconditions.checkArgument(resource.isDirectory()); + Path path = toPath(resource); + return ensureDirectoryExists(path); + } + + private Path ensureDirectoryExists(Path path) throws IOException { + if (!Files.exists(path)) { + Files.createDirectories(path); + } + return path; + } + + @SneakyThrows + private Path dump(PgsqlResource resource) { + try (InputStream in = resource.in()) { + return dump(resource, in); + } + } + + @SneakyThrows + public Path dump(PgsqlResource resource, InputStream in) { + Path file = ensureFileExists(resource); + Files.copy(in, file, StandardCopyOption.REPLACE_EXISTING); + Files.setLastModifiedTime(file, FileTime.fromMillis(resource.lastmodified())); + return file; + } + + public void updateAll(List list) { + list.stream() + .map(PgsqlResource.class::cast) + .filter(PgsqlResource::isDirectory) + .forEach(this::ensureDirectory); + + list.stream() + .map(PgsqlResource.class::cast) + .filter(PgsqlResource::isFile) + .forEach(this::dump); + } + + private Path toPath(PgsqlResource resource) { + return base.resolve(resource.path()); + } + + @SneakyThrows + public void moved(@NonNull PgsqlResource source, @NonNull PgsqlResource target) { + Path sourcePath = toPath(source); + Path targetPath = toPath(target); + if (Files.exists(sourcePath)) { + Files.move(sourcePath, targetPath, StandardCopyOption.REPLACE_EXISTING); + } + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/resource/PgsqlLockProvider.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/resource/PgsqlLockProvider.java new file mode 100644 index 000000000..d751da4c1 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/resource/PgsqlLockProvider.java @@ -0,0 +1,46 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.resource; + +import lombok.extern.slf4j.Slf4j; + +import org.geoserver.platform.resource.LockAdapter; +import org.geoserver.platform.resource.LockProvider; +import org.geoserver.platform.resource.Resource.Lock; +import org.springframework.integration.support.locks.LockRegistry; + +import java.util.Objects; + +/** + * Adapts a spring-integration-jdbc's {@link LockRegistry} to a GeoServer {@link LockProvider} + * + *

Of most interest here is using a {@link LockRegistry} backed by a {@link + * org.springframework.integration.jdbc.lock.DefaultLockRepository} to provide distributed locking + * on a clustered environment using the database to hold the locks. + * + * @since 1.4 + */ +@Slf4j +public class PgsqlLockProvider implements LockProvider { + + private LockRegistry registry; + + public PgsqlLockProvider(LockRegistry registry) { + Objects.requireNonNull(registry); + this.registry = registry; + } + + @Override + public Lock acquire(String path) { + Objects.requireNonNull(path); + log.debug("Acquiring lock on {}", path); + + java.util.concurrent.locks.Lock lock = registry.obtain(path); + lock.lock(); + + log.debug("Acquired lock on {}", path); + return new LockAdapter(path, lock); + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/resource/PgsqlResource.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/resource/PgsqlResource.java new file mode 100644 index 000000000..d0c655b38 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/resource/PgsqlResource.java @@ -0,0 +1,185 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.resource; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NonNull; + +import org.geoserver.platform.resource.Paths; +import org.geoserver.platform.resource.Resource; +import org.geoserver.platform.resource.ResourceListener; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; + +/** + * @since 1.4 + */ +@EqualsAndHashCode(exclude = {"store"}) +class PgsqlResource implements Resource { + + static final long ROOT_ID = 0L; + static final long UNDEFINED_ID = -1L; + + @Getter long id; + @Getter long parentId; + Resource.Type type; + String path; + long lastmodified; + private PgsqlResourceStore store; + + PgsqlResource( + @NonNull PgsqlResourceStore store, + long id, + long parentId, + @NonNull Resource.Type type, + @NonNull String path, + long lastmodified) { + this.store = store; + this.id = id; + this.parentId = parentId; + this.type = type; + this.path = path; + this.lastmodified = lastmodified; + } + + /** Undefined type constructor */ + PgsqlResource(@NonNull PgsqlResourceStore store, @NonNull String path) { + this.store = store; + this.id = UNDEFINED_ID; + this.parentId = UNDEFINED_ID; + this.type = Type.UNDEFINED; + this.path = path; + this.lastmodified = 0L; + } + + void copy(PgsqlResource other) { + this.id = other.id; + this.parentId = other.parentId; + this.type = other.type; + this.path = other.path; + this.lastmodified = other.lastmodified; + } + + @Override + public String path() { + return path; + } + + @Override + public String name() { + return Paths.name(path); + } + + @Override + public InputStream in() { + byte[] contents = store.contents(this); + return new ByteArrayInputStream(contents); + } + + @Override + public OutputStream out() { + return store.out(this); + } + + @Override + public Lock lock() { + return store.getLockProvider().acquire(path()); + } + + @Override + public File file() { + return store.asFile(this); + } + + @Override + public File dir() { + return store.asDir(this); + } + + @Override + public long lastmodified() { + return lastmodified; + } + + @Override + public PgsqlResource parent() { + if (ROOT_ID == id) return null; + return (PgsqlResource) store.get(parentPath()); + } + + @Override + public Resource get(@NonNull String childPath) { + if ("".equals(childPath)) { + return this; + } + String resourcePath = Paths.path(path(), childPath); + return store.get(resourcePath); + } + + @Override + public List list() { + return store.list(this); + } + + @Override + public Type getType() { + return type; + } + + @Override + public boolean delete() { + return store.delete(this); + } + + @Override + public boolean renameTo(@NonNull Resource dest) { + return store.move(this, ((PgsqlResource) dest)); + } + + @Override + public void addListener(ResourceListener listener) { + // TODO Auto-generated method stub + } + + @Override + public void removeListener(ResourceListener listener) { + // TODO Auto-generated method stub + } + + public String parentPath() { + return Paths.parent(path()); + } + + @Override + public String toString() { + return path; + } + + public PgsqlResource mkdirs() { + store.mkdirs(this); + return this; + } + + public boolean exists() { + return id != UNDEFINED_ID; + } + + public boolean isFile() { + return getType() == Type.RESOURCE; + } + + public boolean isDirectory() { + return getType() == Type.DIRECTORY; + } + + public boolean isUndefined() { + return getType() == Type.UNDEFINED; + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/resource/PgsqlResourceRowMapper.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/resource/PgsqlResourceRowMapper.java new file mode 100644 index 000000000..842f74a48 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/resource/PgsqlResourceRowMapper.java @@ -0,0 +1,48 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.resource; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +import org.geoserver.platform.resource.Resource; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * @since 1.4 + */ +@RequiredArgsConstructor +public class PgsqlResourceRowMapper implements RowMapper { + + private final @NonNull PgsqlResourceStore store; + + /** + * Expects the following columns: + * + *

{@code
+     * id         BIGINT
+     * parentid   BIGINT
+     * "type"     resourcetype
+     * path 	  TEXT
+     * mtime	  timestamp
+     * }
+ */ + @Override + public PgsqlResource mapRow(ResultSet rs, int rowNum) throws SQLException { + long id = rs.getLong("id"); + long parentId = rs.getLong("parentid"); + Resource.Type type = Resource.Type.valueOf(rs.getString("type")); + String path = rs.getString("path"); + long mtime = rs.getTimestamp("mtime").getTime(); + return new PgsqlResource(store, id, parentId, type, path, mtime); + } + + public PgsqlResource undefined(String path) { + return new PgsqlResource(store, path); + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/resource/PgsqlResourceStore.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/resource/PgsqlResourceStore.java new file mode 100644 index 000000000..99811e3e8 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/backend/pgsql/resource/PgsqlResourceStore.java @@ -0,0 +1,326 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.resource; + +import com.google.common.base.Preconditions; + +import lombok.Getter; +import lombok.NonNull; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; + +import org.geoserver.platform.resource.Files; +import org.geoserver.platform.resource.LockProvider; +import org.geoserver.platform.resource.Paths; +import org.geoserver.platform.resource.Resource; +import org.geoserver.platform.resource.Resource.Type; +import org.geoserver.platform.resource.ResourceNotificationDispatcher; +import org.geoserver.platform.resource.ResourceStore; +import org.geoserver.platform.resource.SimpleResourceNotificationDispatcher; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.OutputStream; +import java.nio.file.Path; +import java.sql.Timestamp; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +/** + * @since 1.4 + */ +@Slf4j +public class PgsqlResourceStore implements ResourceStore { + + private final JdbcTemplate template; + private final FileSystemResourceStoreCache cache; + + private @NonNull @Getter @Setter LockProvider lockProvider; + + private final PgsqlResourceRowMapper queryMapper; + + public PgsqlResourceStore( + @NonNull Path cacheDirectory, + @NonNull JdbcTemplate template, + @NonNull PgsqlLockProvider lockProvider) { + this(FileSystemResourceStoreCache.of(cacheDirectory), template, lockProvider); + } + + public PgsqlResourceStore( + @NonNull FileSystemResourceStoreCache cache, + @NonNull JdbcTemplate template, + @NonNull PgsqlLockProvider lockProvider) { + this.template = template; + this.lockProvider = lockProvider; + this.queryMapper = new PgsqlResourceRowMapper(this); + this.cache = cache; + } + + @Override + public Resource get(@NonNull String path) { + if (Paths.isAbsolute(path)) { + return Files.asResource(new File(path)); + } + return findByPath(path).orElseGet(() -> queryMapper.undefined(path)); + } + + public Optional findByPath(@NonNull String path) { + path = Paths.valid(path); + Preconditions.checkArgument( + !path.startsWith("/"), "Absolute paths not supported: %s", path); + try { + return Optional.of( + template.queryForObject( + """ + SELECT id, parentid, "type", path, mtime FROM resources WHERE path = ? + """, + queryMapper, + path)); + } catch (EmptyResultDataAccessException empty) { + return Optional.empty(); + } + } + + /** + * Creates the resource if it doesn't exist, updates it if it does + * + * @throws IllegalArgumentException if {@link PgsqlResource#isUndefined()} + */ + public void save(@NonNull PgsqlResource resource) { + if (resource.isUndefined()) + throw new IllegalArgumentException( + "Attempting to save a resource of undefined type: " + resource); + + if (resource.exists()) { + String sql = + """ + UPDATE resourcestore SET parentid = ?, "type" = ?, name = ? + WHERE id = ?; + """; + long id = resource.getId(); + long parentId = resource.getParentId(); + String type = resource.getType().toString(); + String name = resource.name(); + template.update(sql, parentId, type, name, id); + } else { + PgsqlResource parent = resource.parent().mkdirs(); + String sql = + """ + INSERT INTO resourcestore (parentid, "type", name, content) + VALUES (?, ?, ?, ?); + """; + long parentId = parent.getId(); + String type = resource.getType().toString(); + String name = resource.name(); + byte[] contents = resource.getType() == Type.DIRECTORY ? null : new byte[0]; + template.update(sql, parentId, type, name, contents); + } + } + + /** + * Saves the contents of the given resource + * + * @return the new resource lastupdated timestamp + * @throws IllegalArgumentException if + * {@link PgsqlResource#isDirectory() resource.isDirectory()} || !{@link + * PgsqlResource#exists() resource.exists()} + */ + public long save(@NonNull PgsqlResource resource, byte[] contents) { + if (!resource.exists()) + throw new IllegalArgumentException("Resource does not exist: " + resource.path()); + + if (!resource.isFile()) + throw new IllegalArgumentException( + "Resource is a directory, can't have contents: " + resource.path()); + + if (null == contents) contents = new byte[0]; + template.update( + """ + UPDATE resourcestore SET content = ? WHERE id = ? + """, + contents, + resource.getId()); + return getLastmodified(resource.getId()); + } + + public long getLastmodified(long resourceId) { + return template.queryForObject( + "SELECT mtime FROM resourcestore WHERE id = ?", Timestamp.class, resourceId) + .getTime(); + } + + @Override + public boolean remove(@NonNull String path) { + return findByPath(path).map(PgsqlResource::delete).orElse(false); + } + + @Override + public boolean move(@NonNull String path, @NonNull String target) { + ensureNotAbsolute(path); + ensureNotAbsolute(target); + return move((PgsqlResource) get(path), (PgsqlResource) get(target)); + } + + private void ensureNotAbsolute(String path) { + Preconditions.checkArgument( + !Paths.isAbsolute(path), "Absolute paths not supported: %s", path); + } + + public boolean move(@NonNull PgsqlResource source, @NonNull PgsqlResource target) { + if (source.isUndefined()) return true; + if (!source.exists()) { + return false; + } + if (target.exists()) { + target.delete(); + } + final String parentPath = target.parentPath(); + if (null != parentPath && parentPath.contains(source.path())) { + log.warn( + "Cannot rename a resource to a descendant of itself ({} to {})", + source.path(), + target.path()); + return false; + } + PgsqlResource parent = target.parent().mkdirs(); + PgsqlResource save = + new PgsqlResource( + this, + source.getId(), + parent.getId(), + source.getType(), + target.path(), + source.lastmodified()); + save(save); + target.copy(save); + source.type = Type.UNDEFINED; + cache.moved(source, target); + return true; + } + + private ResourceNotificationDispatcher dispatcher = new SimpleResourceNotificationDispatcher(); + + @Override + public ResourceNotificationDispatcher getResourceNotificationDispatcher() { + return dispatcher; + } + + /** + * @return + */ + public byte[] contents(PgsqlResource resource) { + if (!resource.exists() || resource.isUndefined()) + throw new IllegalStateException("File not found " + resource.path()); + if (resource.isDirectory()) + throw new IllegalStateException(resource.path() + " is a directory"); + + long id = resource.getId(); + return template.queryForObject( + """ + SELECT content FROM resourcestore WHERE id = ? + """, byte[].class, id); + } + + public boolean delete(PgsqlResource resource) { + String sql = """ + DELETE FROM resourcestore WHERE id = ? + """; + boolean deleted = 0 < template.update(sql, resource.getId()); + if (deleted) { + resource.type = Type.UNDEFINED; + } + return deleted; + } + + /** + * @return direct children of resource iif resource is a directory, empty list otherwise + */ + public List list(PgsqlResource resource) { + if (!resource.exists() || !resource.isDirectory()) return List.of(); + + String sql = + """ + SELECT id, parentid, "type", path, mtime FROM resources WHERE parentid = ? + """; + + List list; + try (Stream s = + template.queryForStream(sql, queryMapper, resource.getId())) { + list = s.map(Resource.class::cast).toList(); + } + cache.updateAll(list); + return list; + } + + /** + * @return + */ + public File asFile(PgsqlResource resource) { + if (!resource.exists()) { + resource.type = Type.RESOURCE; + save(resource); + } + return cache.getFile(resource); + } + + /** + * @param resource + * @return + */ + public File asDir(PgsqlResource resource) { + if (!resource.exists()) { + resource.type = Type.DIRECTORY; + save(resource); + } + return cache.getDirectory(resource); + } + + public void mkdirs(PgsqlResource resource) { + if (resource.exists() && resource.isDirectory()) { + return; + } + if (resource.isFile()) + throw new IllegalStateException( + "mkdirs() can only be called on DIRECTORY or UNDEFINED resources"); + + PgsqlResource parent = resource.parent(); + if (null == parent) return; + if (!parent.exists()) { + parent = parent.mkdirs(); + } + resource.parentId = parent.getId(); + resource.type = Type.DIRECTORY; + save(resource); + PgsqlResource saved = (PgsqlResource) get(resource.path()); + resource.copy(saved); + } + + public OutputStream out(PgsqlResource res) { + if (res.isDirectory()) { + throw new IllegalStateException(res.path() + " is a directory"); + } + if (res.isUndefined()) { + res.type = Type.RESOURCE; + } + return new ByteArrayOutputStream() { + public @Override void close() { + if (!res.exists()) { + String path = res.path(); + save(res); + PgsqlResource saved = findByPath(path).orElseThrow(); + res.copy(saved); + } + byte[] contents = this.toByteArray(); + long mtime = save(res, contents); + res.lastmodified = mtime; + cache.dump(res, new ByteArrayInputStream(contents)); + } + }; + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/DatabaseMigrationConfiguration.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/DatabaseMigrationConfiguration.java new file mode 100644 index 000000000..ef85f5dbd --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/DatabaseMigrationConfiguration.java @@ -0,0 +1,48 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.config.catalog.backend.pgsql; + +import lombok.RequiredArgsConstructor; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.sql.DataSource; + +/** + * @since 1.4 + */ +@Configuration +@EnableConfigurationProperties(PgsqlBackendProperties.class) +public class DatabaseMigrationConfiguration { + + @Bean + Migrations pgsqlMigrations( + PgsqlBackendProperties config, + @Qualifier("pgsqlConfigDatasource") DataSource dataSource) { + + return new Migrations(config, dataSource); + } + + @RequiredArgsConstructor + static class Migrations implements InitializingBean { + + private final PgsqlBackendProperties config; + private final DataSource dataSource; + + @Override + public void afterPropertiesSet() throws Exception { + new PgsqlDatabaseMigrations() + .setInitialize(config.isInitialize()) + .setDataSource(dataSource) + .setSchema(config.schema()) + .setCreateSchema(config.isCreateSchema()) + .migrate(); + } + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/GeoServerConfigInitializer.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/GeoServerConfigInitializer.java new file mode 100644 index 000000000..cf68b2d1e --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/GeoServerConfigInitializer.java @@ -0,0 +1,75 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.config.catalog.backend.pgsql; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.geoserver.GeoServerConfigurationLock; +import org.geoserver.GeoServerConfigurationLock.LockType; +import org.geoserver.config.GeoServer; +import org.geoserver.config.GeoServerInitializer; +import org.geoserver.config.ServiceInfo; +import org.geoserver.config.ServiceLoader; +import org.geoserver.config.util.XStreamServiceLoader; +import org.geoserver.platform.ExtensionPriority; +import org.geoserver.platform.GeoServerExtensions; +import org.springframework.core.Ordered; + +import java.util.List; + +/** + * @since 1.4 + */ +@RequiredArgsConstructor +@Slf4j +public class GeoServerConfigInitializer + implements GeoServerInitializer, Ordered, ExtensionPriority { + + private final @NonNull GeoServerConfigurationLock configLock; + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE; + } + + @Override + public int getPriority() { + return ExtensionPriority.HIGHEST; + } + + @Override + public void initialize(GeoServer geoServer) throws Exception { + configLock.lock(LockType.READ); + try { + if (geoServer.getGlobal() != null) { + return; + } + log.info("initializing geoserver global config"); + geoServer.setGlobal(geoServer.getFactory().createGlobal()); + + if (geoServer.getLogging() == null) { + log.info("initializing geoserver logging config"); + geoServer.setLogging(geoServer.getFactory().createLogging()); + } + // also ensure we have a service configuration for every service we know about + @SuppressWarnings("rawtypes") + final List loaders = + GeoServerExtensions.extensions(XStreamServiceLoader.class); + for (ServiceLoader l : loaders) { + ServiceInfo s = geoServer.getService(l.getServiceClass()); + if (s == null) { + log.info( + "creating default service config for " + + l.getServiceClass().getSimpleName()); + geoServer.add(l.create(geoServer)); + } + } + } finally { + configLock.unlock(); + } + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/PgsqlBackendConfiguration.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/PgsqlBackendConfiguration.java new file mode 100644 index 000000000..7c868b14e --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/PgsqlBackendConfiguration.java @@ -0,0 +1,167 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.config.catalog.backend.pgsql; + +import lombok.extern.slf4j.Slf4j; + +import org.geoserver.GeoServerConfigurationLock; +import org.geoserver.catalog.plugin.CatalogPlugin; +import org.geoserver.catalog.plugin.ExtendedCatalogFacade; +import org.geoserver.catalog.plugin.locking.LockProviderGeoServerConfigurationLock; +import org.geoserver.cloud.backend.pgsql.PgsqlBackendBuilder; +import org.geoserver.cloud.backend.pgsql.catalog.PgsqlCatalogFacade; +import org.geoserver.cloud.backend.pgsql.config.PgsqlConfigRepository; +import org.geoserver.cloud.backend.pgsql.config.PgsqlGeoServerFacade; +import org.geoserver.cloud.backend.pgsql.config.PgsqlUpdateSequence; +import org.geoserver.cloud.backend.pgsql.resource.FileSystemResourceStoreCache; +import org.geoserver.cloud.backend.pgsql.resource.PgsqlLockProvider; +import org.geoserver.cloud.backend.pgsql.resource.PgsqlResourceStore; +import org.geoserver.cloud.config.catalog.backend.core.CatalogProperties; +import org.geoserver.cloud.config.catalog.backend.core.GeoServerBackendConfigurer; +import org.geoserver.cloud.config.catalog.backend.pgsql.DatabaseMigrationConfiguration.Migrations; +import org.geoserver.config.GeoServerLoader; +import org.geoserver.platform.GeoServerResourceLoader; +import org.geoserver.platform.resource.LockProvider; +import org.geoserver.platform.resource.ResourceStore; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.integration.jdbc.lock.DefaultLockRepository; +import org.springframework.integration.jdbc.lock.JdbcLockRegistry; +import org.springframework.integration.jdbc.lock.LockRepository; +import org.springframework.integration.support.locks.LockRegistry; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.util.StringUtils; + +import javax.sql.DataSource; + +/** + * @since 1.4 + */ +@Slf4j +@Configuration(proxyBeanMethods = true) +public class PgsqlBackendConfiguration implements GeoServerBackendConfigurer { + + private String instanceId; + private DataSource dataSource; + private @Autowired CatalogProperties properties; + + PgsqlBackendConfiguration( + @Value("${info.instance-id:}") String instanceId, + @Qualifier("pgsqlConfigDatasource") DataSource dataSource, + Migrations migrations) { + this.instanceId = instanceId; + this.dataSource = dataSource; + } + + @Bean + CatalogPlugin rawCatalog() { + boolean isolated = properties.isIsolated(); + CatalogPlugin rawCatalog = new CatalogPlugin(isolated); + + PgsqlCatalogFacade rawFacade = catalogFacade(); + + ExtendedCatalogFacade facade = + PgsqlBackendBuilder.createResolvingCatalogFacade(rawCatalog, rawFacade); + rawCatalog.setFacade(facade); + + GeoServerResourceLoader resourceLoader = resourceLoader(); + rawCatalog.setResourceLoader(resourceLoader); + return rawCatalog; + } + + @Bean(name = "pgsqlCongigJdbcTemplate") + JdbcTemplate template() { + JdbcTemplate template = new JdbcTemplate(dataSource); + return template; + } + + @Bean + @Override + public GeoServerConfigurationLock configurationLock() { + LockProvider lockProvider = pgsqlLockProvider(); + return new LockProviderGeoServerConfigurationLock(lockProvider); + } + + @Bean + @Override + public PgsqlUpdateSequence updateSequence() { + return new PgsqlUpdateSequence(dataSource, geoserverFacade()); + } + + @Bean + @Override + public PgsqlCatalogFacade catalogFacade() { + return new PgsqlCatalogFacade(template()); + } + + @Bean + @Override + public GeoServerLoader geoServerLoaderImpl() { + return new PgsqlGeoServerLoader(resourceLoader(), configurationLock()); + } + + @Bean + PgsqlConfigRepository configRepository() { + return new PgsqlConfigRepository(template()); + } + + @Bean + @Override + public PgsqlGeoServerFacade geoserverFacade() { + return new PgsqlGeoServerFacade(configRepository()); + } + + @Bean + @Override + public ResourceStore resourceStoreImpl() { + FileSystemResourceStoreCache resourceStoreCache = pgsqlFileSystemResourceStoreCache(); + JdbcTemplate template = template(); + PgsqlLockProvider lockProvider = pgsqlLockProvider(); + return new PgsqlResourceStore(resourceStoreCache, template, lockProvider); + } + + @Bean + FileSystemResourceStoreCache pgsqlFileSystemResourceStoreCache() { + return FileSystemResourceStoreCache.newTempDirInstance(); + } + + @Bean + @Override + public PgsqlGeoServerResourceLoader resourceLoader() { + return new PgsqlGeoServerResourceLoader(resourceStoreImpl()); + } + + @Bean + PgsqlLockProvider pgsqlLockProvider() { + return new PgsqlLockProvider(pgsqlLockRegistry()); + } + + /** + * @return + */ + private LockRegistry pgsqlLockRegistry() { + return new JdbcLockRegistry(pgsqlLockRepository()); + } + + @Bean + LockRepository pgsqlLockRepository() { + String id = this.instanceId; + DefaultLockRepository lockRepository; + if (StringUtils.hasLength(id)) { + lockRepository = new DefaultLockRepository(dataSource, id); + } else { + lockRepository = new DefaultLockRepository(dataSource); + } + // override default table prefix "INT" by "RESOURCE_" (matching table definition + // RESOURCE_LOCK in init.XXX.sql + lockRepository.setPrefix("RESOURCE_"); + // time in ms to expire dead locks (10k is the default) + lockRepository.setTimeToLive(300_000); + return lockRepository; + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/PgsqlBackendProperties.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/PgsqlBackendProperties.java new file mode 100644 index 000000000..0fbfd1921 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/PgsqlBackendProperties.java @@ -0,0 +1,29 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.config.catalog.backend.pgsql; + +import lombok.Data; + +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.util.StringUtils; + +/** + * @since 1.4 + */ +@Data +@ConfigurationProperties("geoserver.backend.pgconfig") +public class PgsqlBackendProperties { + + private DataSourceProperties datasource; + + private String schema = "public"; + private boolean initialize = true; + private boolean createSchema = true; + + public String schema() { + return StringUtils.hasLength(schema) ? schema : "public"; + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/PgsqlDataSourceConfiguration.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/PgsqlDataSourceConfiguration.java new file mode 100644 index 000000000..7a19dd28e --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/PgsqlDataSourceConfiguration.java @@ -0,0 +1,49 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.config.catalog.backend.pgsql; + +import com.zaxxer.hikari.HikariDataSource; + +import lombok.extern.slf4j.Slf4j; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.DependsOn; +import org.springframework.jdbc.datasource.lookup.JndiDataSourceLookup; +import org.springframework.util.StringUtils; + +import javax.sql.DataSource; + +/** + * @since 1.4 + */ +@Configuration +@EnableConfigurationProperties(PgsqlBackendProperties.class) +@Slf4j +public class PgsqlDataSourceConfiguration { + + @Bean + @DependsOn("jndiInitializer") + DataSource pgsqlConfigDatasource(PgsqlBackendProperties configprops) { + DataSourceProperties config = configprops.getDatasource(); + String jndiName = config.getJndiName(); + if (StringUtils.hasText(jndiName)) { + log.info("Creating pgsqlConfigDataSource from JNDI reference {}", jndiName); + return new JndiDataSourceLookup().getDataSource(jndiName); + } + return config.initializeDataSourceBuilder().type(HikariDataSource.class).build(); + } + + @Bean(name = "jndiInitializer") + @ConditionalOnMissingBean(name = "jndiInitializer") + Object jndiInitializerFallback() { + log.warn( + "jndiInitializer is not provided, beware a JNDI datasource definition for the pgsql catalog backend won't work."); + return new Object(); + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/PgsqlDatabaseMigrations.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/PgsqlDatabaseMigrations.java new file mode 100644 index 000000000..ba4887f88 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/PgsqlDatabaseMigrations.java @@ -0,0 +1,55 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.config.catalog.backend.pgsql; + +import lombok.Data; +import lombok.experimental.Accessors; +import lombok.extern.slf4j.Slf4j; + +import org.flywaydb.core.Flyway; + +import javax.sql.DataSource; + +/** + * @since 1.4 + */ +@Slf4j(topic = "org.geoserver.cloud.config.catalog.backend.pgsql") +@Data +@Accessors(chain = true) +public class PgsqlDatabaseMigrations { + + private DataSource dataSource; + private boolean initialize = true; + private String schema = "public"; + private boolean createSchema = true; + private boolean cleanDisabled = true; + + public void migrate() throws Exception { + if (!isInitialize()) { + log.warn("Not initializing pgsql backend database as defined in configuration"); + return; + } + log.info("Running pgsql backend database migrations..."); + + buildFlyway().migrate(); + } + + /** */ + public void clean() { + buildFlyway().clean(); + } + + protected Flyway buildFlyway() { + Flyway flyway = + Flyway.configure() + .dataSource(dataSource) + .schemas(schema) + .createSchemas(createSchema) + .cleanDisabled(cleanDisabled) + .locations("db/pgsqlcatalog/migration") + .load(); + return flyway; + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/PgsqlGeoServerLoader.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/PgsqlGeoServerLoader.java new file mode 100644 index 000000000..8c69b4f3e --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/PgsqlGeoServerLoader.java @@ -0,0 +1,203 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.config.catalog.backend.pgsql; + +import static org.geoserver.catalog.StyleInfo.DEFAULT_GENERIC; +import static org.geoserver.catalog.StyleInfo.DEFAULT_LINE; +import static org.geoserver.catalog.StyleInfo.DEFAULT_POINT; +import static org.geoserver.catalog.StyleInfo.DEFAULT_POLYGON; +import static org.geoserver.catalog.StyleInfo.DEFAULT_RASTER; + +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; + +import org.geoserver.GeoServerConfigurationLock; +import org.geoserver.GeoServerConfigurationLock.LockType; +import org.geoserver.catalog.Catalog; +import org.geoserver.catalog.StyleInfo; +import org.geoserver.config.GeoServer; +import org.geoserver.config.GeoServerInfo; +import org.geoserver.config.GeoServerLoader; +import org.geoserver.config.GeoServerLoaderProxy; +import org.geoserver.config.LoggingInfo; +import org.geoserver.config.ServiceInfo; +import org.geoserver.config.util.XStreamPersister; +import org.geoserver.config.util.XStreamServiceLoader; +import org.geoserver.platform.GeoServerExtensions; +import org.geoserver.platform.GeoServerResourceLoader; +import org.geoserver.platform.resource.Resource.Lock; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import javax.annotation.PostConstruct; + +/** + * @since 1.4 + */ +@Slf4j +public class PgsqlGeoServerLoader extends GeoServerLoader { + + private @NonNull GeoServerConfigurationLock configLock; + + /** + * @param resourceLoader + * @param knownServiceTypes know {@link ServiceInfo} types used to initialize a default service + * config when starting off an empty config + */ + public PgsqlGeoServerLoader( + @NonNull GeoServerResourceLoader resourceLoader, + @NonNull GeoServerConfigurationLock configLock) { + super(resourceLoader); + this.configLock = configLock; + } + + /** There's no {@link GeoServerLoaderProxy} in gs-cloud */ + public @PostConstruct void load() { + Catalog rawCatalog = (Catalog) GeoServerExtensions.bean("rawCatalog"); + GeoServer geoserver = (GeoServer) GeoServerExtensions.bean("geoServer"); + postProcessBeforeInitialization(rawCatalog, "rawCatalog"); + postProcessBeforeInitialization(geoserver, "geoServer"); + } + + @Override + protected void loadCatalog(Catalog catalog, XStreamPersister xp) throws Exception { + log.info("Loading catalog with pgsql loader..."); + } + + /** + * Overrides to run inside a lock on "styles" to avoid multiple instances starting up off an + * empty database trying to create the same default styles, which results in either a startup + * error or multiple styles named the same. + */ + @Override + protected void initializeDefaultStyles(Catalog catalog) throws IOException { + if (anyStyleMissing( + catalog, + DEFAULT_POINT, + DEFAULT_LINE, + DEFAULT_POLYGON, + DEFAULT_RASTER, + DEFAULT_GENERIC)) { + final Lock lock = resourceLoader.getLockProvider().acquire("DEFAULT_STYLES"); + try { + super.initializeDefaultStyles(catalog); + } finally { + lock.release(); + } + } + } + + private boolean anyStyleMissing(Catalog catalog, String... defaultStyleNames) { + for (String name : defaultStyleNames) { + StyleInfo style = catalog.getStyleByName(name); + if (null == style) return true; + } + return false; + } + + @Override + protected void loadGeoServer(GeoServer geoServer, XStreamPersister xp) throws Exception { + log.info("loading geoserver config with pgsql loader"); + + // to ensure we have a service configuration for every service we know about + var missingServices = findMissingServices(geoServer); + + configLock.lock(LockType.READ); + try { + GeoServerInfo global = geoServer.getGlobal(); + LoggingInfo logging = geoServer.getLogging(); + boolean someConfigMissing = + global == null || logging == null || !missingServices.isEmpty(); + if (someConfigMissing) { + try { + log.info("Found missing config objects, acquiring config lock..."); + + configLock.tryUpgradeLock(); + log.info( + "Config lock acquired. Creating initial GeoServer configuration objects..."); + + doCreateMissing(geoServer, missingServices); + + log.info("Done creating initial GeoServer configuration objects."); + } catch (RuntimeException failedUpgrade) { + log.info( + "Unable to acquire config lock, checking if another instance initialized the config"); + verifyInitialized(geoServer, missingServices); + } + } + } finally { + configLock.unlock(); + } + log.info("GeoServer config loaded."); + } + + private void verifyInitialized( + GeoServer geoServer, + List> missingServices) { + + if (geoServer.getGlobal() == null) { + throw new IllegalStateException("GeoServerInfo not found"); + } + if (geoServer.getLogging() == null) { + throw new IllegalStateException("LoggingInfo not found"); + } + + String missing = + missingServices.stream() + .filter(loader -> null == geoServer.getService(loader.getServiceClass())) + .map(XStreamServiceLoader::getServiceClass) + .map(Class::getName) + .collect(Collectors.joining(", ")); + if (!missing.isEmpty()) { + throw new IllegalStateException("ServiceInfo not found for %s".formatted(missing)); + } + } + + /** Must run inside a config lock to create missing config objects */ + private void doCreateMissing( + GeoServer geoServer, + List> missingServices) { + + if (geoServer.getGlobal() == null) { + log.info("initializing geoserver global config"); + geoServer.setGlobal(geoServer.getFactory().createGlobal()); + } + + if (geoServer.getLogging() == null) { + log.info("initializing geoserver logging config"); + geoServer.setLogging(geoServer.getFactory().createLogging()); + } + for (var loader : missingServices) { + var serviceClass = loader.getServiceClass(); + ServiceInfo service = geoServer.getService(serviceClass); + if (service == null) { + log.info("creating default service config for " + serviceClass.getSimpleName()); + try { + service = loader.create(geoServer); + geoServer.add(service); + } catch (Exception e) { + log.warn("Error creating default {}", serviceClass, e); + } + } + } + } + + private List> findMissingServices( + GeoServer geoServer) { + + var loaders = GeoServerExtensions.extensions(XStreamServiceLoader.class); + var missing = new ArrayList>(); + for (XStreamServiceLoader loader : loaders) { + ServiceInfo service = geoServer.getService(loader.getServiceClass()); + if (service == null) { + missing.add(loader); + } + } + return missing; + } +} diff --git a/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/PgsqlGeoServerResourceLoader.java b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/PgsqlGeoServerResourceLoader.java new file mode 100644 index 000000000..851280e24 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/java/org/geoserver/cloud/config/catalog/backend/pgsql/PgsqlGeoServerResourceLoader.java @@ -0,0 +1,27 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.config.catalog.backend.pgsql; + +import lombok.NonNull; + +import org.geoserver.platform.GeoServerResourceLoader; +import org.geoserver.platform.resource.ResourceStore; + +import java.io.File; + +/** + * @since 1.4 + */ +public class PgsqlGeoServerResourceLoader extends GeoServerResourceLoader { + + /** + * @param resourceStore + */ + public PgsqlGeoServerResourceLoader(@NonNull ResourceStore resourceStore) { + super(resourceStore); + File baseDirectory = resourceStore.get("").dir(); + setBaseDirectory(baseDirectory); + } +} diff --git a/src/catalog/backends/pgsql/src/main/resources/META-INF/spring.factories b/src/catalog/backends/pgsql/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000..94517e5f3 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/resources/META-INF/spring.factories @@ -0,0 +1,5 @@ +# Auto Configure +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +org.geoserver.cloud.autoconfigure.catalog.backend.pgsql.PgsqlDataSourceAutoConfiguration,\ +org.geoserver.cloud.autoconfigure.catalog.backend.pgsql.PgsqlMigrationAutoConfiguration,\ +org.geoserver.cloud.autoconfigure.catalog.backend.pgsql.PgsqlBackendAutoConfiguration \ No newline at end of file diff --git a/src/catalog/backends/pgsql/src/main/resources/db/pgsqlcatalog/migration/postgresql/V1_0__Catalog_Tables.sql b/src/catalog/backends/pgsql/src/main/resources/db/pgsqlcatalog/migration/postgresql/V1_0__Catalog_Tables.sql new file mode 100644 index 000000000..2fbaba94d --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/resources/db/pgsqlcatalog/migration/postgresql/V1_0__Catalog_Tables.sql @@ -0,0 +1,219 @@ +/* + * Set up CatalogInfo tables with only enough columns to support referential integrity + * The triggers calling populate_table_columns_from_jsonb() will populate the "@type" + * column from cataloginfo tables. All JSON representations of CatalogInfo subtyles contain + * an "@type" attribute with a string value matching one of the constants in this enum. + */ +CREATE TYPE infotype AS ENUM ( + 'WorkspaceInfo', + 'NamespaceInfo', + 'DataStoreInfo', + 'CoverageStoreInfo', + 'WMSStoreInfo', + 'WMTSStoreInfo', + 'FeatureTypeInfo', + 'CoverageInfo', + 'WMSLayerInfo', + 'WMTSLayerInfo', + 'LayerInfo', + 'LayerGroupInfo', + 'StyleInfo', + 'MapInfo'); + +CREATE CAST (character varying AS infotype) WITH INOUT AS ASSIGNMENT; + +/* + * Function for cataloginfo table triggers that update table columns from json field values + */ +CREATE OR REPLACE FUNCTION populate_table_columns_from_jsonb() RETURNS TRIGGER AS +$func$ +BEGIN + NEW := jsonb_populate_record(NEW, NEW.info); + RETURN NEW; +END +$func$ LANGUAGE plpgsql; + +/* + * "Abstract" base table for CatalogInfo types + */ +CREATE TABLE IF NOT EXISTS cataloginfo( + id TEXT NOT NULL PRIMARY KEY, + "@type" infotype NOT NULL, + name TEXT NOT NULL, + info JSONB NOT NULL, +-- info_tsvector tsvector generated always as (jsonb_to_tsvector('simple', info, '["string"]')) stored + CHECK (false) NO INHERIT -- Make it abstract, can't insert on it directly +); + +/* + * namespaceinfo inherits cataloginfo + */ +CREATE TABLE IF NOT EXISTS namespaceinfo( + "@type" infotype NOT NULL DEFAULT 'NamespaceInfo', + uri TEXT NOT NULL, + isolated BOOLEAN NOT NULL DEFAULT FALSE, + default_namespace BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (id), + UNIQUE(name), + CHECK ("@type" = 'NamespaceInfo') +) INHERITS (cataloginfo); + +CREATE TRIGGER namespaceinfo_populate BEFORE INSERT OR UPDATE ON namespaceinfo FOR EACH ROW EXECUTE PROCEDURE populate_table_columns_from_jsonb(); + +CREATE INDEX namespaceinfo_type_idx ON namespaceinfo ("@type"); +CREATE INDEX namespaceinfo_name_idx ON namespaceinfo (name); +CREATE INDEX namespaceinfo_uri_idx ON namespaceinfo (uri); +CREATE INDEX namespaceinfo_isolated_idx ON namespaceinfo (isolated); +CREATE INDEX namespaceinfo_default_namespace_idx ON namespaceinfo (default_namespace); + +/* + * workspaceinfo inherits cataloginfo + */ +CREATE TABLE IF NOT EXISTS workspaceinfo( + "@type" infotype NOT NULL DEFAULT 'WorkspaceInfo', + isolated BOOLEAN NOT NULL DEFAULT FALSE, + default_workspace BOOLEAN NOT NULL DEFAULT FALSE, + default_store TEXT, + PRIMARY KEY (id), + UNIQUE(name), + CHECK ("@type" = 'WorkspaceInfo') +) INHERITS (cataloginfo); + +CREATE TRIGGER workspaceinfo_populate BEFORE INSERT OR UPDATE ON workspaceinfo FOR EACH ROW EXECUTE PROCEDURE populate_table_columns_from_jsonb(); + +CREATE INDEX workspaceinfo_type_idx ON workspaceinfo ("@type"); +CREATE INDEX workspaceinfo_name_idx ON workspaceinfo (name); +CREATE INDEX workspaceinfo_isolated_idx ON workspaceinfo (isolated); +CREATE INDEX workspaceinfo_default_workspace_idx ON workspaceinfo (default_workspace); +CREATE INDEX workspaceinfo_default_store_idx ON workspaceinfo (default_store); + +/* + * storeinfo inherits cataloginfo + */ +CREATE TABLE IF NOT EXISTS storeinfo( + workspace TEXT NOT NULL, + "type" TEXT, + enabled boolean NOT NULL DEFAULT true, + PRIMARY KEY (id), + UNIQUE(workspace, name), + FOREIGN KEY (workspace) REFERENCES workspaceinfo(id), + CHECK ("@type" IN('DataStoreInfo', 'CoverageStoreInfo', 'WMSStoreInfo', 'WMTSStoreInfo')) +) INHERITS (cataloginfo); + +CREATE TRIGGER storeinfo_populate BEFORE INSERT OR UPDATE ON storeinfo FOR EACH ROW EXECUTE PROCEDURE populate_table_columns_from_jsonb(); + +CREATE INDEX storeinfo_type_idx ON storeinfo ("@type"); +CREATE INDEX storeinfo_name_idx ON storeinfo (name); +CREATE INDEX storeinfo_storetype_idx ON storeinfo ("type"); +CREATE INDEX storeinfo_workspace_idx ON storeinfo (workspace); + +/* + * resourceinfo inherits cataloginfo. + */ +CREATE TABLE IF NOT EXISTS resourceinfo( + store TEXT NOT NULL, + namespace TEXT NOT NULL, + title TEXT, + enabled boolean NOT NULL DEFAULT true, + advertised boolean NOT NULL DEFAULT true, + "SRS" TEXT, + PRIMARY KEY (id), + UNIQUE(namespace, name), + FOREIGN KEY (store) REFERENCES storeinfo(id), + FOREIGN KEY (namespace) REFERENCES namespaceinfo(id), + CHECK ("@type" IN('FeatureTypeInfo', 'CoverageInfo', 'WMSLayerInfo', 'WMTSLayerInfo')) +) INHERITS (cataloginfo); + +CREATE TRIGGER resourceinfo_populate BEFORE INSERT OR UPDATE ON resourceinfo FOR EACH ROW EXECUTE PROCEDURE populate_table_columns_from_jsonb(); + +CREATE INDEX resourceinfo_type_idx ON resourceinfo ("@type"); +CREATE INDEX resourceinfo_name_idx ON resourceinfo (name); +CREATE INDEX resourceinfo_store_idx ON resourceinfo (store); +CREATE INDEX resourceinfo_namespace_idx ON resourceinfo (namespace); +CREATE INDEX resourceinfo_title_idx ON resourceinfo (title); +CREATE INDEX resourceinfo_enabled_idx ON resourceinfo (enabled); +CREATE INDEX resourceinfo_advertised_idx ON resourceinfo (advertised); +CREATE INDEX resourceinfo_srs_idx ON resourceinfo ("SRS"); + +/* + * styleinfo inherits cataloginfo + */ +CREATE TABLE IF NOT EXISTS styleinfo( + "@type" infotype NOT NULL DEFAULT 'StyleInfo', + workspace TEXT NULL, + filename TEXT NULL, + format TEXT NULL, + PRIMARY KEY (id), + UNIQUE NULLS NOT DISTINCT(workspace, name), + FOREIGN KEY (workspace) REFERENCES workspaceinfo(id), + CHECK ("@type" = 'StyleInfo') +) INHERITS (cataloginfo); + +CREATE TRIGGER style_populate_fields_trigger + BEFORE INSERT OR UPDATE ON styleinfo FOR EACH ROW + EXECUTE PROCEDURE populate_table_columns_from_jsonb(); + +CREATE INDEX styleinfo_type_idx ON styleinfo ("@type"); +CREATE INDEX styleinfo_name_idx ON styleinfo (name); +CREATE INDEX styleinfo_workspace_idx ON styleinfo (workspace); +CREATE INDEX styleinfo_filename_idx ON styleinfo (filename); +CREATE INDEX styleinfo_format_idx ON styleinfo (format); + +/* + * publishedinfo inherits cataloginfo. Base table for layerinfo and layergroupinfo. + */ +CREATE TABLE IF NOT EXISTS publishedinfo( + PRIMARY KEY (id), + CHECK (false) NO INHERIT -- Make it abstract, can't insert on it directly +) INHERITS (cataloginfo); + +/* + * layerinfo inherits publishedinfo + */ +CREATE TABLE IF NOT EXISTS layerinfo( + "@type" infotype NOT NULL DEFAULT 'LayerInfo', + resource TEXT NOT NULL, + "defaultStyle" TEXT, + "type" TEXT, + PRIMARY KEY (id), + UNIQUE(resource, name), + FOREIGN KEY (resource) REFERENCES resourceinfo(id), + FOREIGN KEY ("defaultStyle") REFERENCES styleinfo(id), + CHECK ("@type" = 'LayerInfo') +) INHERITS (publishedinfo); + +CREATE TRIGGER layerinfo_populate_fields_trigger + BEFORE INSERT OR UPDATE ON layerinfo FOR EACH ROW + EXECUTE PROCEDURE populate_table_columns_from_jsonb(); + +CREATE INDEX layerinfo_type_idx ON layerinfo ("@type"); +CREATE INDEX layerinfo_name_idx ON layerinfo (name); +CREATE INDEX layerinfo_resource_idx ON layerinfo (resource); +CREATE INDEX layerinfo_defaultstyle_idx ON layerinfo ("defaultStyle"); + +/* + * layergroupinfo inherits publishedinfo + */ +CREATE TABLE IF NOT EXISTS layergroupinfo( + "@type" infotype NOT NULL DEFAULT 'LayerGroupInfo', + workspace TEXT NULL, + title TEXT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + advertised BOOLEAN NOT NULL DEFAULT TRUE, + mode TEXT NULL, + PRIMARY KEY (id), + UNIQUE NULLS NOT DISTINCT (workspace, name), + FOREIGN KEY (workspace) REFERENCES workspaceinfo(id), + CHECK ("@type" = 'LayerGroupInfo') +) INHERITS (publishedinfo); + +CREATE TRIGGER layergroup_populate_fields_trigger + BEFORE INSERT OR UPDATE ON layergroupinfo FOR EACH ROW + EXECUTE PROCEDURE populate_table_columns_from_jsonb(); + +CREATE INDEX layergroupinfo_type_idx ON layergroupinfo ("@type"); +CREATE INDEX layergroupinfo_name_idx ON layergroupinfo (name); +CREATE INDEX layergroupinfo_workspace_idx ON layergroupinfo (workspace); +CREATE INDEX layergroupinfo_enabled_idx ON layergroupinfo (enabled); +CREATE INDEX layergroupinfo_advertised_idx ON layergroupinfo (advertised); + diff --git a/src/catalog/backends/pgsql/src/main/resources/db/pgsqlcatalog/migration/postgresql/V1_1__Catalog_Query_Tables.sql b/src/catalog/backends/pgsql/src/main/resources/db/pgsqlcatalog/migration/postgresql/V1_1__Catalog_Query_Tables.sql new file mode 100644 index 000000000..40a4d2feb --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/resources/db/pgsqlcatalog/migration/postgresql/V1_1__Catalog_Query_Tables.sql @@ -0,0 +1,208 @@ +/** + * Views for bulk queries, contains related info objects + */ +CREATE VIEW workspaceinfos +AS + SELECT id AS id, + "@type" AS "@type", + name AS name, + info AS workspace, + default_workspace, + default_store + FROM workspaceinfo; + +CREATE VIEW namespaceinfos +AS + SELECT id, + "@type", + name, + uri, + isolated, + info AS namespace, + default_namespace + FROM namespaceinfo; + +CREATE VIEW storeinfos +AS + SELECT store.id AS id, + store."@type" AS "@type", + store.name AS name, + store.enabled AS enabled, + store."type" AS "type", + store.workspace AS "workspace.id", + workspace.name AS "workspace.name", + workspace.isolated AS "workspace.isolated", + store.info AS store, + workspace.info AS workspace, + workspace.default_store + FROM storeinfo store + INNER JOIN workspaceinfo workspace + ON store.workspace = workspace.id; + +CREATE VIEW resourceinfos +AS + SELECT resource.id AS id, + resource."@type" AS "@type", + resource.name AS name, + resource.title AS title, + resource.enabled AS enabled, + resource.advertised AS advertised, + resource."SRS" AS "SRS", + stores.id AS "store.id", + stores.name AS "store.name", + stores.enabled AS "store.enabled", + stores."type" AS "store.type", + stores."workspace.id" AS "store.workspace.id", + stores."workspace.name" AS "store.workspace.name", + stores."workspace.isolated" AS "store.workspace.isolated", + resource.namespace AS "namespace.id", + namespace.name AS "namespace.prefix", + namespace.uri AS "namespace.uri", + namespace.isolated AS "namespace.isolated", + resource.info AS resource, + stores.store AS store, + stores.workspace AS workspace, + namespace.info AS namespace + FROM resourceinfo resource + INNER JOIN storeinfos stores ON resource.store = stores.id + INNER JOIN namespaceinfo namespace ON resource.namespace = namespace.id; + +CREATE VIEW styleinfos +AS + SELECT style.id AS id, + style."@type" AS "@type", + style.name AS name, + style.filename AS filename, + style.format AS format, + style.workspace AS "workspace.id", + workspace.name AS "workspace.name", + style.info AS style, + workspace.info AS workspace + FROM styleinfo style + LEFT OUTER JOIN workspaceinfo workspace + ON style.workspace = workspace.id; + +CREATE VIEW layerinfos +AS + SELECT layer.id AS id, + layer."@type" AS "@type", + -- override layer.name with resource.name while it's coupled in the object model + -- layer.name AS name, + resource.name AS name, + resource.title AS title, + resource.enabled AS enabled, + resource.advertised AS advertised, + layer."defaultStyle" AS "defaultStyle.id", + layer."type" AS "type", + style.name AS "defaultStyle.name", + resource.id AS "resource.id", + resource.name AS "resource.name", + resource."store.workspace.name" || ':' || layer.name AS "prefixedName", + resource.enabled AS "resource.enabled", + resource.advertised AS "resource.advertised", + resource."SRS" AS "resource.SRS", + resource."store.id" AS "resource.store.id", + resource."store.name" AS "resource.store.name", + resource."store.enabled" AS "resource.store.enabled", + resource."store.type" AS "resource.store.type", + resource."store.workspace.id" AS "resource.store.workspace.id", + resource."store.workspace.name" AS "resource.store.workspace.name", + resource."namespace.id" AS "resource.namespace.id", + resource."namespace.prefix" AS "resource.namespace.prefix", + style.filename AS "defaultStyle.filename", + style.format AS "defaultStyle.format", + layer.info AS publishedinfo, + resource.resource AS resource, + resource.store AS store, + resource.workspace AS workspace, + resource.namespace AS namespace, + style.info AS "defaultStyle" + FROM layerinfo layer + INNER JOIN resourceinfos resource ON layer.resource = resource.id + LEFT OUTER JOIN styleinfo style ON layer."defaultStyle" = style.id; + +CREATE VIEW layergroupinfos +AS + SELECT lg.id AS id, + lg."@type" AS "@type", + lg.name AS name, + lg.title AS title, + 'GROUP' AS "type", + concat_ws(':', workspace.name, lg.name) AS "prefixedName", + lg.mode AS mode, + lg.enabled AS enabled, + lg.advertised AS advertised, + lg.workspace AS "workspace.id", + workspace.name AS "workspace.name", + lg.info AS publishedinfo, + workspace.info AS workspace + FROM layergroupinfo lg + LEFT OUTER JOIN workspaceinfo workspace + ON lg.workspace = workspace.id; + + + +CREATE VIEW publishedinfos +AS + SELECT + -- common PublishedInfo properties + id, "@type", name, "prefixedName", title, enabled, advertised, "type", workspace, publishedinfo, + -- LayerInfo specific properties + "resource.id", + "resource.name", + "resource.enabled", + "resource.advertised", + "resource.SRS", + "resource.store.id", + "resource.store.name", + "resource.store.enabled", + "resource.store.type", + "resource.store.workspace.id", + "resource.store.workspace.name", + "resource.namespace.id", + "resource.namespace.prefix", + "defaultStyle.id", + "defaultStyle.name", + "defaultStyle.filename", + "defaultStyle.format", + resource, + store, + namespace, + "defaultStyle", + -- LayerGroupInfo specific properties + NULL AS mode, + NULL AS "workspace.id", + NULL AS "workspace.name" + FROM layerinfos +UNION + SELECT + -- common PublishedInfo properties + id, "@type", name, "prefixedName", title, enabled, advertised, "type", workspace, publishedinfo, + -- LayerInfo specific properties + NULL AS "resource.id", + NULL AS "resource.name", + NULL AS "resource.enabled", + NULL AS "resource.advertised", + NULL AS "resource.SRS", + NULL AS "resource.store.id", + NULL AS "resource.store.name", + NULL AS "resource.store.enabled", + NULL AS "resource.store.type", + NULL AS "resource.store.workspace.id", + NULL AS "resource.store.workspace.name", + NULL AS "resource.namespace.id", + NULL AS "resource.namespace.prefix", + NULL AS "defaultStyle.id", + NULL AS "defaultStyle.name", + NULL AS "defaultStyle.filename", + NULL AS "defaultStyle.format", + NULL AS resource, + NULL AS store, + NULL AS namespace, + NULL AS "defaultStyle", + -- LayerGroupInfo specific properties + mode, + "workspace.id", + "workspace.name" + FROM layergroupinfos; + diff --git a/src/catalog/backends/pgsql/src/main/resources/db/pgsqlcatalog/migration/postgresql/V1_2__Catalog_Full_Text_Search.sql b/src/catalog/backends/pgsql/src/main/resources/db/pgsqlcatalog/migration/postgresql/V1_2__Catalog_Full_Text_Search.sql new file mode 100644 index 000000000..128dbcc47 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/resources/db/pgsqlcatalog/migration/postgresql/V1_2__Catalog_Full_Text_Search.sql @@ -0,0 +1,14 @@ +-- Full Text Search over cataloginfo's jsonb info column +-- PostgreSQL 10 introduces Full Text Search on JSONB. https://www.postgresql.org/docs/current/functions-textsearch.html +-- The new FTS indexing on JSON works with phrase search and skips over both the JSON-markup and keys. +-- sample query: select * from cataloginfo where to_tsvector('simple', info::text) @@ plainto_tsquery('simple', 'layer1'); + +CREATE INDEX ON namespaceinfo USING gin ( to_tsvector('simple', info) ); +CREATE INDEX ON workspaceinfo USING gin ( to_tsvector('simple', info) ); +CREATE INDEX ON storeinfo USING gin ( to_tsvector('simple', info) ); +CREATE INDEX ON resourceinfo USING gin ( to_tsvector('simple', info) ); +CREATE INDEX ON layerinfo USING gin ( to_tsvector('simple', info) ); +CREATE INDEX ON layergroupinfo USING gin ( to_tsvector('simple', info) ); +CREATE INDEX ON styleinfo USING gin ( to_tsvector('simple', info) ); + + diff --git a/src/catalog/backends/pgsql/src/main/resources/db/pgsqlcatalog/migration/postgresql/V1_3__Config_Tables.sql b/src/catalog/backends/pgsql/src/main/resources/db/pgsqlcatalog/migration/postgresql/V1_3__Config_Tables.sql new file mode 100644 index 000000000..0c77dafb9 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/resources/db/pgsqlcatalog/migration/postgresql/V1_3__Config_Tables.sql @@ -0,0 +1,57 @@ +/* + * + */ +CREATE SEQUENCE IF NOT EXISTS gs_update_sequence AS BIGINT CYCLE; +SELECT NEXTVAL('gs_update_sequence'); + +/* + * + */ +CREATE TABLE IF NOT EXISTS geoserverinfo( + updatesequence BIGINT NOT NULL DEFAULT 0, + info JSONB NOT NULL +); + +CREATE TABLE IF NOT EXISTS settingsinfo( + id TEXT NOT NULL PRIMARY KEY, + workspace TEXT NOT NULL, + info JSONB NOT NULL +); + +CREATE TABLE IF NOT EXISTS serviceinfo( + id TEXT NOT NULL PRIMARY KEY, + "@type" TEXT NOT NULL, + name TEXT NOT NULL, + workspace TEXT, + info JSONB NOT NULL +); + +CREATE TABLE IF NOT EXISTS logginginfo( + info JSONB NOT NULL +); + +CREATE TRIGGER geoserverinfo_populate BEFORE INSERT OR UPDATE ON geoserverinfo FOR EACH ROW EXECUTE PROCEDURE populate_table_columns_from_jsonb(); +CREATE TRIGGER settingsinfo_populate BEFORE INSERT OR UPDATE ON settingsinfo FOR EACH ROW EXECUTE PROCEDURE populate_table_columns_from_jsonb(); +CREATE TRIGGER serviceinfo_populate BEFORE INSERT OR UPDATE ON serviceinfo FOR EACH ROW EXECUTE PROCEDURE populate_table_columns_from_jsonb(); +CREATE TRIGGER logginginfo_populate BEFORE INSERT OR UPDATE ON logginginfo FOR EACH ROW EXECUTE PROCEDURE populate_table_columns_from_jsonb(); + +CREATE VIEW settingsinfos +AS + SELECT s.id, + s.workspace AS "workspace.id", + s.info, + w.info AS workspace + FROM settingsinfo s + INNER JOIN workspaceinfo w ON s.workspace = w.id; + +CREATE VIEW serviceinfos +AS + SELECT s.id, + s."@type", + s.name, + s.workspace AS "workspace.id", + s.info, + w.info AS workspace + FROM serviceinfo s + LEFT OUTER JOIN workspaceinfo w ON s.workspace = w.id; + \ No newline at end of file diff --git a/src/catalog/backends/pgsql/src/main/resources/db/pgsqlcatalog/migration/postgresql/V1_4__ResourceStore_Tables.sql b/src/catalog/backends/pgsql/src/main/resources/db/pgsqlcatalog/migration/postgresql/V1_4__ResourceStore_Tables.sql new file mode 100644 index 000000000..a45540883 --- /dev/null +++ b/src/catalog/backends/pgsql/src/main/resources/db/pgsqlcatalog/migration/postgresql/V1_4__ResourceStore_Tables.sql @@ -0,0 +1,139 @@ +/* + * Table used to support distributed locks through spring-integration-jdbc in PgsqlLockProvider + * Locking table definition from spring-integration-jdbc.jar!org/springframework/integration/jdbc/schema-postgresql.sql + * original table name: INT_LOCK + */ +CREATE TABLE RESOURCE_LOCK ( + LOCK_KEY CHAR(36) NOT NULL, + REGION VARCHAR(100) NOT NULL, + CLIENT_ID CHAR(36), + CREATED_DATE TIMESTAMP NOT NULL, + constraint INT_LOCK_PK primary key (LOCK_KEY, REGION) +); + +/* + * ResourceStore tables + */ + +CREATE TYPE resourcetype AS ENUM ( + 'DIRECTORY', + 'RESOURCE' +); + +CREATE CAST (CHARACTER VARYING AS resourcetype) WITH INOUT AS ASSIGNMENT; + +CREATE TABLE resourcestore ( + id BIGSERIAL PRIMARY KEY, + parentid BIGINT NULL CHECK(id <> parentid), + "type" resourcetype, + mtime TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT timezone('UTC'::text, now()), + name TEXT NOT NULL, + content BYTEA CHECK(("type" = 'DIRECTORY' AND content IS NULL) OR ("type" = 'RESOURCE' AND content IS NOT NULL)), + CONSTRAINT resourcestore_parent_fkey FOREIGN KEY (parentid) + REFERENCES resourcestore (id) + ON UPDATE RESTRICT ON DELETE CASCADE, + UNIQUE NULLS NOT DISTINCT (parentid, name), + CONSTRAINT resourcestore_only_one_root_check CHECK (parentid IS NOT NULL OR id = 0) +); + +CREATE INDEX resourcestore_name_idx ON resourcestore (name); +CREATE INDEX resourcestore_parent_name_idx ON resourcestore (parentid NULLS FIRST, name NULLS FIRST); + +INSERT INTO resourcestore (id, name, parentid, "type") VALUES (0, '', NULL, 'DIRECTORY'); + +/* + * Trigger that prevents upadting "type" + */ +CREATE FUNCTION resourcestore_type_readonly() RETURNS trigger LANGUAGE plpgsql AS +$$BEGIN + IF NEW."type" <> OLD."type" THEN + RAISE EXCEPTION 'resourcestore.type is read-only, trying to upate % to %s on % (%)', OLD."type", NEW."type", NEW.id, OLD.name; + END IF; + RETURN NEW; +END;$$; + +CREATE TRIGGER resourcestore_type_readonly_trigger + BEFORE UPDATE ON resourcestore + FOR EACH ROW EXECUTE PROCEDURE resourcestore_type_readonly(); + +/* + * Trigger that checks on insert and update that the parent is a directory + */ +CREATE FUNCTION resourcestore_parent_is_directory() RETURNS trigger AS +$BODY$ +DECLARE + parent_type resourcetype; +BEGIN + SELECT "type" INTO parent_type FROM resourcestore WHERE id = NEW.parentid; + IF parent_type <> 'DIRECTORY' THEN + RAISE EXCEPTION 'Parent is not a directory: parentid = %, type = %', NEW.parentid, parent_type; + END IF; + RETURN NEW; +END; +$BODY$ +LANGUAGE plpgsql; + +CREATE TRIGGER resourcestore_parent_is_directory_trigger + BEFORE INSERT OR UPDATE ON resourcestore + FOR EACH ROW EXECUTE PROCEDURE resourcestore_parent_is_directory(); + +/* + * Trigger that updates the mtime on update, unless it is explicitly being set + * NOTE: set client_min_messages to 'debug'; to see the messages in psql + */ +CREATE FUNCTION resourcestore_update_mtime() RETURNS trigger LANGUAGE plpgsql AS +$$BEGIN + RAISE DEBUG 'resourcestore_update_mtime: %, %', OLD, NEW; + IF OLD.mtime = NEW.mtime THEN + RAISE DEBUG 'update mtime % to % on %', OLD.mtime, NEW.mtime, NEW.id; + NEW.mtime = now(); + ELSE + RAISE DEBUG 'mtime set explicitly to % on % (old mtime: %)', NEW.mtime, NEW.id, OLD.mtime; + END IF; + RETURN NEW; +END;$$; + +CREATE TRIGGER resourcestore_update_mtime_trigger + BEFORE UPDATE ON resourcestore + FOR EACH ROW EXECUTE PROCEDURE resourcestore_update_mtime(); + +/* + * Trigger that updates the parent's mtime after a resource is created or deleted + */ +CREATE FUNCTION resourcestore_update_parent_mtime() RETURNS trigger LANGUAGE plpgsql AS +$$BEGIN + RAISE DEBUG 'resourcestore_update_parent_mtime: %, %', OLD, NEW; + IF NEW IS NULL THEN -- delete + RAISE DEBUG 'updating mtime to % on parent (%) on delete of % (%)', OLD.mtime, OLD.parentid, OLD.id, OLD.name; + UPDATE resourcestore SET mtime = now() WHERE id = OLD.parentid; + ELSIF OLD IS NULL THEN -- insert + RAISE DEBUG 'updating mtime to % on parent (%) on insert of % (%)', NEW.mtime, NEW.parentid, NEW.id, NEW.name; + UPDATE resourcestore SET mtime = NEW.mtime WHERE id = NEW.parentid; + ELSIF OLD.parentid <> NEW.parentid THEN -- moved + RAISE DEBUG '% parent moved from % to %, updating mtime to % on both', NEW.id, NEW.mtime, OLD.parentid, NEW.parentid; + UPDATE resourcestore SET mtime = NEW.mtime WHERE id = OLD.parentid OR parentid = NEW.parentid; + ELSIF OLD.mtime <> NEW.mtime THEN + RAISE DEBUG '% updated, parent %, does not trigger update of parent mtime', NEW.id, NEW.parentid; + END IF; + RETURN NEW; +END;$$; + +CREATE TRIGGER resourcestore_update_parent_mtime_trigger + AFTER INSERT OR UPDATE OR DELETE ON resourcestore + FOR EACH ROW EXECUTE PROCEDURE resourcestore_update_parent_mtime(); + +CREATE OR REPLACE VIEW resources AS + WITH RECURSIVE top_down AS ( + SELECT id, parentid, "type", name AS path, mtime, content + FROM resourcestore + WHERE parentid = 0 + UNION ALL + SELECT t.id, t.parentid, t."type", concat_ws('/', r.path, t.name) AS path, t.mtime, t.content + FROM resourcestore t + JOIN top_down r ON t.parentid = r.id +) +SELECT id, parentid, "type", path, mtime, content +FROM top_down +UNION SELECT id, parentid, "type", name AS path, mtime, content FROM resourcestore WHERE parentid IS NULL +ORDER BY path; + diff --git a/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/PgsqlBackendAutoConfigurationTest.java b/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/PgsqlBackendAutoConfigurationTest.java new file mode 100644 index 000000000..9f9c8c5a2 --- /dev/null +++ b/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/PgsqlBackendAutoConfigurationTest.java @@ -0,0 +1,76 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.autoconfigure.catalog.backend.pgsql; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.geoserver.GeoServerConfigurationLock; +import org.geoserver.cloud.backend.pgsql.catalog.PgsqlCatalogFacade; +import org.geoserver.cloud.backend.pgsql.config.PgsqlConfigRepository; +import org.geoserver.cloud.backend.pgsql.config.PgsqlGeoServerFacade; +import org.geoserver.cloud.backend.pgsql.config.PgsqlUpdateSequence; +import org.geoserver.cloud.backend.pgsql.resource.PgsqlLockProvider; +import org.geoserver.cloud.config.catalog.backend.core.CatalogProperties; +import org.geoserver.cloud.config.catalog.backend.pgsql.PgsqlGeoServerLoader; +import org.geoserver.cloud.config.catalog.backend.pgsql.PgsqlGeoServerResourceLoader; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.jdbc.core.JdbcTemplate; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * @since 1.4 + */ +@Testcontainers(disabledWithoutDocker = true) +class PgsqlBackendAutoConfigurationTest { + + @Container static PostgreSQLContainer container = new PostgreSQLContainer<>("postgres:15"); + + private ApplicationContextRunner runner = + new ApplicationContextRunner() + .withBean(CatalogProperties.class, CatalogProperties::new) + .withConfiguration( + AutoConfigurations.of( + PgsqlDataSourceAutoConfiguration.class, + PgsqlMigrationAutoConfiguration.class, + PgsqlBackendAutoConfiguration.class)); + + @BeforeEach + void setUp() throws Exception { + String url = container.getJdbcUrl(); + String username = container.getUsername(); + String password = container.getPassword(); + runner = + runner.withPropertyValues( // + "geoserver.backend.pgconfig.enabled=true", // + "geoserver.backend.pgconfig.datasource.url=" + url, // + "geoserver.backend.pgconfig.datasource.username=" + username, // + "geoserver.backend.pgconfig.datasource.password=" + password // + ); + } + + @Test + void testCatalogAndConfigBeans() { + runner.run( + context -> { + assertThat(context) + .hasNotFailed() + .hasSingleBean(JdbcTemplate.class) + .hasSingleBean(GeoServerConfigurationLock.class) + .hasSingleBean(PgsqlUpdateSequence.class) + .hasSingleBean(PgsqlCatalogFacade.class) + .hasSingleBean(PgsqlGeoServerLoader.class) + .hasSingleBean(PgsqlConfigRepository.class) + .hasSingleBean(PgsqlGeoServerFacade.class) + // .hasSingleBean(PgsqlResourceStore.class) + .hasSingleBean(PgsqlGeoServerResourceLoader.class) + .hasSingleBean(PgsqlLockProvider.class); + }); + } +} diff --git a/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/PgsqlDataSourceAutoConfigurationTest.java b/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/PgsqlDataSourceAutoConfigurationTest.java new file mode 100644 index 000000000..ffc241ac8 --- /dev/null +++ b/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/PgsqlDataSourceAutoConfigurationTest.java @@ -0,0 +1,127 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.autoconfigure.catalog.backend.pgsql; + +import static org.assertj.core.api.Assertions.assertThat; + +import lombok.extern.slf4j.Slf4j; + +import org.geoserver.cloud.config.catalog.backend.pgsql.PgsqlDataSourceConfiguration; +import org.geoserver.cloud.config.jndi.SimpleJNDIStaticContextInitializer; +import org.geoserver.cloud.config.jndidatasource.JNDIDataSourceAutoConfiguration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.BeansException; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.assertj.AssertableApplicationContext; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import javax.sql.DataSource; + +/** + * Tests for {@link PgsqlDataSourceConfiguration} + * + * @since 1.4 + */ +@Testcontainers(disabledWithoutDocker = true) +@Slf4j +class PgsqlDataSourceAutoConfigurationTest { + + @Container static PostgreSQLContainer container = new PostgreSQLContainer<>("postgres:15"); + + private ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(PgsqlDataSourceAutoConfiguration.class)); + + String url; + String username; + String password; + + @BeforeEach + void setUp() throws Exception { + url = container.getJdbcUrl(); + username = container.getUsername(); + password = container.getPassword(); + } + + /** + * Test method for {@link + * org.geoserver.cloud.config.catalog.backend.pgsql.PgsqlDataSourceConfiguration#dataSource()}. + */ + @Test + void testDataSource() { + runner.withPropertyValues( // + "geoserver.backend.pgconfig.enabled: true", // + "geoserver.backend.pgconfig.datasource.url: " + url, // + "geoserver.backend.pgconfig.datasource.username: " + username, // + "geoserver.backend.pgconfig.datasource.password: " + password // + ) + .run( + context -> { + assertThat(context) + .hasNotFailed() + .hasBean("pgsqlConfigDatasource") + .getBean("pgsqlConfigDatasource") + .isInstanceOf(DataSource.class); + + assertIsPostgresql(context); + }); + } + + /** + * Test method for {@link + * org.geoserver.cloud.config.catalog.backend.pgsql.PgsqlDataSourceConfiguration#jndiDataSource()}. + */ + @Test + void testJndiDataSource() { + runner + // enable simplejndi + .withInitializer(new SimpleJNDIStaticContextInitializer()) + .withConfiguration(AutoConfigurations.of(JNDIDataSourceAutoConfiguration.class)) + .withPropertyValues( // + "geoserver.backend.pgconfig.enabled: true", // + // java:comp/env/jdbc/testdb config properties + "jndi.datasources.testdb.url: " + url, + "jndi.datasources.testdb.username: " + username, // + "jndi.datasources.testdb.password: " + password, // + "jndi.datasources.testdb.enabled: true", // + // pgsql backend datasource config using jndi + "geoserver.backend.pgconfig.datasource.jndi-name: java:comp/env/jdbc/testdb") + .run( + context -> { + assertThat(context) + .hasNotFailed() + .hasBean("pgsqlConfigDatasource") + .getBean("pgsqlConfigDatasource") + .isInstanceOf(DataSource.class); + assertIsPostgresql(context); + }); + } + + private void assertIsPostgresql(AssertableApplicationContext context) + throws BeansException, SQLException { + try (Connection c = + context.getBean("pgsqlConfigDatasource", DataSource.class).getConnection()) { + assertThat(c.isValid(2)).isTrue(); + + try (Statement st = c.createStatement(); + ResultSet rs = st.executeQuery("SELECT version()")) { + rs.next(); + String version = rs.getString(1); + log.info("database version: " + version); + assertThat(version).contains("PostgreSQL"); + } + } + } +} diff --git a/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/PgsqlMigrationAutoConfigurationTest.java b/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/PgsqlMigrationAutoConfigurationTest.java new file mode 100644 index 000000000..96379b356 --- /dev/null +++ b/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/autoconfigure/catalog/backend/pgsql/PgsqlMigrationAutoConfigurationTest.java @@ -0,0 +1,154 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.autoconfigure.catalog.backend.pgsql; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.geoserver.cloud.config.catalog.backend.pgsql.PgsqlBackendProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.function.Function; +import java.util.stream.Collectors; + +import javax.sql.DataSource; + +/** + * @since 1.4 + */ +@Testcontainers(disabledWithoutDocker = true) +class PgsqlMigrationAutoConfigurationTest { + + @Container static PostgreSQLContainer container = new PostgreSQLContainer<>("postgres:15"); + + private ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + PgsqlDataSourceAutoConfiguration.class, + PgsqlMigrationAutoConfiguration.class)); + + @BeforeEach + void setUp() throws Exception {} + + @Test + void testMigration_enabledByDefault() { + String url = container.getJdbcUrl(); + String username = container.getUsername(); + String password = container.getPassword(); + runner.withPropertyValues( // + "geoserver.backend.pgconfig.enabled=true", // + "geoserver.backend.pgconfig.datasource.url=" + url, // + "geoserver.backend.pgconfig.datasource.username=" + username, // + "geoserver.backend.pgconfig.datasource.password=" + password // + ) + .run( + context -> { + assertThat(context) + .hasNotFailed() + .hasSingleBean(PgsqlBackendProperties.class) + .hasBean("pgsqlConfigDatasource"); + + PgsqlBackendProperties config = + context.getBean(PgsqlBackendProperties.class); + DataSource ds = + context.getBean("pgsqlConfigDatasource", DataSource.class); + assertDbSchema(ds, config); + }); + } + + private void assertDbSchema(DataSource ds, PgsqlBackendProperties config) throws SQLException { + String schema = config.schema(); + Map expected = buildExpected(schema); + Map actual = findTables(ds, schema); + assertThat(actual).isEqualTo(expected); + } + + /** + * @param ds + * @param schema + * @return + * @throws SQLException + */ + private Map findTables(DataSource ds, String schema) throws SQLException { + Map actual = new TreeMap<>(); + try (Connection c = ds.getConnection()) { + try (ResultSet tables = c.getMetaData().getTables(null, schema, null, null)) { + while (tables.next()) { + String schem = tables.getString("TABLE_SCHEM"); + String name = tables.getString("TABLE_NAME"); + String type = tables.getString("TABLE_TYPE"); + if (Set.of("VIEW", "TABLE", "SEQUENCE").contains(type)) { + actual.put(schem + "." + name, type); + } + } + } + } + return actual; + } + + /** + * @param schema + * @return + */ + private Map buildExpected(String schema) { + List views = + List.of( + "workspaceinfos", + "namespaceinfos", + "storeinfos", + "resourceinfos", + "layerinfos", + "layergroupinfos", + "styleinfos", + "settingsinfos", + "serviceinfos", + "resources", + "publishedinfos"); + List tables = + List.of( + "flyway_schema_history", + "cataloginfo", + "layergroupinfo", + "layerinfo", + "namespaceinfo", + "publishedinfo", + "resourceinfo", + "storeinfo", + "styleinfo", + "workspaceinfo", + "geoserverinfo", + "settingsinfo", + "serviceinfo", + "logginginfo", + "resourcestore", + "resource_lock"); + List sequences = List.of("gs_update_sequence", "resourcestore_id_seq"); + + Map expected = new TreeMap<>(); + expected.putAll(buildExpected(schema, "VIEW", views)); + expected.putAll(buildExpected(schema, "TABLE", tables)); + expected.putAll(buildExpected(schema, "SEQUENCE", sequences)); + return expected; + } + + private Map buildExpected(String schema, String type, List names) { + return names.stream() + .map(n -> schema + "." + n) + .collect(Collectors.toMap(Function.identity(), s -> type)); + } +} diff --git a/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/backend/pgsql/catalog/PgsqlCatalogBackendConformanceTest.java b/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/backend/pgsql/catalog/PgsqlCatalogBackendConformanceTest.java new file mode 100644 index 000000000..c1be1cf56 --- /dev/null +++ b/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/backend/pgsql/catalog/PgsqlCatalogBackendConformanceTest.java @@ -0,0 +1,80 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.catalog; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; + +import org.geoserver.catalog.impl.CatalogImpl; +import org.geoserver.catalog.plugin.CatalogConformanceTest; +import org.geoserver.cloud.backend.pgsql.PgsqlBackendBuilder; +import org.geoserver.cloud.config.catalog.backend.pgsql.PgsqlDatabaseMigrations; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import javax.sql.DataSource; + +/** + * @since 1.4 + */ +@Testcontainers(disabledWithoutDocker = true) +class PgsqlCatalogBackendConformanceTest extends CatalogConformanceTest { + + @Container static PostgreSQLContainer container = new PostgreSQLContainer<>("postgres:15"); + + static DataSource dataSource; + + static final String schema = "testschema"; + static PgsqlDatabaseMigrations databaseMigrations; + + @Disabled( + """ + revisit, seems to be just a problem of ordering or equals with the \ + returned ft/ft2 where mockito is not throwing the expected exception + """) + public @Override void testSaveDataStoreRollbacksBothStoreAndResources() throws Exception {} + + static @BeforeAll void createDataSource() throws Exception { + String url = container.getJdbcUrl(); + String username = container.getUsername(); + String password = container.getPassword(); + String driverClassName = container.getDriverClassName(); + + HikariConfig hikariConfig = new HikariConfig(); + hikariConfig.setJdbcUrl(url); + hikariConfig.setPassword(password); + hikariConfig.setUsername(username); + hikariConfig.setDriverClassName(driverClassName); + hikariConfig.setSchema(schema); + dataSource = new HikariDataSource(hikariConfig); + databaseMigrations = + new PgsqlDatabaseMigrations() + .setSchema(schema) + .setDataSource(dataSource) + .setCleanDisabled(false); + } + + @Override + @BeforeEach + public void setUp() throws Exception { + databaseMigrations.migrate(); + super.setUp(); + } + + @AfterEach + void cleanDb() throws Exception { + databaseMigrations.clean(); + } + + @Override + protected CatalogImpl createCatalog() { + return new PgsqlBackendBuilder(dataSource).createCatalog(); + } +} diff --git a/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlWorkspaceRepositoryTest.java b/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlWorkspaceRepositoryTest.java new file mode 100644 index 000000000..ce82d6557 --- /dev/null +++ b/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/backend/pgsql/catalog/repository/PgsqlWorkspaceRepositoryTest.java @@ -0,0 +1,69 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.catalog.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; + +import org.geoserver.catalog.WorkspaceInfo; +import org.geoserver.catalog.impl.WorkspaceInfoImpl; +import org.geoserver.cloud.config.catalog.backend.pgsql.PgsqlDatabaseMigrations; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.Optional; + +import javax.sql.DataSource; + +/** + * @since 1.4 + */ +@Testcontainers(disabledWithoutDocker = true) +class PgsqlWorkspaceRepositoryTest { + + @Container static PostgreSQLContainer container = new PostgreSQLContainer<>("postgres:15"); + + static DataSource dataSource; + + PgsqlWorkspaceRepository repo; + + static @BeforeAll void createDataSource() throws Exception { + String url = container.getJdbcUrl(); + String username = container.getUsername(); + String password = container.getPassword(); + String driverClassName = container.getDriverClassName(); + + HikariConfig hikariConfig = new HikariConfig(); + hikariConfig.setJdbcUrl(url); + hikariConfig.setPassword(password); + hikariConfig.setUsername(username); + hikariConfig.setDriverClassName(driverClassName); + dataSource = new HikariDataSource(hikariConfig); + + new PgsqlDatabaseMigrations().setDataSource(dataSource).migrate(); + } + + @BeforeEach + void setUp() { + repo = new PgsqlWorkspaceRepository(new JdbcTemplate(dataSource)); + } + + @Test + void testAdd() { + WorkspaceInfoImpl info = new WorkspaceInfoImpl(); + info.setId("ws1"); + info.setName("ws1"); + repo.add(info); + Optional found = repo.findById(info.getId(), repo.getContentType()); + assertThat(found.isPresent()); + } +} diff --git a/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/backend/pgsql/config/PgsqlConfigRepositoryConformanceTest.java b/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/backend/pgsql/config/PgsqlConfigRepositoryConformanceTest.java new file mode 100644 index 000000000..ac4436eb4 --- /dev/null +++ b/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/backend/pgsql/config/PgsqlConfigRepositoryConformanceTest.java @@ -0,0 +1,76 @@ +/* + * /* (c) 2014 Open Source Geospatial Foundation - all rights reserved (c) 2001 - 2013 OpenPlans + * This code is licensed under the GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.config; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; + +import org.geoserver.catalog.Catalog; +import org.geoserver.cloud.backend.pgsql.PgsqlBackendBuilder; +import org.geoserver.cloud.config.catalog.backend.pgsql.PgsqlDatabaseMigrations; +import org.geoserver.config.GeoServer; +import org.geoserver.config.GeoServerConfigConformanceTest; +import org.geoserver.config.plugin.GeoServerImpl; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import javax.sql.DataSource; + +/** + * @since 1.4 + */ +@Testcontainers(disabledWithoutDocker = true) +public class PgsqlConfigRepositoryConformanceTest extends GeoServerConfigConformanceTest { + + @Container static PostgreSQLContainer container = new PostgreSQLContainer<>("postgres:15"); + + static DataSource dataSource; + + static final String schema = "testschema"; + static PgsqlDatabaseMigrations databaseMigrations; + + static @BeforeAll void createDataSource() throws Exception { + String url = container.getJdbcUrl(); + String username = container.getUsername(); + String password = container.getPassword(); + String driverClassName = container.getDriverClassName(); + + HikariConfig hikariConfig = new HikariConfig(); + hikariConfig.setJdbcUrl(url); + hikariConfig.setPassword(password); + hikariConfig.setUsername(username); + hikariConfig.setDriverClassName(driverClassName); + hikariConfig.setSchema(schema); + dataSource = new HikariDataSource(hikariConfig); + databaseMigrations = + new PgsqlDatabaseMigrations() + .setSchema(schema) + .setDataSource(dataSource) + .setCleanDisabled(false); + } + + @Override + @BeforeEach + public void setUp() throws Exception { + databaseMigrations.migrate(); + super.setUp(); + } + + @AfterEach + void cleanDb() throws Exception { + databaseMigrations.clean(); + } + + protected @Override GeoServer createGeoServer() { + PgsqlBackendBuilder builder = new PgsqlBackendBuilder(dataSource); + Catalog catalog = builder.createCatalog(); + GeoServerImpl gs = builder.createGeoServer(catalog); + return gs; + } +} diff --git a/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/backend/pgsql/config/PgsqlUpdateSequenceTest.java b/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/backend/pgsql/config/PgsqlUpdateSequenceTest.java new file mode 100644 index 000000000..c264d15eb --- /dev/null +++ b/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/backend/pgsql/config/PgsqlUpdateSequenceTest.java @@ -0,0 +1,83 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.config; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; + +import org.geoserver.cloud.config.catalog.backend.pgsql.PgsqlDatabaseMigrations; +import org.geoserver.config.GeoServer; +import org.geoserver.config.plugin.GeoServerImpl; +import org.geoserver.platform.config.UpdateSequence; +import org.geoserver.platform.config.UpdateSequenceConformanceTest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.jdbc.core.JdbcTemplate; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import javax.sql.DataSource; + +/** + * @since 1.4 + */ +@Testcontainers(disabledWithoutDocker = true) +class PgsqlUpdateSequenceTest implements UpdateSequenceConformanceTest { + + @Container static PostgreSQLContainer container = new PostgreSQLContainer<>("postgres:15"); + + static DataSource dataSource; + static final String schema = "testschema"; + static PgsqlDatabaseMigrations databaseMigrations; + + static @BeforeAll void createDataSource() throws Exception { + String url = container.getJdbcUrl(); + String username = container.getUsername(); + String password = container.getPassword(); + String driverClassName = container.getDriverClassName(); + + HikariConfig hikariConfig = new HikariConfig(); + hikariConfig.setJdbcUrl(url); + hikariConfig.setPassword(password); + hikariConfig.setUsername(username); + hikariConfig.setDriverClassName(driverClassName); + hikariConfig.setSchema(schema); + dataSource = new HikariDataSource(hikariConfig); + databaseMigrations = + new PgsqlDatabaseMigrations() + .setSchema(schema) + .setDataSource(dataSource) + .setCleanDisabled(false); + } + + private UpdateSequence sequence; + private PgsqlGeoServerFacade facade; + private GeoServer geoserver; + + @BeforeEach + public void init() throws Exception { + databaseMigrations.migrate(); + facade = new PgsqlGeoServerFacade(new JdbcTemplate(dataSource)); + geoserver = new GeoServerImpl(facade); + sequence = new PgsqlUpdateSequence(dataSource, facade); + } + + @AfterEach + void cleanDb() throws Exception { + databaseMigrations.clean(); + } + + @Override + public UpdateSequence getUpdataSequence() { + return sequence; + } + + @Override + public GeoServer getGeoSever() { + return geoserver; + } +} diff --git a/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/backend/pgsql/resource/PgsqlResourceStoreTest.java b/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/backend/pgsql/resource/PgsqlResourceStoreTest.java new file mode 100644 index 000000000..34de08623 --- /dev/null +++ b/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/backend/pgsql/resource/PgsqlResourceStoreTest.java @@ -0,0 +1,142 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.resource; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.integration.support.locks.LockRegistry; + +/** + * @since 1.4 + */ +@Disabled +class PgsqlResourceStoreTest { + + private PgsqlResourceStore store; + + /** + * @throws java.lang.Exception + */ + @BeforeEach + void setUp() throws Exception { + LockRegistry registry; + // PgsqlLockProvider lockProvider = new PgsqlLockProvider(registry); + // store = new PgsqlResourceStore(new JdbcTemplate(dataSource), lockProvider); + } + + /** + * Test method for {@link + * org.geoserver.cloud.backend.pgsql.resource.PgsqlResourceStore#get(java.lang.String)}. + */ + @Test + void testGet() { + fail("Not yet implemented"); + } + + /** + * Test method for {@link + * org.geoserver.cloud.backend.pgsql.resource.PgsqlResourceStore#save(org.geoserver.cloud.backend.pgsql.resource.PgsqlResource, + * byte[])}. + */ + @Test + void testSave() { + fail("Not yet implemented"); + } + + /** + * Test method for {@link + * org.geoserver.cloud.backend.pgsql.resource.PgsqlResourceStore#remove(java.lang.String)}. + */ + @Test + void testRemove() { + fail("Not yet implemented"); + } + + /** + * Test method for {@link + * org.geoserver.cloud.backend.pgsql.resource.PgsqlResourceStore#move(java.lang.String, + * java.lang.String)}. + */ + @Test + void testMove() { + fail("Not yet implemented"); + } + + /** + * Test method for {@link + * org.geoserver.cloud.backend.pgsql.resource.PgsqlResourceStore#getResourceNotificationDispatcher()}. + */ + @Test + void testGetResourceNotificationDispatcher() { + fail("Not yet implemented"); + } + + /** + * Test method for {@link + * org.geoserver.cloud.backend.pgsql.resource.PgsqlResourceStore#contents(org.geoserver.cloud.backend.pgsql.resource.PgsqlResource)}. + */ + @Test + void testContents() { + fail("Not yet implemented"); + } + + /** + * Test method for {@link + * org.geoserver.cloud.backend.pgsql.resource.PgsqlResourceStore#delete(org.geoserver.cloud.backend.pgsql.resource.PgsqlResource)}. + */ + @Test + void testDelete() { + fail("Not yet implemented"); + } + + /** + * Test method for {@link + * org.geoserver.cloud.backend.pgsql.resource.PgsqlResourceStore#list(org.geoserver.cloud.backend.pgsql.resource.PgsqlResource)}. + */ + @Test + void testList() { + fail("Not yet implemented"); + } + + /** + * Test method for {@link + * org.geoserver.cloud.backend.pgsql.resource.PgsqlResourceStore#move(org.geoserver.cloud.backend.pgsql.resource.PgsqlResource, + * org.geoserver.platform.resource.Resource)}. + */ + @Test + void testRename() { + fail("Not yet implemented"); + } + + /** + * Test method for {@link + * org.geoserver.cloud.backend.pgsql.resource.PgsqlResourceStore#asFile(org.geoserver.cloud.backend.pgsql.resource.PgsqlResource)}. + */ + @Test + void testAsFile() { + fail("Not yet implemented"); + } + + /** + * Test method for {@link + * org.geoserver.cloud.backend.pgsql.resource.PgsqlResourceStore#asDir(org.geoserver.cloud.backend.pgsql.resource.PgsqlResource)}. + */ + @Test + void testAsDir() { + fail("Not yet implemented"); + } + + /** + * Test method for {@link + * org.geoserver.cloud.backend.pgsql.resource.PgsqlResourceStore#getLockProvider()}. + */ + @Test + void testGetLockProvider() { + fail("Not yet implemented"); + } +} diff --git a/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/backend/pgsql/resource/PgsqlResourceTest.java b/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/backend/pgsql/resource/PgsqlResourceTest.java new file mode 100644 index 000000000..b79f5351c --- /dev/null +++ b/src/catalog/backends/pgsql/src/test/java/org/geoserver/cloud/backend/pgsql/resource/PgsqlResourceTest.java @@ -0,0 +1,191 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.backend.pgsql.resource; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; + +import org.geoserver.cloud.config.catalog.backend.pgsql.PgsqlDatabaseMigrations; +import org.geoserver.platform.resource.Paths; +import org.geoserver.platform.resource.Resource; +import org.geoserver.platform.resource.Resource.Type; +import org.geoserver.platform.resource.ResourceTheoryTest; +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Ignore; +import org.junit.Rule; +import org.junit.experimental.theories.DataPoints; +import org.junit.experimental.theories.Theories; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.springframework.context.annotation.Bean; +import org.springframework.integration.jdbc.lock.DefaultLockRepository; +import org.springframework.integration.jdbc.lock.JdbcLockRegistry; +import org.springframework.integration.jdbc.lock.LockRepository; +import org.springframework.integration.support.locks.LockRegistry; +import org.springframework.jdbc.core.JdbcTemplate; +import org.testcontainers.containers.PostgreSQLContainer; + +import java.util.Arrays; +import java.util.Objects; + +import javax.sql.DataSource; + +@RunWith(Theories.class) +public class PgsqlResourceTest extends ResourceTheoryTest { + + public @ClassRule static PostgreSQLContainer container = + new PostgreSQLContainer<>("postgres:15"); + + static final String schema = "testschema"; + static PgsqlDatabaseMigrations databaseMigrations; + static DataSource dataSource; + + public @Rule TemporaryFolder cacheDir = new TemporaryFolder(); + private PgsqlResourceStore store; + + public static @BeforeClass void createDataSource() throws Exception { + String url = container.getJdbcUrl(); + String username = container.getUsername(); + String password = container.getPassword(); + String driverClassName = container.getDriverClassName(); + + HikariConfig hikariConfig = new HikariConfig(); + hikariConfig.setJdbcUrl(url); + hikariConfig.setPassword(password); + hikariConfig.setUsername(username); + hikariConfig.setDriverClassName(driverClassName); + hikariConfig.setSchema(schema); + dataSource = new HikariDataSource(hikariConfig); + databaseMigrations = + new PgsqlDatabaseMigrations() + .setSchema(schema) + .setDataSource(dataSource) + .setCleanDisabled(false); + databaseMigrations.migrate(); + } + + @DataPoints + public static String[] testPaths() { + return new String[] { + "FileA", + "FileB", + "DirC", + "DirC/FileD", + "DirE", + "UndefF", + "DirC/UndefF", + "DirE/UndefF", + "DirE/UndefD/UndefF" + }; + } + + @After + public void cleanDb() throws Exception { + new JdbcTemplate(dataSource).update("DELETE FROM resourcestore WHERE parentid IS NOT NULL"); + } + + @Before + public void setUp() throws Exception { + JdbcTemplate template = new JdbcTemplate(dataSource); + PgsqlLockProvider lockProvider = new PgsqlLockProvider(pgsqlLockRegistry()); + store = new PgsqlResourceStore(cacheDir.getRoot().toPath(), template, lockProvider); + setupTestData(template); + } + + private void setupTestData(JdbcTemplate template) throws Exception { + for (String path : testPaths()) { + boolean undef = Paths.name(path).contains("Undef"); + if (!undef) { + boolean dir = Paths.name(path).contains("Dir"); + String parentPath = Paths.parent(path); + Objects.requireNonNull(parentPath); + long parentId = + template.queryForObject( + "SELECT id FROM resources WHERE path = ?", Long.class, parentPath); + String name = Paths.name(path); + Resource.Type type = dir ? Type.DIRECTORY : Type.RESOURCE; + byte[] contents = dir ? null : path.getBytes("UTF-8"); + String sql = + """ + INSERT INTO resourcestore (parentid, name, "type", content) + VALUES (?, ?, ?, ?) + """; + template.update(sql, parentId, name, type.toString(), contents); + } + } + } + + /** + * @return + */ + private LockRegistry pgsqlLockRegistry() { + return new JdbcLockRegistry(pgsqlLockRepository()); + } + + @Bean + LockRepository pgsqlLockRepository() { + DefaultLockRepository lockRepository = + new DefaultLockRepository(dataSource, "test-instance"); + // override default table prefix "INT" by "RESOURCE_" (matching table definition + // RESOURCE_LOCK in init.XXX.sql + lockRepository.setPrefix("RESOURCE_"); + // time in ms to expire dead locks (10k is the default) + lockRepository.setTimeToLive(300_000); + return lockRepository; + } + + @Override + protected Resource getDirectory() { + return Arrays.stream(testPaths()) + .filter(path -> Paths.name(path).contains("Dir")) + .map(store::get) + .map(PgsqlResource.class::cast) + .filter(PgsqlResource::isDirectory) + .findFirst() + .orElseThrow(); + } + + @Override + protected Resource getResource() { + return Arrays.stream(testPaths()) + .filter(path -> Paths.name(path).contains("File")) + .map(store::get) + .map(PgsqlResource.class::cast) + .filter(PgsqlResource::isFile) + .findFirst() + .orElseThrow(); + } + + @Override + protected Resource getUndefined() { + return Arrays.stream(testPaths()) + .filter(path -> Paths.name(path).contains("UndefF")) + .map(store::get) + .map(PgsqlResource.class::cast) + .filter(PgsqlResource::isUndefined) + .findFirst() + .orElseThrow(); + } + + @Override + protected Resource getResource(String path) throws Exception { + return store.get(path); + } + + @Ignore + @Override + public void theoryAlteringFileAltersResource(String path) throws Exception { + // disabled + } + + @Ignore + @Override + public void theoryAddingFileToDirectoryAddsResource(String path) throws Exception { + // disabled + } +} diff --git a/src/catalog/backends/pgsql/src/test/resources/application-test.yml b/src/catalog/backends/pgsql/src/test/resources/application-test.yml new file mode 100644 index 000000000..402894936 --- /dev/null +++ b/src/catalog/backends/pgsql/src/test/resources/application-test.yml @@ -0,0 +1,10 @@ + + +geoserver: + backend: + pgsql: + initialize: true + schema: gscatalog + create-schema: true + datasource: + jndi-name: java:comp/env/jdbc/test \ No newline at end of file diff --git a/src/catalog/backends/pgsql/src/test/resources/logback-test.xml b/src/catalog/backends/pgsql/src/test/resources/logback-test.xml new file mode 100644 index 000000000..6de5b9074 --- /dev/null +++ b/src/catalog/backends/pgsql/src/test/resources/logback-test.xml @@ -0,0 +1,17 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/catalog/cache/src/test/java/org/geoserver/cloud/event/remote/cache/RemoteEventCacheEvictorTestConfiguration.java b/src/catalog/cache/src/test/java/org/geoserver/cloud/event/remote/cache/RemoteEventCacheEvictorTestConfiguration.java index d373febe7..3e3b2de6b 100644 --- a/src/catalog/cache/src/test/java/org/geoserver/cloud/event/remote/cache/RemoteEventCacheEvictorTestConfiguration.java +++ b/src/catalog/cache/src/test/java/org/geoserver/cloud/event/remote/cache/RemoteEventCacheEvictorTestConfiguration.java @@ -34,8 +34,8 @@ public class RemoteEventCacheEvictorTestConfiguration { @Bean - UpdateSequence defaultUpdateSequence() { - return new DefaultUpdateSequence(); + UpdateSequence defaultUpdateSequence(GeoServer geoserver) { + return new DefaultUpdateSequence(geoserver); } @Bean(name = {"rawCatalog"}) diff --git a/src/catalog/catalog-server/server/src/main/java/org/geoserver/cloud/catalog/server/service/ProxyResolver.java b/src/catalog/catalog-server/server/src/main/java/org/geoserver/cloud/catalog/server/service/ProxyResolver.java index 526378dbf..07cbaab07 100644 --- a/src/catalog/catalog-server/server/src/main/java/org/geoserver/cloud/catalog/server/service/ProxyResolver.java +++ b/src/catalog/catalog-server/server/src/main/java/org/geoserver/cloud/catalog/server/service/ProxyResolver.java @@ -7,13 +7,15 @@ import org.geoserver.catalog.Catalog; import org.geoserver.catalog.Info; import org.geoserver.catalog.plugin.Patch; +import org.geoserver.catalog.plugin.resolving.ProxyUtils; import org.geoserver.config.GeoServer; -import org.geoserver.jackson.databind.catalog.ProxyUtils; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; +import java.util.Optional; + /** */ @Service public class ProxyResolver { @@ -21,7 +23,7 @@ public class ProxyResolver { private ProxyUtils blockingResolver; public ProxyResolver(Catalog catalog, GeoServer config) { - this.blockingResolver = new ProxyUtils(catalog, config); + this.blockingResolver = new ProxyUtils(catalog, Optional.of(config)); } public Mono resolve(C info) { diff --git a/src/catalog/catalog-server/server/src/test/java/org/geoserver/cloud/catalog/server/api/v1/AbstractReactiveCatalogControllerTest.java b/src/catalog/catalog-server/server/src/test/java/org/geoserver/cloud/catalog/server/api/v1/AbstractReactiveCatalogControllerTest.java index cedc78ada..cd9063f37 100644 --- a/src/catalog/catalog-server/server/src/test/java/org/geoserver/cloud/catalog/server/api/v1/AbstractReactiveCatalogControllerTest.java +++ b/src/catalog/catalog-server/server/src/test/java/org/geoserver/cloud/catalog/server/api/v1/AbstractReactiveCatalogControllerTest.java @@ -16,12 +16,12 @@ import org.geoserver.catalog.CatalogTestData; import org.geoserver.catalog.impl.ClassMappings; import org.geoserver.catalog.plugin.Query; +import org.geoserver.catalog.plugin.resolving.ProxyUtils; import org.geoserver.cloud.catalog.server.test.CatalogTestClient; import org.geoserver.cloud.catalog.server.test.TestConfiguration; import org.geoserver.cloud.catalog.server.test.WebTestClientSupport; import org.geoserver.cloud.catalog.server.test.WebTestClientSupportConfiguration; import org.geoserver.config.GeoServer; -import org.geoserver.jackson.databind.catalog.ProxyUtils; import org.geotools.filter.text.cql2.CQLException; import org.geotools.filter.text.ecql.ECQL; import org.junit.jupiter.api.AfterEach; @@ -39,6 +39,7 @@ import java.io.IOException; import java.util.Arrays; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.function.BiConsumer; import java.util.function.Consumer; @@ -67,7 +68,8 @@ protected AbstractReactiveCatalogControllerTest(@NonNull Class infoType) { } public @BeforeEach void setup() { - proxyResolver = new ProxyUtils(catalog, geoServer).failOnMissingReference(true); + proxyResolver = + new ProxyUtils(catalog, Optional.of(geoServer)).failOnMissingReference(true); testData = CatalogTestData.initialized(() -> catalog, () -> null) .initConfig(false) diff --git a/src/catalog/event-bus/pom.xml b/src/catalog/event-bus/pom.xml index 0518cd45e..c44eb36cd 100644 --- a/src/catalog/event-bus/pom.xml +++ b/src/catalog/event-bus/pom.xml @@ -9,9 +9,6 @@ gs-cloud-catalog-event-bus jar Event bus catalog notification support - - 1.15.1 - org.springframework.cloud @@ -59,13 +56,11 @@ org.testcontainers junit-jupiter - ${testcontainers.version} test org.testcontainers rabbitmq - ${testcontainers.version} test diff --git a/src/catalog/event-bus/src/main/java/org/geoserver/cloud/event/bus/InfoEventResolver.java b/src/catalog/event-bus/src/main/java/org/geoserver/cloud/event/bus/InfoEventResolver.java index 4c4f798bb..0d6f2498c 100644 --- a/src/catalog/event-bus/src/main/java/org/geoserver/cloud/event/bus/InfoEventResolver.java +++ b/src/catalog/event-bus/src/main/java/org/geoserver/cloud/event/bus/InfoEventResolver.java @@ -13,13 +13,14 @@ import org.geoserver.catalog.plugin.Patch; import org.geoserver.catalog.plugin.resolving.CatalogPropertyResolver; import org.geoserver.catalog.plugin.resolving.CollectionPropertiesInitializer; +import org.geoserver.catalog.plugin.resolving.ProxyUtils; import org.geoserver.catalog.plugin.resolving.ResolvingProxyResolver; import org.geoserver.cloud.event.info.InfoAdded; import org.geoserver.cloud.event.info.InfoEvent; import org.geoserver.cloud.event.info.InfoModified; import org.geoserver.config.GeoServer; -import org.geoserver.jackson.databind.catalog.ProxyUtils; +import java.util.Optional; import java.util.function.Function; /** @@ -43,7 +44,7 @@ public class InfoEventResolver { public InfoEventResolver(@NonNull Catalog rawCatalog, @NonNull GeoServer geoserverConfig) { this.rawCatalog = rawCatalog; this.geoserverConfig = geoserverConfig; - proxyUtils = new ProxyUtils(rawCatalog, geoserverConfig); + proxyUtils = new ProxyUtils(rawCatalog, Optional.of(geoserverConfig)); configInfoResolver = CollectionPropertiesInitializer.instance() diff --git a/src/catalog/event-bus/src/test/java/org/geoserver/cloud/event/bus/TestConfigurationAutoConfiguration.java b/src/catalog/event-bus/src/test/java/org/geoserver/cloud/event/bus/TestConfigurationAutoConfiguration.java index 8d66fa3ac..489d8ea56 100644 --- a/src/catalog/event-bus/src/test/java/org/geoserver/cloud/event/bus/TestConfigurationAutoConfiguration.java +++ b/src/catalog/event-bus/src/test/java/org/geoserver/cloud/event/bus/TestConfigurationAutoConfiguration.java @@ -20,8 +20,8 @@ @SpringBootConfiguration public class TestConfigurationAutoConfiguration { - public @Bean UpdateSequence testUpdateSequence() { - return new DefaultUpdateSequence(); + public @Bean UpdateSequence testUpdateSequence(GeoServer gs) { + return new DefaultUpdateSequence(gs); } public @Bean XStreamPersisterFactory xStreamPersisterFactory() { diff --git a/src/catalog/events/src/test/java/org/geoserver/cloud/config/catalog/events/TestConfigurationAutoConfiguration.java b/src/catalog/events/src/test/java/org/geoserver/cloud/config/catalog/events/TestConfigurationAutoConfiguration.java index 7a45019e7..6fe581601 100644 --- a/src/catalog/events/src/test/java/org/geoserver/cloud/config/catalog/events/TestConfigurationAutoConfiguration.java +++ b/src/catalog/events/src/test/java/org/geoserver/cloud/config/catalog/events/TestConfigurationAutoConfiguration.java @@ -20,8 +20,8 @@ @SpringBootConfiguration class TestConfigurationAutoConfiguration { - public @Bean UpdateSequence testUpdateSequence() { - return new DefaultUpdateSequence(); + public @Bean UpdateSequence testUpdateSequence(GeoServer gs) { + return new DefaultUpdateSequence(gs); } public @Bean XStreamPersisterFactory xStreamPersisterFactory() { diff --git a/src/catalog/jackson-bindings/geoserver/src/main/java/org/geoserver/jackson/databind/catalog/dto/Namespace.java b/src/catalog/jackson-bindings/geoserver/src/main/java/org/geoserver/jackson/databind/catalog/dto/Namespace.java index bcdc117e8..0fd62bf14 100644 --- a/src/catalog/jackson-bindings/geoserver/src/main/java/org/geoserver/jackson/databind/catalog/dto/Namespace.java +++ b/src/catalog/jackson-bindings/geoserver/src/main/java/org/geoserver/jackson/databind/catalog/dto/Namespace.java @@ -15,7 +15,7 @@ @EqualsAndHashCode(callSuper = true) @JsonTypeName("NamespaceInfo") public class Namespace extends CatalogInfoDto { - private String prefix; + private String name; private String URI; private boolean isolated; private MetadataMapDto metadata; diff --git a/src/catalog/jackson-bindings/geoserver/src/main/java/org/geoserver/jackson/databind/catalog/mapper/NamespaceMapper.java b/src/catalog/jackson-bindings/geoserver/src/main/java/org/geoserver/jackson/databind/catalog/mapper/NamespaceMapper.java index f043f35be..a7a814795 100644 --- a/src/catalog/jackson-bindings/geoserver/src/main/java/org/geoserver/jackson/databind/catalog/mapper/NamespaceMapper.java +++ b/src/catalog/jackson-bindings/geoserver/src/main/java/org/geoserver/jackson/databind/catalog/mapper/NamespaceMapper.java @@ -7,9 +7,12 @@ import org.geoserver.catalog.NamespaceInfo; import org.geoserver.jackson.databind.catalog.dto.Namespace; import org.mapstruct.Mapper; +import org.mapstruct.Mapping; @Mapper(config = CatalogInfoMapperConfig.class) public interface NamespaceMapper { + + @Mapping(target = "prefix", source = "name") NamespaceInfo map(Namespace o); Namespace map(NamespaceInfo o); diff --git a/src/catalog/jackson-bindings/geoserver/src/main/java/org/geoserver/jackson/databind/config/dto/Service.java b/src/catalog/jackson-bindings/geoserver/src/main/java/org/geoserver/jackson/databind/config/dto/Service.java index 77ec670d1..57e050142 100644 --- a/src/catalog/jackson-bindings/geoserver/src/main/java/org/geoserver/jackson/databind/config/dto/Service.java +++ b/src/catalog/jackson-bindings/geoserver/src/main/java/org/geoserver/jackson/databind/config/dto/Service.java @@ -41,7 +41,8 @@ @JsonSubTypes.Type(value = Service.WfsService.class), @JsonSubTypes.Type(value = Service.WcsService.class), @JsonSubTypes.Type(value = Service.WpsService.class), - @JsonSubTypes.Type(value = Service.WmtsService.class) + @JsonSubTypes.Type(value = Service.WmtsService.class), + @JsonSubTypes.Type(value = Service.GenericService.class) }) @EqualsAndHashCode(callSuper = true) public abstract @Data @Generated class Service extends ConfigInfoDto { @@ -79,6 +80,10 @@ */ private Map internationalAbstract; + @EqualsAndHashCode(callSuper = true) + @JsonTypeName("ServiceInfo") + public static @Data @Generated class GenericService extends Service {} + @EqualsAndHashCode(callSuper = true) @JsonTypeName("WMSInfo") public static @Data @Generated class WmsService extends Service { diff --git a/src/catalog/jackson-bindings/geoserver/src/main/java/org/geoserver/jackson/databind/config/dto/mapper/GeoServerConfigMapper.java b/src/catalog/jackson-bindings/geoserver/src/main/java/org/geoserver/jackson/databind/config/dto/mapper/GeoServerConfigMapper.java index 172848b4e..83076ef21 100644 --- a/src/catalog/jackson-bindings/geoserver/src/main/java/org/geoserver/jackson/databind/config/dto/mapper/GeoServerConfigMapper.java +++ b/src/catalog/jackson-bindings/geoserver/src/main/java/org/geoserver/jackson/databind/config/dto/mapper/GeoServerConfigMapper.java @@ -15,6 +15,7 @@ import org.geoserver.config.LoggingInfo; import org.geoserver.config.ServiceInfo; import org.geoserver.config.SettingsInfo; +import org.geoserver.config.impl.ServiceInfoImpl; import org.geoserver.gwc.wmts.WMTSInfo; import org.geoserver.gwc.wmts.WMTSInfoImpl; import org.geoserver.jackson.databind.catalog.dto.CatalogInfoDto; @@ -125,6 +126,7 @@ default ServiceInfo toInfo(Service dto) { if (dto instanceof Service.WcsService) return toInfo((Service.WcsService) dto); if (dto instanceof Service.WpsService) return toInfo((Service.WpsService) dto); if (dto instanceof Service.WmtsService) return toInfo((Service.WmtsService) dto); + if (dto instanceof Service.GenericService) return toInfo((Service.GenericService) dto); throw new IllegalArgumentException( "Unknown ServiceInfo type: " + dto.getClass().getCanonicalName()); @@ -137,6 +139,7 @@ default Service toDto(ServiceInfo info) { if (info instanceof WCSInfo) return toDto((WCSInfo) info); if (info instanceof WPSInfo) return toDto((WPSInfo) info); if (info instanceof WMTSInfo) return toDto((WMTSInfo) info); + if (info.getClass().equals(ServiceInfoImpl.class)) return toGenericService(info); throw new IllegalArgumentException( "Unknown ServiceInfo type: " + info.getClass().getCanonicalName()); @@ -183,6 +186,13 @@ default List stringListToVersionList(List lis @Mapping(target = "versions", expression = "java(stringListToVersionList(dto.getVersions()))") WMTSInfoImpl toInfo(Service.WmtsService dto); + @Mapping(target = "clientProperties", ignore = true) + @Mapping(target = "geoServer", ignore = true) + @Mapping(target = "versions", expression = "java(stringListToVersionList(dto.getVersions()))") + ServiceInfoImpl toInfo(Service.GenericService dto); + + Service.GenericService toGenericService(ServiceInfo info); + Service.WmtsService toDto(WMTSInfo info); CogSettings cogSettings(CogSettingsDto dto); diff --git a/src/catalog/jackson-bindings/geoserver/src/main/java/org/geoserver/jackson/databind/mapper/PatchMapper.java b/src/catalog/jackson-bindings/geoserver/src/main/java/org/geoserver/jackson/databind/mapper/PatchMapper.java index fe586bb57..c84b2b6b6 100644 --- a/src/catalog/jackson-bindings/geoserver/src/main/java/org/geoserver/jackson/databind/mapper/PatchMapper.java +++ b/src/catalog/jackson-bindings/geoserver/src/main/java/org/geoserver/jackson/databind/mapper/PatchMapper.java @@ -9,7 +9,7 @@ import org.geoserver.catalog.Info; import org.geoserver.catalog.plugin.Patch; import org.geoserver.catalog.plugin.PropertyDiff; -import org.geoserver.jackson.databind.catalog.ProxyUtils; +import org.geoserver.catalog.plugin.resolving.ProxyUtils; import org.geoserver.jackson.databind.catalog.dto.InfoReference; import org.geoserver.jackson.databind.catalog.dto.PatchDto; import org.geoserver.jackson.databind.catalog.mapper.ValueMappers; diff --git a/src/catalog/jackson-bindings/geoserver/src/test/java/org/geoserver/jackson/databind/catalog/GeoServerCatalogModuleTest.java b/src/catalog/jackson-bindings/geoserver/src/test/java/org/geoserver/jackson/databind/catalog/GeoServerCatalogModuleTest.java index 216d1aa33..7ba929401 100644 --- a/src/catalog/jackson-bindings/geoserver/src/test/java/org/geoserver/jackson/databind/catalog/GeoServerCatalogModuleTest.java +++ b/src/catalog/jackson-bindings/geoserver/src/test/java/org/geoserver/jackson/databind/catalog/GeoServerCatalogModuleTest.java @@ -51,6 +51,7 @@ import org.geoserver.catalog.impl.ModificationProxy; import org.geoserver.catalog.plugin.CatalogPlugin; import org.geoserver.catalog.plugin.Query; +import org.geoserver.catalog.plugin.resolving.ProxyUtils; import org.geoserver.cog.CogSettings.RangeReaderType; import org.geoserver.cog.CogSettingsStore; import org.geoserver.config.GeoServer; @@ -92,6 +93,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Optional; /** * Verifies that all {@link CatalogInfo} can be sent over the wire and parsed back using jackson, @@ -126,7 +128,7 @@ protected void print(String logmsg, Object... args) { geoserver = new GeoServerImpl(); geoserver.setCatalog(catalog); data = CatalogTestData.initialized(() -> catalog, () -> geoserver).initialize(); - proxyResolver = new ProxyUtils(catalog, geoserver); + proxyResolver = new ProxyUtils(catalog, Optional.of(geoserver)); } protected abstract ObjectMapper newObjectMapper(); diff --git a/src/catalog/jackson-bindings/geoserver/src/test/java/org/geoserver/jackson/databind/catalog/PatchSerializationTest.java b/src/catalog/jackson-bindings/geoserver/src/test/java/org/geoserver/jackson/databind/catalog/PatchSerializationTest.java index a38f7988a..29c28e729 100644 --- a/src/catalog/jackson-bindings/geoserver/src/test/java/org/geoserver/jackson/databind/catalog/PatchSerializationTest.java +++ b/src/catalog/jackson-bindings/geoserver/src/test/java/org/geoserver/jackson/databind/catalog/PatchSerializationTest.java @@ -44,6 +44,7 @@ import org.geoserver.catalog.impl.ModificationProxy; import org.geoserver.catalog.plugin.CatalogPlugin; import org.geoserver.catalog.plugin.Patch; +import org.geoserver.catalog.plugin.resolving.ProxyUtils; import org.geoserver.cog.CogSettings; import org.geoserver.cog.CogSettings.RangeReaderType; import org.geoserver.cog.CogSettingsStore; @@ -94,6 +95,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.function.Supplier; @@ -129,7 +131,7 @@ protected void print(String logmsg, Object... args) { geoserver = new GeoServerImpl(); geoserver.setCatalog(catalog); data = CatalogTestData.initialized(() -> catalog, () -> geoserver).initialize(); - proxyResolver = new ProxyUtils(catalog, geoserver); + proxyResolver = new ProxyUtils(catalog, Optional.of(geoserver)); } protected abstract ObjectMapper newObjectMapper(); diff --git a/src/catalog/jackson-bindings/geoserver/src/test/java/org/geoserver/jackson/databind/config/GeoServerConfigModuleTest.java b/src/catalog/jackson-bindings/geoserver/src/test/java/org/geoserver/jackson/databind/config/GeoServerConfigModuleTest.java index c37bb11bc..46ea2e602 100644 --- a/src/catalog/jackson-bindings/geoserver/src/test/java/org/geoserver/jackson/databind/config/GeoServerConfigModuleTest.java +++ b/src/catalog/jackson-bindings/geoserver/src/test/java/org/geoserver/jackson/databind/config/GeoServerConfigModuleTest.java @@ -18,6 +18,7 @@ import org.geoserver.catalog.Info; import org.geoserver.catalog.impl.ClassMappings; import org.geoserver.catalog.plugin.CatalogPlugin; +import org.geoserver.catalog.plugin.resolving.ProxyUtils; import org.geoserver.config.ContactInfo; import org.geoserver.config.GeoServer; import org.geoserver.config.GeoServerInfo; @@ -25,7 +26,6 @@ import org.geoserver.config.plugin.GeoServerImpl; import org.geoserver.gwc.wmts.WMTSInfo; import org.geoserver.gwc.wmts.WMTSInfoImpl; -import org.geoserver.jackson.databind.catalog.ProxyUtils; import org.geoserver.ows.util.OwsUtils; import org.geoserver.platform.GeoServerExtensionsHelper; import org.geoserver.wcs.WCSInfo; @@ -34,6 +34,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.util.Optional; + /** * Verifies that all GeoServer config ({@link GeoServerInfo}, etc) object types can be sent over the * wire and parsed back using jackson, thanks to {@link GeoServerConfigModule} jackcon-databind @@ -70,7 +72,7 @@ protected void print(String logmsg, Object... args) { CatalogTestData.initialized(() -> catalog, () -> geoserver) .initConfig(false) .initialize(); - proxyResolver = new ProxyUtils(catalog, geoserver); + proxyResolver = new ProxyUtils(catalog, Optional.of(geoserver)); } private void roundtripTest(@NonNull final T orig) diff --git a/src/catalog/plugin/src/main/java/org/geoserver/catalog/plugin/Patch.java b/src/catalog/plugin/src/main/java/org/geoserver/catalog/plugin/Patch.java index 3cfca4537..a04bbb000 100644 --- a/src/catalog/plugin/src/main/java/org/geoserver/catalog/plugin/Patch.java +++ b/src/catalog/plugin/src/main/java/org/geoserver/catalog/plugin/Patch.java @@ -120,7 +120,7 @@ public Optional getValue(String propertyName) { return get(propertyName).map(Property::getValue); } - public void applyTo(Object target) { + public T applyTo(T target) { Objects.requireNonNull(target); Class targetType = target.getClass(); if (Proxy.isProxyClass(targetType)) { @@ -130,11 +130,12 @@ public void applyTo(Object target) { "Argument object is a dynamic proxy and couldn't determine it's surrogate type, use applyTo(Object, Class) instead"); } } - applyTo(target, targetType); + return applyTo(target, targetType); } - public void applyTo(Object target, Class objectType) { + public T applyTo(T target, Class objectType) { patches.forEach(p -> apply(target, objectType, p)); + return target; } @SuppressWarnings({"unchecked", "rawtypes"}) diff --git a/src/catalog/plugin/src/main/java/org/geoserver/catalog/plugin/resolving/CatalogPropertyResolver.java b/src/catalog/plugin/src/main/java/org/geoserver/catalog/plugin/resolving/CatalogPropertyResolver.java index 6a6d19f7b..4072319c3 100644 --- a/src/catalog/plugin/src/main/java/org/geoserver/catalog/plugin/resolving/CatalogPropertyResolver.java +++ b/src/catalog/plugin/src/main/java/org/geoserver/catalog/plugin/resolving/CatalogPropertyResolver.java @@ -7,9 +7,14 @@ import org.geoserver.catalog.Catalog; import org.geoserver.catalog.CatalogInfo; import org.geoserver.catalog.Info; +import org.geoserver.catalog.LayerGroupInfo; +import org.geoserver.catalog.LayerInfo; +import org.geoserver.catalog.PublishedInfo; import org.geoserver.catalog.ResourceInfo; import org.geoserver.catalog.StoreInfo; import org.geoserver.catalog.StyleInfo; +import org.geoserver.catalog.impl.LayerGroupStyle; +import org.geoserver.catalog.impl.ModificationProxy; import org.geoserver.catalog.impl.StoreInfoImpl; import org.geoserver.catalog.impl.StyleInfoImpl; import org.geoserver.catalog.plugin.forwarding.ResolvingCatalogFacadeDecorator; @@ -43,18 +48,62 @@ public static CatalogPropertyResolver of(Catalog catalog) { if (i instanceof StoreInfo) setCatalog((StoreInfo) i); else if (i instanceof ResourceInfo) setCatalog((ResourceInfo) i); else if (i instanceof StyleInfo) setCatalog((StyleInfo) i); + else if (i instanceof PublishedInfo) setCatalog((PublishedInfo) i); return i; } + private void setCatalog(PublishedInfo i) { + if (i instanceof LayerInfo) setCatalog((LayerInfo) i); + else if (i instanceof LayerGroupInfo) setCatalog((LayerGroupInfo) i); + } + + private void setCatalog(LayerInfo i) { + if (null == i) return; + ResourceInfo resource = i.getResource(); + if (null != resource) { + if (null == resource.getCatalog()) { + setCatalog(resource); + } + StoreInfo store = resource.getStore(); + if (null != store && null == store.getCatalog()) setCatalog(store); + } + setCatalog(i.getDefaultStyle()); + if (i.getStyles() != null) i.getStyles().forEach(this::setCatalog); + } + + private void setCatalog(LayerGroupInfo i) { + if (null == i) return; + if (i.getLayerGroupStyles() != null) i.getLayerGroupStyles().forEach(this::setCatalog); + + if (i.getLayers() != null) i.getLayers().forEach(this::setCatalog); + + setCatalog(i.getRootLayer()); + setCatalog(i.getRootLayerStyle()); + } + + private void setCatalog(LayerGroupStyle i) { + if (null == i) return; + + // TODO: check if this would result in a stack overflow + // if(null!=i.getLayers())i.getLayers().forEach(this::setCatalog); + // if(null != i.getStyles())i.getStyles().forEach(this::setCatalog); + } + private void setCatalog(StoreInfo i) { + if (null == i) return; + i = ModificationProxy.unwrap(i); if (i instanceof StoreInfoImpl) ((StoreInfoImpl) i).setCatalog(catalog); } private void setCatalog(ResourceInfo i) { + if (null == i) return; + i = ModificationProxy.unwrap(i); i.setCatalog(catalog); } private void setCatalog(StyleInfo i) { + if (null == i) return; + i = ModificationProxy.unwrap(i); if (i instanceof StyleInfoImpl) ((StyleInfoImpl) i).setCatalog(catalog); } } diff --git a/src/catalog/jackson-bindings/geoserver/src/main/java/org/geoserver/jackson/databind/catalog/ProxyUtils.java b/src/catalog/plugin/src/main/java/org/geoserver/catalog/plugin/resolving/ProxyUtils.java similarity index 95% rename from src/catalog/jackson-bindings/geoserver/src/main/java/org/geoserver/jackson/databind/catalog/ProxyUtils.java rename to src/catalog/plugin/src/main/java/org/geoserver/catalog/plugin/resolving/ProxyUtils.java index bb01be08b..452ebf136 100644 --- a/src/catalog/jackson-bindings/geoserver/src/main/java/org/geoserver/jackson/databind/catalog/ProxyUtils.java +++ b/src/catalog/plugin/src/main/java/org/geoserver/catalog/plugin/resolving/ProxyUtils.java @@ -2,7 +2,7 @@ * (c) 2020 Open Source Geospatial Foundation - all rights reserved This code is licensed under the * GPL 2.0 license, available at the root application directory. */ -package org.geoserver.jackson.databind.catalog; +package org.geoserver.catalog.plugin.resolving; import lombok.Getter; import lombok.NonNull; @@ -37,7 +37,6 @@ import org.geoserver.config.LoggingInfo; import org.geoserver.config.ServiceInfo; import org.geoserver.config.SettingsInfo; -import org.geoserver.jackson.databind.catalog.dto.InfoReference; import java.lang.reflect.Proxy; import java.util.HashSet; @@ -73,7 +72,7 @@ public class ProxyUtils { ServiceInfo.class); private final @NonNull @Getter Catalog catalog; - private final @NonNull @Getter GeoServer config; + private final @NonNull @Getter Optional config; private boolean failOnNotFound = false; @@ -161,10 +160,12 @@ public T resolve(final T unresolved) { T info = unwrap(unresolved); if (isResolvingProxy) { if (info instanceof CatalogInfo) info = resolve(catalog, info); - else if (info instanceof GeoServerInfo) info = (T) this.config.getGlobal(); - else if (info instanceof LoggingInfo) info = (T) this.config.getLogging(); - else if (info instanceof ServiceInfo) - info = (T) this.config.getService(info.getId(), ServiceInfo.class); + else if (info instanceof GeoServerInfo && this.config.isPresent()) + info = (T) this.config.get().getGlobal(); + else if (info instanceof LoggingInfo && this.config.isPresent()) + info = (T) this.config.get().getLogging(); + else if (info instanceof ServiceInfo && this.config.isPresent()) + info = (T) this.config.get().getService(info.getId(), ServiceInfo.class); } if (info == null) { diff --git a/src/catalog/plugin/src/main/java/org/geoserver/catalog/plugin/resolving/ResolvingProxyResolver.java b/src/catalog/plugin/src/main/java/org/geoserver/catalog/plugin/resolving/ResolvingProxyResolver.java index eb823d184..cdddb4be7 100644 --- a/src/catalog/plugin/src/main/java/org/geoserver/catalog/plugin/resolving/ResolvingProxyResolver.java +++ b/src/catalog/plugin/src/main/java/org/geoserver/catalog/plugin/resolving/ResolvingProxyResolver.java @@ -29,6 +29,7 @@ import java.util.LinkedHashSet; import java.util.Map; import java.util.NoSuchElementException; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.function.BiConsumer; import java.util.function.UnaryOperator; @@ -52,6 +53,7 @@ public class ResolvingProxyResolver implements UnaryOperator private final Catalog catalog; private final BiConsumer onNotFound; + private final ProxyUtils proxyUtils; public ResolvingProxyResolver(Catalog catalog) { this( @@ -69,6 +71,7 @@ public ResolvingProxyResolver( requireNonNull(onNotFound); this.catalog = catalog; this.onNotFound = onNotFound; + this.proxyUtils = new ProxyUtils(catalog, Optional.empty()); } public static ResolvingProxyResolver of( @@ -137,7 +140,7 @@ public I resolve(final I orig) { } protected I doResolveProxy(final I orig) { - return ResolvingProxy.resolve(catalog, orig); + return proxyUtils.resolve(orig); } protected boolean isResolvingProxy(final CatalogInfo unresolved) { @@ -204,6 +207,8 @@ protected LayerGroupInfo resolveInternal(LayerGroupInfo lg) { } } lg.setWorkspace(resolve(lg.getWorkspace())); + lg.setRootLayer(resolve(lg.getRootLayer())); + lg.setRootLayerStyle(resolve(lg.getRootLayerStyle())); return lg; } diff --git a/src/catalog/plugin/src/main/java/org/geoserver/platform/config/DefaultUpdateSequence.java b/src/catalog/plugin/src/main/java/org/geoserver/platform/config/DefaultUpdateSequence.java index 37cc987db..b75babf54 100644 --- a/src/catalog/plugin/src/main/java/org/geoserver/platform/config/DefaultUpdateSequence.java +++ b/src/catalog/plugin/src/main/java/org/geoserver/platform/config/DefaultUpdateSequence.java @@ -4,9 +4,11 @@ */ package org.geoserver.platform.config; +import lombok.NonNull; + +import org.geoserver.catalog.impl.ModificationProxy; import org.geoserver.config.GeoServer; import org.geoserver.config.GeoServerInfo; -import org.springframework.beans.factory.annotation.Autowired; import java.util.Optional; import java.util.concurrent.atomic.AtomicLong; @@ -24,7 +26,15 @@ public class DefaultUpdateSequence implements UpdateSequence { private final Lock lock = new ReentrantLock(); - private @Autowired GeoServer geoServer; + private final GeoServer geoServer; + + public DefaultUpdateSequence(@NonNull GeoServer gs) { + this.geoServer = gs; + sequence.set( + Optional.ofNullable(gs.getGlobal()) + .map(GeoServerInfo::getUpdateSequence) + .orElse(0L)); + } public @Override long currValue() { return info().map(GeoServerInfo::getUpdateSequence).orElse(0L); @@ -37,6 +47,7 @@ public class DefaultUpdateSequence implements UpdateSequence { if (global == null) return 0; long nextVal = sequence.incrementAndGet(); if (global != null) { + global = ModificationProxy.unwrap(global); global.setUpdateSequence(nextVal); } return nextVal; diff --git a/src/catalog/plugin/src/test/java/org/geoserver/catalog/plugin/CatalogConformanceTest.java b/src/catalog/plugin/src/test/java/org/geoserver/catalog/plugin/CatalogConformanceTest.java index e35366146..77c552dad 100644 --- a/src/catalog/plugin/src/test/java/org/geoserver/catalog/plugin/CatalogConformanceTest.java +++ b/src/catalog/plugin/src/test/java/org/geoserver/catalog/plugin/CatalogConformanceTest.java @@ -71,8 +71,10 @@ import org.geoserver.catalog.impl.CatalogImpl; import org.geoserver.catalog.impl.CatalogPropertyAccessor; import org.geoserver.catalog.impl.CoverageInfoImpl; +import org.geoserver.catalog.impl.ModificationProxy; import org.geoserver.catalog.impl.NamespaceInfoImpl; import org.geoserver.catalog.impl.RunnerBase; +import org.geoserver.catalog.impl.StyleInfoImpl; import org.geoserver.catalog.impl.WorkspaceInfoImpl; import org.geoserver.catalog.util.CloseableIterator; import org.geoserver.config.GeoServerDataDirectory; @@ -1539,6 +1541,10 @@ public void testGetLayerById() { assertNotSame(data.layerFeatureTypeA, l2); assertEquals(data.layerFeatureTypeA, l2); assertSame(catalog, l2.getResource().getCatalog()); + StyleInfo defaultStyle = l2.getDefaultStyle(); + defaultStyle = ModificationProxy.unwrap(defaultStyle); + if (defaultStyle instanceof StyleInfoImpl) + assertSame(catalog, ((StyleInfoImpl) defaultStyle).getCatalog()); } @Test @@ -1661,6 +1667,8 @@ public void testGetLayerByNameWithColon() { l.setDefaultStyle(data.style1); catalog.add(l); + assertEquals("foo:bar", l.getName()); + assertEquals("wsName:foo:bar", l.prefixedName()); assertNotNull(catalog.getLayerByName("foo:bar")); } @@ -2452,7 +2460,8 @@ public void testGetLayerGroupByNameWithWorkspace() { // lg is not global, but it is in the default workspace, so it should be found if we don't // specify the workspace - assertEquals(lg1, catalog.getLayerGroupByName("lg")); + LayerGroupInfo layerGroupByName = catalog.getLayerGroupByName("lg"); + assertEquals(lg1, layerGroupByName); assertEquals(lg1, catalog.getLayerGroupByName(data.workspaceA.getName(), "lg")); assertEquals(lg1, catalog.getLayerGroupByName(data.workspaceA, "lg")); @@ -2870,6 +2879,8 @@ public void testGet() { assertTrue(true); } + assertEquals( + s1.getId(), catalog.get(StyleInfo.class, equal("filename", "s1Filename")).getId()); filter = equal("defaultStyle.filename", "s1Filename"); assertEquals(l1.getId(), catalog.get(LayerInfo.class, filter).getId()); @@ -3271,8 +3282,8 @@ private void testOrderBy( CatalogPropertyAccessor pe = new CatalogPropertyAccessor(); - List props = new ArrayList(); - List actual = new ArrayList(); + List props = new ArrayList<>(); + List actual = new ArrayList<>(); String sortProperty = sortOrder.getPropertyName().getPropertyName(); for (T info : expected) { Object pval = pe.getProperty(info, sortProperty); diff --git a/src/catalog/plugin/src/test/java/org/geoserver/platform/config/DefaultUpdateSequenceTest.java b/src/catalog/plugin/src/test/java/org/geoserver/platform/config/DefaultUpdateSequenceTest.java new file mode 100644 index 000000000..9bc713998 --- /dev/null +++ b/src/catalog/plugin/src/test/java/org/geoserver/platform/config/DefaultUpdateSequenceTest.java @@ -0,0 +1,36 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.platform.config; + +import org.geoserver.config.GeoServer; +import org.geoserver.config.impl.GeoServerInfoImpl; +import org.geoserver.config.plugin.GeoServerImpl; +import org.junit.jupiter.api.BeforeEach; + +/** + * @since 1.2 + */ +class DefaultUpdateSequenceTest implements UpdateSequenceConformanceTest { + + GeoServer gs; + UpdateSequence updateSequence; + + @BeforeEach + void init() { + gs = new GeoServerImpl(); + gs.setGlobal(new GeoServerInfoImpl(gs)); + updateSequence = new DefaultUpdateSequence(gs); + } + + @Override + public UpdateSequence getUpdataSequence() { + return updateSequence; + } + + @Override + public GeoServer getGeoSever() { + return gs; + } +} diff --git a/src/catalog/plugin/src/test/java/org/geoserver/platform/config/UpdateSequenceConformanceTest.java b/src/catalog/plugin/src/test/java/org/geoserver/platform/config/UpdateSequenceConformanceTest.java new file mode 100644 index 000000000..23d8c38cf --- /dev/null +++ b/src/catalog/plugin/src/test/java/org/geoserver/platform/config/UpdateSequenceConformanceTest.java @@ -0,0 +1,53 @@ +/* + * (c) 2023 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.platform.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.geoserver.config.GeoServer; +import org.junit.jupiter.api.Test; + +import java.util.stream.IntStream; + +/** + * @since 1.2 + */ +public interface UpdateSequenceConformanceTest { + + UpdateSequence getUpdataSequence(); + + GeoServer getGeoSever(); + + default @Test void testUpdateSequence() { + UpdateSequence updateSequence = getUpdataSequence(); + GeoServer geoSever = getGeoSever(); + final long initial = updateSequence.currValue(); + long v = updateSequence.currValue(); + assertEquals(initial, v); + v = updateSequence.nextValue(); + assertEquals(1 + initial, v); + v = updateSequence.currValue(); + assertEquals(1 + initial, v); + v = updateSequence.nextValue(); + assertEquals(2 + initial, v); + v = updateSequence.currValue(); + assertEquals(2 + initial, v); + assertEquals(2 + initial, geoSever.getGlobal().getUpdateSequence()); + } + + default @Test void multiThreadedTest() { + UpdateSequence updateSequence = getUpdataSequence(); + GeoServer geoSever = getGeoSever(); + final int incrementCount = 1_000; + final long initial = updateSequence.currValue(); + final long expected = initial + incrementCount; + + IntStream.range(0, incrementCount).parallel().forEach(i -> updateSequence.nextValue()); + + long v = updateSequence.currValue(); + assertEquals(expected, v); + assertEquals(expected, geoSever.getGlobal().getUpdateSequence()); + } +} diff --git a/src/library/spring-boot-simplejndi/src/main/java/org/geoserver/cloud/config/jndidatasource/JNDIDataSourceAutoConfiguration.java b/src/library/spring-boot-simplejndi/src/main/java/org/geoserver/cloud/config/jndidatasource/JNDIDataSourceAutoConfiguration.java index ddc18b1f3..5fc0f0c39 100644 --- a/src/library/spring-boot-simplejndi/src/main/java/org/geoserver/cloud/config/jndidatasource/JNDIDataSourceAutoConfiguration.java +++ b/src/library/spring-boot-simplejndi/src/main/java/org/geoserver/cloud/config/jndidatasource/JNDIDataSourceAutoConfiguration.java @@ -4,116 +4,18 @@ */ package org.geoserver.cloud.config.jndidatasource; -import com.zaxxer.hikari.HikariDataSource; - -import lombok.extern.slf4j.Slf4j; - -import org.springframework.beans.factory.InitializingBean; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.ApplicationContextException; -import org.springframework.jdbc.support.DatabaseStartupValidator; - -import java.util.Map; - -import javax.naming.Context; -import javax.naming.NamingException; -import javax.naming.spi.NamingManager; -import javax.sql.DataSource; +import org.springframework.context.annotation.Bean; /** * @since 1.0 */ @AutoConfiguration @EnableConfigurationProperties(JNDIDataSourcesConfigurationProperties.class) -@Slf4j(topic = "org.geoserver.cloud.config.jndidatasource") -public class JNDIDataSourceAutoConfiguration implements InitializingBean { - - private @Autowired JNDIDataSourcesConfigurationProperties config; - - @Override - public void afterPropertiesSet() throws Exception { - - Map configs = config.getDatasources(); - - if (null == configs || configs.isEmpty()) { - log.info("No JNDI datasources configured"); - return; - } - - configs.entrySet() - .forEach(e -> setUpDataSource(toJndiDatasourceName(e.getKey()), e.getValue())); - } - - String toJndiDatasourceName(String dsname) { - final String prefix = "java:comp/env/jdbc/"; - if (!dsname.startsWith(prefix)) { - if (dsname.contains("/")) { - throw new IllegalArgumentException( - "The datasource name '" - + dsname - + "' is invalid. Provide either a simple name, or a full name like java:comp/env/jdbc/mydatasource"); - } - return prefix + dsname; - } - return dsname; - } - - void setUpDataSource(String jndiName, JNDIDatasourceConfig props) { - if (props.isEnabled()) { - log.info("Creating JNDI datasoruce " + jndiName + " on " + props.getUrl()); - } else { - log.info("Ignoring disabled JNDI datasource " + jndiName); - return; - } - - Context initialContext; - try { - initialContext = NamingManager.getInitialContext(null); - } catch (NamingException e) { - throw new ApplicationContextException("No JNDI initial context bound", e); - } - - DataSource dataSource = createDataSource(props); - waitForIt(jndiName, dataSource, props); - try { - initialContext.bind(jndiName, dataSource); - log.info( - "Bound JNDI datasource {}: url: {}, user: {}, max size: {}, min size: {}, connection timeout: {}, idle timeout: {}", - jndiName, - props.getUrl(), - props.getUsername(), - props.getMaximumPoolSize(), - props.getMinimumIdle(), - props.getConnectionTimeout(), - props.getIdleTimeout()); - } catch (NamingException e) { - throw new ApplicationContextException("Error binding JNDI datasource " + jndiName, e); - } - } - - private void waitForIt(String jndiName, DataSource dataSource, JNDIDatasourceConfig props) { - if (props.isWaitForIt()) { - log.info( - "Waiting up to {} seconds for datasource {}", props.getWaitTimeout(), jndiName); - DatabaseStartupValidator validator = new DatabaseStartupValidator(); - validator.setDataSource(dataSource); - validator.setTimeout(props.getWaitTimeout()); - validator.afterPropertiesSet(); - } - } - - protected DataSource createDataSource(JNDIDatasourceConfig props) { - HikariDataSource dataSource = - props.initializeDataSourceBuilder() // - .type(HikariDataSource.class) - .build(); - - dataSource.setMaximumPoolSize(props.getMaximumPoolSize()); - dataSource.setMinimumIdle(props.getMinimumIdle()); - dataSource.setConnectionTimeout(props.getConnectionTimeout()); - dataSource.setIdleTimeout(props.getIdleTimeout()); - return dataSource; +public class JNDIDataSourceAutoConfiguration { + @Bean + JNDIInitializer jndiInitializer(JNDIDataSourcesConfigurationProperties config) { + return new JNDIInitializer(config); } } diff --git a/src/library/spring-boot-simplejndi/src/main/java/org/geoserver/cloud/config/jndidatasource/JNDIDatasourceConfig.java b/src/library/spring-boot-simplejndi/src/main/java/org/geoserver/cloud/config/jndidatasource/JNDIDatasourceConfig.java index 23804508a..f7b2f4429 100644 --- a/src/library/spring-boot-simplejndi/src/main/java/org/geoserver/cloud/config/jndidatasource/JNDIDatasourceConfig.java +++ b/src/library/spring-boot-simplejndi/src/main/java/org/geoserver/cloud/config/jndidatasource/JNDIDatasourceConfig.java @@ -23,4 +23,9 @@ public class JNDIDatasourceConfig extends DataSourceProperties { int maximumPoolSize = 10; long connectionTimeout = 250; // ms long idleTimeout = 60_000; // ms + + /** + * @since 1.3 + */ + String schema; } diff --git a/src/library/spring-boot-simplejndi/src/main/java/org/geoserver/cloud/config/jndidatasource/JNDIInitializer.java b/src/library/spring-boot-simplejndi/src/main/java/org/geoserver/cloud/config/jndidatasource/JNDIInitializer.java new file mode 100644 index 000000000..81207d77e --- /dev/null +++ b/src/library/spring-boot-simplejndi/src/main/java/org/geoserver/cloud/config/jndidatasource/JNDIInitializer.java @@ -0,0 +1,120 @@ +/* + * (c) 2022 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.config.jndidatasource; + +import com.zaxxer.hikari.HikariDataSource; + +import lombok.extern.slf4j.Slf4j; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ApplicationContextException; +import org.springframework.jdbc.support.DatabaseStartupValidator; +import org.springframework.util.StringUtils; + +import java.util.Map; + +import javax.naming.Context; +import javax.naming.NamingException; +import javax.naming.spi.NamingManager; +import javax.sql.DataSource; + +/** + * @since 1.0 + */ +@Slf4j(topic = "org.geoserver.cloud.config.jndidatasource") +public class JNDIInitializer implements InitializingBean { + + private JNDIDataSourcesConfigurationProperties config; + + JNDIInitializer(JNDIDataSourcesConfigurationProperties config) { + this.config = config; + } + + @Override + public void afterPropertiesSet() throws Exception { + + Map configs = config.getDatasources(); + + if (null == configs || configs.isEmpty()) { + log.info("No JNDI datasources configured"); + return; + } + + configs.entrySet() + .forEach(e -> setUpDataSource(toJndiDatasourceName(e.getKey()), e.getValue())); + } + + String toJndiDatasourceName(String dsname) { + final String prefix = "java:comp/env/jdbc/"; + if (!dsname.startsWith(prefix)) { + if (dsname.contains("/")) { + throw new IllegalArgumentException( + "The datasource name '" + + dsname + + "' is invalid. Provide either a simple name, or a full name like java:comp/env/jdbc/mydatasource"); + } + return prefix + dsname; + } + return dsname; + } + + void setUpDataSource(String jndiName, JNDIDatasourceConfig props) { + if (props.isEnabled()) { + log.info("Creating JNDI datasoruce " + jndiName + " on " + props.getUrl()); + } else { + log.info("Ignoring disabled JNDI datasource " + jndiName); + return; + } + + Context initialContext; + try { + initialContext = NamingManager.getInitialContext(null); + } catch (NamingException e) { + throw new ApplicationContextException("No JNDI initial context bound", e); + } + + DataSource dataSource = createDataSource(props); + waitForIt(jndiName, dataSource, props); + try { + initialContext.bind(jndiName, dataSource); + log.info( + "Bound JNDI datasource {}: url: {}, user: {}, max size: {}, min size: {}, connection timeout: {}, idle timeout: {}", + jndiName, + props.getUrl(), + props.getUsername(), + props.getMaximumPoolSize(), + props.getMinimumIdle(), + props.getConnectionTimeout(), + props.getIdleTimeout()); + } catch (NamingException e) { + throw new ApplicationContextException("Error binding JNDI datasource " + jndiName, e); + } + } + + private void waitForIt(String jndiName, DataSource dataSource, JNDIDatasourceConfig props) { + if (props.isWaitForIt()) { + log.info( + "Waiting up to {} seconds for datasource {}", props.getWaitTimeout(), jndiName); + DatabaseStartupValidator validator = new DatabaseStartupValidator(); + validator.setDataSource(dataSource); + validator.setTimeout(props.getWaitTimeout()); + validator.afterPropertiesSet(); + } + } + + protected DataSource createDataSource(JNDIDatasourceConfig props) { + HikariDataSource dataSource = + props.initializeDataSourceBuilder() // + .type(HikariDataSource.class) + .build(); + + dataSource.setMaximumPoolSize(props.getMaximumPoolSize()); + dataSource.setMinimumIdle(props.getMinimumIdle()); + dataSource.setConnectionTimeout(props.getConnectionTimeout()); + dataSource.setIdleTimeout(props.getIdleTimeout()); + if (StringUtils.hasLength(props.getSchema())) dataSource.setSchema(props.getSchema()); + return dataSource; + } +} diff --git a/src/pom.xml b/src/pom.xml index 26e82655f..02cfab292 100644 --- a/src/pom.xml +++ b/src/pom.xml @@ -35,6 +35,8 @@ 4.1.41.Final 1.18.24 1.4.2.Final + 9.19.4 + 1.18.3 true 256M @@ -96,6 +98,13 @@ pom import + + org.testcontainers + testcontainers-bom + ${testcontainers.version} + pom + import + org.geoserver.acl gs-acl-bom @@ -779,6 +788,11 @@ mapstruct ${mapstruct.version} + + org.flywaydb + flyway-core + ${flyway.version} + diff --git a/src/starters/catalog-backend/pom.xml b/src/starters/catalog-backend/pom.xml index c2fb46c45..2b855c08c 100644 --- a/src/starters/catalog-backend/pom.xml +++ b/src/starters/catalog-backend/pom.xml @@ -18,6 +18,10 @@ org.geoserver.cloud.catalog.backend gs-cloud-catalog-backend-jdbcconfig + + org.geoserver.cloud.catalog.backend + gs-cloud-catalog-backend-pgsql + org.geoserver.cloud.catalog gs-cloud-catalog-cache