diff --git a/src/services/ogc-features/src/main/java/com/camptocamp/opendata/ogc/features/autoconfigure/geotools/PostgisBackendAutoConfiguration.java b/src/services/ogc-features/src/main/java/com/camptocamp/opendata/ogc/features/autoconfigure/geotools/PostgisBackendAutoConfiguration.java index b6603a6..f95d87f 100644 --- a/src/services/ogc-features/src/main/java/com/camptocamp/opendata/ogc/features/autoconfigure/geotools/PostgisBackendAutoConfiguration.java +++ b/src/services/ogc-features/src/main/java/com/camptocamp/opendata/ogc/features/autoconfigure/geotools/PostgisBackendAutoConfiguration.java @@ -1,13 +1,15 @@ package com.camptocamp.opendata.ogc.features.autoconfigure.geotools; import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.HashMap; import java.util.Map; import javax.sql.DataSource; import org.geotools.api.data.DataStore; import org.geotools.data.postgis.PostgisNGDataStoreFactory; -import org.springframework.beans.factory.annotation.Qualifier; +import org.geotools.jdbc.JDBCDataStore; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.context.annotation.Bean; @@ -18,22 +20,26 @@ import com.camptocamp.opendata.ogc.features.repository.CollectionRepository; import com.camptocamp.opendata.ogc.features.repository.DataStoreCollectionRepository; +import com.camptocamp.opendata.ogc.features.repository.DataStoreProvider; +import com.camptocamp.opendata.ogc.features.repository.DefaultDataStoreProvider; import com.camptocamp.opendata.producer.geotools.FeatureToRecord; +import lombok.NonNull; + @AutoConfiguration @Profile("postgis") public class PostgisBackendAutoConfiguration implements WebMvcConfigurer { @Bean - CollectionRepository postgisDataStoreCollectionRepository(@Qualifier("indexDataStore") DataStore indexStore) { - return new DataStoreCollectionRepository(indexStore, new FeatureToRecord()); + CollectionRepository postgisDataStoreCollectionRepository(DataStoreProvider dsProvider) { + return new DataStoreCollectionRepository(dsProvider, new FeatureToRecord()); } @Bean(name = "indexDataStore") @DependsOn("databaseStartupValidator") - DataStore postgisDataStore(DataSource dataSource, @Value("${pg.schema:opendataindex}") String schema) + DataStoreProvider postgisDataStore(DataSource dataSource, @Value("${pg.schema:opendataindex}") String schema) throws IOException { - Map params = Map.of(// + Map params = Map.of(// PostgisNGDataStoreFactory.DBTYPE.key, "postgis", // PostgisNGDataStoreFactory.DATASOURCE.key, dataSource, // PostgisNGDataStoreFactory.SCHEMA.key, schema, // @@ -43,8 +49,7 @@ DataStore postgisDataStore(DataSource dataSource, @Value("${pg.schema:opendatain PostgisNGDataStoreFactory.LOOSEBBOX.key, true// ); - PostgisNGDataStoreFactory fac = new PostgisNGDataStoreFactory(); - return fac.createDataStore(params); + return new PostgisDataStoreProvider(params); } @Bean @@ -53,4 +58,32 @@ DatabaseStartupValidator databaseStartupValidator(DataSource dataSource) { dsv.setDataSource(dataSource); return dsv; } + + public static class PostgisDataStoreProvider extends DefaultDataStoreProvider { + + public PostgisDataStoreProvider(@NonNull Map connectionParams) { + super(new HashMap<>(connectionParams)); + } + + public DataSource getDataSource() { + return (DataSource) super.connectionParams.get(PostgisNGDataStoreFactory.DATASOURCE.key); + } + + public void setDataSource(DataSource ds) { + super.connectionParams.put(PostgisNGDataStoreFactory.DATASOURCE.key, ds); + if (null != super.store) { + ((JDBCDataStore) super.store).setDataSource(ds); + } + } + + @Override + protected @NonNull DataStore create() { + PostgisNGDataStoreFactory fac = new PostgisNGDataStoreFactory(); + try { + return fac.createDataStore(connectionParams); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + } } diff --git a/src/services/ogc-features/src/main/java/com/camptocamp/opendata/ogc/features/autoconfigure/geotools/SampleDataBackendAutoConfiguration.java b/src/services/ogc-features/src/main/java/com/camptocamp/opendata/ogc/features/autoconfigure/geotools/SampleDataBackendAutoConfiguration.java index d5c0aca..b062e85 100644 --- a/src/services/ogc-features/src/main/java/com/camptocamp/opendata/ogc/features/autoconfigure/geotools/SampleDataBackendAutoConfiguration.java +++ b/src/services/ogc-features/src/main/java/com/camptocamp/opendata/ogc/features/autoconfigure/geotools/SampleDataBackendAutoConfiguration.java @@ -3,6 +3,7 @@ import java.io.File; import java.io.FileOutputStream; import java.io.IOException; +import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Map; @@ -19,7 +20,6 @@ import org.geotools.geopkg.GeoPkgDataStoreFactory; import org.geotools.jdbc.JDBCDataStore; import org.springframework.beans.factory.DisposableBean; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Profile; @@ -28,10 +28,10 @@ import com.camptocamp.opendata.ogc.features.repository.CollectionRepository; import com.camptocamp.opendata.ogc.features.repository.DataStoreCollectionRepository; +import com.camptocamp.opendata.ogc.features.repository.DataStoreProvider; import com.camptocamp.opendata.producer.geotools.FeatureToRecord; import com.google.common.io.ByteStreams; -import lombok.Getter; import lombok.extern.slf4j.Slf4j; /** @@ -47,19 +47,9 @@ @Slf4j public class SampleDataBackendAutoConfiguration { -// @Bean -// IndexedReader sampleDataIndexedReader(@NonNull GeoToolsDataReader gtReader) throws IOException { -// return new SampleDataReader(gtReader); -// } - @Bean - CollectionRepository sampleDataDataStoreCollectionRepository(@Qualifier("indexDataStore") DataStore indexStore) { - return new DataStoreCollectionRepository(indexStore, new FeatureToRecord()); - } - - @Bean - DataStore indexDataStore(SampleData sampleData) throws IOException { - return sampleData.getDataStore(); + CollectionRepository sampleDataDataStoreCollectionRepository(SampleData dsProvider) { + return new DataStoreCollectionRepository(dsProvider, new FeatureToRecord()); } @Bean @@ -72,19 +62,47 @@ SampleData sampleData() throws IOException { * shutdown, since the geotools CSV datastore does not support URL resources, * only Files */ - private static class SampleData implements DisposableBean { + private static class SampleData implements DataStoreProvider, DisposableBean { + + private Path tempDirectory; + + private DataStore dataStore; + + @Override + public DataStore get() { + if (null == dataStore) { + synchronized (this) { + if (null == dataStore) { + try { + create(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + } + } + return dataStore; + } - private Path tempDirectory = Files.createTempDirectory("ogc-features-sample-data"); - private final @Getter DataStore dataStore; + @Override + public void reInit() { + try { + dispose(); + create(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } - SampleData() throws IOException { + private void create() throws IOException { + tempDirectory = Files.createTempDirectory("ogc-features-sample-data"); dataStore = new MemoryDataStore(); log.info("Extracting sample data to {}", tempDirectory); File sd = copyToTempDir("sample-datasets.gpkg"); final GeoPkgDataStoreFactory factory = new GeoPkgDataStoreFactory(); - final Map params = Map.of(// + Map params = Map.of(// GeoPkgDataStoreFactory.DBTYPE.key, "geopkg", // GeoPkgDataStoreFactory.DATABASE.key, sd, // // Whether to return only tables listed as features in gpkg_contents, or give @@ -113,10 +131,18 @@ private static class SampleData implements DisposableBean { @Override public void destroy() throws Exception { - dataStore.dispose(); - if (tempDirectory != null && Files.isDirectory(tempDirectory)) { - log.info("Deleting sample data directory {}", tempDirectory); - FileSystemUtils.deleteRecursively(tempDirectory); + dispose(); + } + + private void dispose() throws IOException { + if (null != dataStore) { + dataStore.dispose(); + if (tempDirectory != null && Files.isDirectory(tempDirectory)) { + log.info("Deleting sample data directory {}", tempDirectory); + FileSystemUtils.deleteRecursively(tempDirectory); + } + dataStore = null; + tempDirectory = null; } } diff --git a/src/services/ogc-features/src/main/java/com/camptocamp/opendata/ogc/features/model/GeoToolsFeatureCollection.java b/src/services/ogc-features/src/main/java/com/camptocamp/opendata/ogc/features/model/GeoToolsFeatureCollection.java index a892693..39cbde8 100644 --- a/src/services/ogc-features/src/main/java/com/camptocamp/opendata/ogc/features/model/GeoToolsFeatureCollection.java +++ b/src/services/ogc-features/src/main/java/com/camptocamp/opendata/ogc/features/model/GeoToolsFeatureCollection.java @@ -14,6 +14,7 @@ import org.geotools.data.DataUtilities; import org.geotools.data.simple.SimpleFeatureCollection; import org.geotools.data.simple.SimpleFeatureIterator; +import org.geotools.jdbc.JDBCDataStore; import com.camptocamp.opendata.model.GeodataRecord; import com.camptocamp.opendata.producer.geotools.FeatureToRecord; diff --git a/src/services/ogc-features/src/main/java/com/camptocamp/opendata/ogc/features/repository/DataStoreCollectionRepository.java b/src/services/ogc-features/src/main/java/com/camptocamp/opendata/ogc/features/repository/DataStoreCollectionRepository.java index 2bf2034..506d6f2 100644 --- a/src/services/ogc-features/src/main/java/com/camptocamp/opendata/ogc/features/repository/DataStoreCollectionRepository.java +++ b/src/services/ogc-features/src/main/java/com/camptocamp/opendata/ogc/features/repository/DataStoreCollectionRepository.java @@ -6,6 +6,7 @@ import java.util.Arrays; import java.util.List; import java.util.Optional; +import java.util.concurrent.Callable; import java.util.function.Function; import org.geotools.api.data.DataStore; @@ -31,20 +32,25 @@ import lombok.NonNull; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; @RequiredArgsConstructor +@Slf4j public class DataStoreCollectionRepository implements CollectionRepository { - // private final @NonNull Producers producers; - private final @NonNull DataStore dataStore; + private final @NonNull DataStoreProvider dataStoreProvider; private final @NonNull Function featureMapper; private final FilterFactory ff = CommonFactoryFinder.getFilterFactory(); + private DataStore dataStore() { + return dataStoreProvider.get(); + } + @Override public List getCollections() { try { - String[] typeNames = dataStore.getTypeNames(); + String[] typeNames = dataStore().getTypeNames(); return Arrays.stream(typeNames).map(this::loadCollection).toList(); } catch (IOException e) { throw new UncheckedIOException(e); @@ -62,34 +68,72 @@ public Optional findCollection(String collectionId) { @Override public Optional getRecord(String collectionId, String featureId) { - SimpleFeature feature; - Query query = new Query(collectionId, ff.id(ff.featureId(featureId))); - SimpleFeatureCollection features = query(query); - try (SimpleFeatureIterator it = features.features()) { - feature = it.hasNext() ? it.next() : null; - } + final Query query = new Query(collectionId, ff.id(ff.featureId(featureId))); + + return runWithRetry("getRecord(%s:%s)".formatted(collectionId, featureId), () -> { + SimpleFeatureCollection features = query(query); + SimpleFeature feature; + try (SimpleFeatureIterator it = features.features()) { + feature = it.hasNext() ? it.next() : null; + } - return Optional.ofNullable(feature).map(featureMapper); + return Optional.ofNullable(feature).map(featureMapper); + }); + } + + private T runWithRetry(String description, Callable command) { + try { + return command.call(); + } catch (Exception e) { + log.info("Retrying command %s".formatted(description)); + dataStoreProvider.reInit(); + try { + return command.call(); + } catch (Exception e2) { + log.info("Retry command failed. Giving up for %s".formatted(description)); + if (e2 instanceof RuntimeException) { + throw (RuntimeException) e2; + } + throw new RuntimeException(e); + } + } } @Override public FeatureCollection query(@NonNull DataQuery query) { - Collection collection = findCollection(query.getLayerName()).orElseThrow(); - Query gtQuery = toQuery(query); - SimpleFeatureCollection features = query(gtQuery); - - long matched = count(toQuery(query.withLimit(null))); - long returned = count(gtQuery); - GeoToolsFeatureCollection ret = new GeoToolsFeatureCollection(collection, features); - ret.setNumberMatched(matched); - ret.setNumberReturned(returned); - return ret; + final Collection collection = findCollection(query.getLayerName()).orElseThrow(); + final Query gtQuery = toQuery(query); + return runWithRetry("query(%s)".formatted(query.getLayerName()), () -> { + ensureSchemaIsInSync(gtQuery); + SimpleFeatureCollection fc = query(gtQuery); + long matched = count(toQuery(query.withLimit(null))); + long returned = count(gtQuery); + GeoToolsFeatureCollection ret = new GeoToolsFeatureCollection(collection, fc); + ret.setNumberMatched(matched); + ret.setNumberReturned(returned); + return ret; + }); + } + + /** + * Workaround to make sure the datastore cached featuretype is in sync with the + * one in the database in case it has changed under the hood + */ + private void ensureSchemaIsInSync(Query gtQuery) { + Query noopQuery = new Query(gtQuery); + noopQuery.setMaxFeatures(0); + SimpleFeatureCollection fc = query(noopQuery); + try (SimpleFeatureIterator it = fc.features()) { + + } catch (RuntimeException e) { + throw e; + } } private int count(Query query) { try { - return dataStore.getFeatureSource(query.getTypeName()).getCount(query); + return dataStore().getFeatureSource(query.getTypeName()).getCount(query); } catch (IOException e) { throw new UncheckedIOException(e); } @@ -114,7 +158,7 @@ private Query toQuery(@NonNull DataQuery query) { private SimpleFeatureCollection query(Query query) { try { String typeName = query.getTypeName(); - SimpleFeatureSource featureSource = dataStore.getFeatureSource(typeName); + SimpleFeatureSource featureSource = dataStore().getFeatureSource(typeName); return featureSource.getFeatures(query); } catch (IOException e) { throw new UncheckedIOException(e); @@ -126,7 +170,7 @@ private Collection loadCollection(String typeName) { c.setTitle(typeName); SimpleFeatureType schema; try { - schema = dataStore.getSchema(typeName); + schema = dataStore().getSchema(typeName); } catch (IOException e) { throw new UncheckedIOException(e); } diff --git a/src/services/ogc-features/src/main/java/com/camptocamp/opendata/ogc/features/repository/DataStoreProvider.java b/src/services/ogc-features/src/main/java/com/camptocamp/opendata/ogc/features/repository/DataStoreProvider.java new file mode 100644 index 0000000..f95103f --- /dev/null +++ b/src/services/ogc-features/src/main/java/com/camptocamp/opendata/ogc/features/repository/DataStoreProvider.java @@ -0,0 +1,10 @@ +package com.camptocamp.opendata.ogc.features.repository; + +import org.geotools.api.data.DataStore; + +public interface DataStoreProvider { + + DataStore get(); + + public void reInit(); +} \ No newline at end of file diff --git a/src/services/ogc-features/src/main/java/com/camptocamp/opendata/ogc/features/repository/DefaultDataStoreProvider.java b/src/services/ogc-features/src/main/java/com/camptocamp/opendata/ogc/features/repository/DefaultDataStoreProvider.java new file mode 100644 index 0000000..762ce32 --- /dev/null +++ b/src/services/ogc-features/src/main/java/com/camptocamp/opendata/ogc/features/repository/DefaultDataStoreProvider.java @@ -0,0 +1,83 @@ +package com.camptocamp.opendata.ogc.features.repository; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +import org.geotools.api.data.DataStore; +import org.geotools.api.data.DataStoreFinder; +import org.springframework.beans.factory.DisposableBean; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class DefaultDataStoreProvider implements DataStoreProvider, DisposableBean { + + protected final @NonNull Map connectionParams; + + protected final ReadWriteLock lock = new ReentrantReadWriteLock(); + + protected DataStore store; + + @Override + public DataStore get() { + DataStore ds = store; + lock.readLock().lock(); + if (ds == null) { + lock.readLock().unlock(); + lock.writeLock().lock(); + try { + ds = store; + if (ds == null) { + ds = create(); + store = ds; + } + lock.readLock().lock();// downgrade + } finally { + lock.writeLock().unlock(); + } + } + try { + return ds; + } finally { + lock.readLock().unlock(); + } + } + + protected @NonNull DataStore create() { + try { + return Objects.requireNonNull(DataStoreFinder.getDataStore(connectionParams), + "Unable to find datastore with the provided connection parameters"); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + public void reInit() { + lock.writeLock().lock(); + try { + destroy(); + store = create(); + } finally { + lock.writeLock().unlock(); + } + } + + @Override + public void destroy() { + lock.writeLock().lock(); + try { + if (store != null) { + store.dispose(); + store = null; + } + } finally { + lock.writeLock().unlock(); + } + } + +} diff --git a/src/services/ogc-features/src/main/resources/application.yml b/src/services/ogc-features/src/main/resources/application.yml index b285ec2..5771082 100644 --- a/src/services/ogc-features/src/main/resources/application.yml +++ b/src/services/ogc-features/src/main/resources/application.yml @@ -30,4 +30,6 @@ spring: hikari: maximum-pool-size: ${postgres.pool.maxsize:20} minimum-idle: ${postgres.pool.minsize:0} + max-lifetime: 1000 + diff --git a/src/services/ogc-features/src/test/java/com/camptocamp/opendata/ogc/features/server/impl/CollectionsApiImplPostgisIT.java b/src/services/ogc-features/src/test/java/com/camptocamp/opendata/ogc/features/server/impl/CollectionsApiImplPostgisIT.java index 1425e60..55ef6df 100644 --- a/src/services/ogc-features/src/test/java/com/camptocamp/opendata/ogc/features/server/impl/CollectionsApiImplPostgisIT.java +++ b/src/services/ogc-features/src/test/java/com/camptocamp/opendata/ogc/features/server/impl/CollectionsApiImplPostgisIT.java @@ -7,11 +7,25 @@ import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Set; +import java.util.stream.Collectors; + +import javax.sql.DataSource; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; import org.junit.jupiter.api.io.TempDir; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; @@ -19,7 +33,13 @@ import org.testcontainers.containers.PostgisContainerProvider; import org.testcontainers.junit.jupiter.Testcontainers; +import com.camptocamp.opendata.model.GeodataRecord; import com.camptocamp.opendata.ogc.features.app.OgcFeaturesApp; +import com.camptocamp.opendata.ogc.features.autoconfigure.geotools.PostgisBackendAutoConfiguration.PostgisDataStoreProvider; +import com.camptocamp.opendata.ogc.features.model.Collection; +import com.camptocamp.opendata.ogc.features.model.FeatureCollection; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; @SpringBootTest(classes = OgcFeaturesApp.class) @ActiveProfiles("postgis") @@ -29,6 +49,11 @@ public class CollectionsApiImplPostgisIT extends AbstractCollectionsApiImplIT { public static JdbcDatabaseContainer postgis; + private @Autowired PostgisDataStoreProvider pgDataStoreProvider; + + private final Set defaultTables = Set.of("locations", "ouvrages-acquis-par-les-mediatheques", + "base-sirene-v3", "comptages-velo"); + static @BeforeAll void setUp(@TempDir Path tmpdir) throws IOException { final String initScriptHostPath = copyInitScript(tmpdir); @@ -42,6 +67,12 @@ public class CollectionsApiImplPostgisIT extends AbstractCollectionsApiImplIT { postgis.start(); } + @BeforeEach + void beforeEeach() { + reinitDataSource(); + pgDataStoreProvider.reInit(); + } + private static String copyInitScript(Path tmpdir) throws IOException { URL resource = CollectionsApiImplPostgisIT.class.getResource("/test-data/postgis/opendataindex.sql"); assertThat(resource).isNotNull(); @@ -60,5 +91,197 @@ static void postgisProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgis::getJdbcUrl); registry.add("spring.datasource.username", postgis::getUsername); registry.add("spring.datasource.password", postgis::getPassword); + registry.add("spring.datasource.hikari.max-lifetime", () -> 1000); + } + + @Test + public void testGetCollections_sees_new_table(TestInfo testInfo) throws SQLException { + final String table = testInfo.getDisplayName(); + + Set collections = getCollectionNames(); + + assertThat(collections).doesNotContain(table); + assertThat(collections).containsAll(defaultTables); + + createTestTable(table); + + collections = getCollectionNames(); + + assertThat(collections).contains(table); + assertThat(collections).containsAll(defaultTables); + } + + private Set getCollectionNames() { + return collectionsApi.getCollections().getBody().getCollections().stream().map(Collection::getTitle) + .collect(Collectors.toSet()); + } + + @Test + public void testGetCollections_survives_schema_change() throws SQLException { + final String table = "locations"; + + Set collections = getCollectionNames(); + assertThat(collections).contains(table); + + renameColumn(table, "city", "ciudad"); + + collections = getCollectionNames(); + assertThat(collections).contains(table); + + dropColumn(table, "ciudad"); + + collections = getCollectionNames(); + assertThat(collections).contains(table); + } + + @Test + public void testGetCollections_survives_table_rename(TestInfo testInfo) throws SQLException { + final String table = testInfo.getDisplayName(); + final String newName = table + "_renamed"; + createTestTable(table); + + Set collections = getCollectionNames(); + assertThat(collections).contains(table); + + renameTable(table, newName); + + collections = getCollectionNames(); + assertThat(collections).doesNotContain(table); + assertThat(collections).contains(newName); + } + + @Test + public void testGetCollections_survives_drop_table(TestInfo testInfo) throws SQLException { + final String table = testInfo.getDisplayName(); + createTestTable(table); + pgDataStoreProvider.reInit(); + + var collections = getCollectionNames(); + + assertThat(collections).contains(table); + assertThat(collections).containsAll(defaultTables); + + dropTable(table); + + collections = getCollectionNames(); + assertThat(collections).doesNotContain(table); + assertThat(collections).containsAll(defaultTables); + } + + @Test + public void testGetItems_survives_schema_change() throws SQLException { + MockHttpServletRequest actualRequest = (MockHttpServletRequest) req.getNativeRequest(); + actualRequest.addHeader("Accept", "application/json"); + + ResponseEntity response = collectionsApi.getFeatures("locations", 10, null, null, null); + assertThat(response.getBody().getFeatures().toList().size()).isEqualTo(10); + + renameColumn("locations", "year", "año"); + + response = collectionsApi.getFeatures("locations", 10, null, null, null); + assertThat(response.getBody().getFeatures().toList().size()).isEqualTo(10); + + dropColumn("locations", "año"); + + response = collectionsApi.getFeatures("locations", 10, null, null, null); + assertThat(response.getBody().getFeatures().toList().size()).isEqualTo(10); + } + + @Test + public void testGetItem_survives_schema_change() throws SQLException { + MockHttpServletRequest actualRequest = (MockHttpServletRequest) req.getNativeRequest(); + actualRequest.addHeader("Accept", "application/json"); + + GeodataRecord before = collectionsApi.getFeatures("locations", 1, null, null, null).getBody().getFeatures() + .toList().get(0); + assertThat(before.getProperty("number")).isPresent(); + + final String id = before.getId(); + + renameColumn("locations", "number", "número"); + + GeodataRecord after = collectionsApi.getFeature("locations", id).getBody(); + assertThat(after.getProperty("number")).isEmpty(); + assertThat(after.getProperty("número")).isPresent(); + + dropColumn("locations", "número"); + + after = collectionsApi.getFeature("locations", id).getBody(); + assertThat(after.getProperty("número")).isEmpty(); + } + + private void dropTable(String name) throws SQLException { + alterDatabase(""" + DROP TABLE opendataindex."%s" + """.formatted(name)); + } + + private void renameTable(String table, String as) throws SQLException { + alterDatabase(""" + ALTER TABLE opendataindex."%s" RENAME TO "%s" + """.formatted(table, as)); + } + + private void renameColumn(String table, String from, String to) throws SQLException { + alterDatabase(""" + ALTER TABLE opendataindex."%s" RENAME COLUMN "%s" TO "%s" + """.formatted(table, from, to)); + } + + private void dropColumn(String table, String col) throws SQLException { + alterDatabase(""" + ALTER TABLE opendataindex.%s DROP COLUMN "%s" + """.formatted(table, col)); + } + + private void createTestTable(String tableName) throws SQLException { + alterDatabase(""" + CREATE TABLE opendataindex."%s" (id BIGINT, name TEXT) + """.formatted(tableName)); + } + + private void alterDatabase(String ddl) throws SQLException { + // ALTER TABLE needs to grab an exclusive lock on the table, which open + // connections prevent. close the datasource then + closeDataSource(); + + Connection c = DriverManager.getConnection(postgis.getJdbcUrl(), postgis.getUsername(), postgis.getPassword()); + try (Statement st = c.createStatement()) { + c.setAutoCommit(false); + try { + st.execute(ddl); + c.commit(); + } catch (SQLException e) { + c.rollback(); + throw e; + } finally { + c.setAutoCommit(true); + } + } finally { + c.close(); + } + + // replace the datasource used by the DataStore, but do not dispose the + // datastore + resetDataSource(); + } + + private void reinitDataSource() { + closeDataSource(); + resetDataSource(); + } + + private void closeDataSource() { + HikariDataSource hikariDs = (HikariDataSource) pgDataStoreProvider.getDataSource(); + hikariDs.close(); + } + + private void resetDataSource() { + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(postgis.getJdbcUrl()); + config.setUsername(postgis.getUsername()); + config.setPassword(postgis.getPassword()); + DataSource newDataSource = new HikariDataSource(config); + pgDataStoreProvider.setDataSource(newDataSource); } } diff --git a/src/services/ogc-features/src/test/resources/application.yml b/src/services/ogc-features/src/test/resources/application.yml index cb95f7c..e0d2cf3 100644 --- a/src/services/ogc-features/src/test/resources/application.yml +++ b/src/services/ogc-features/src/test/resources/application.yml @@ -3,4 +3,5 @@ logging: level: root: INFO com.zaxxer.hikari.pool.HikariPool: OFF + com.zaxxer.hikari.HikariDataSource: OFF # org.geotools: DEBUG