diff --git a/geowebcache/diskquota/jdbc/src/main/java/org/geowebcache/diskquota/jdbc/HSQLDialect.java b/geowebcache/diskquota/jdbc/src/main/java/org/geowebcache/diskquota/jdbc/HSQLDialect.java index 41cf8cd18..3a83313fb 100644 --- a/geowebcache/diskquota/jdbc/src/main/java/org/geowebcache/diskquota/jdbc/HSQLDialect.java +++ b/geowebcache/diskquota/jdbc/src/main/java/org/geowebcache/diskquota/jdbc/HSQLDialect.java @@ -13,7 +13,7 @@ */ package org.geowebcache.diskquota.jdbc; -import java.util.Arrays; +import java.util.List; /** * HSQL dialect for the quota store @@ -25,69 +25,44 @@ public HSQLDialect() { TABLE_CREATION_MAP.put( "TILESET", - Arrays.asList( // - "CREATE CACHED TABLE ${schema}TILESET (\n" - + // - " KEY VARCHAR(" - + TILESET_KEY_SIZE - + ") PRIMARY KEY,\n" - + // - " LAYER_NAME VARCHAR(" - + LAYER_NAME_SIZE - + "),\n" - + // - " GRIDSET_ID VARCHAR(" - + GRIDSET_ID_SIZE - + "),\n" - + // - " BLOB_FORMAT VARCHAR(" - + BLOB_FORMAT_SIZE - + "),\n" - + // - " PARAMETERS_ID VARCHAR(" - + PARAMETERS_ID_SIZE - + "),\n" - + // - " BYTES NUMERIC(" - + BYTES_SIZE - + ") DEFAULT 0 NOT NULL\n" - + // - ")", // + List.of( // + """ + CREATE CACHED TABLE ${schema}TILESET ( + KEY VARCHAR(%d) PRIMARY KEY, + LAYER_NAME VARCHAR(%d), + GRIDSET_ID VARCHAR(%d), + BLOB_FORMAT VARCHAR(%d), + PARAMETERS_ID VARCHAR(%d), + BYTES NUMERIC(%d) DEFAULT 0 NOT NULL + ) + """ + .formatted( + TILESET_KEY_SIZE, + LAYER_NAME_SIZE, + GRIDSET_ID_SIZE, + BLOB_FORMAT_SIZE, + PARAMETERS_ID_SIZE, + BYTES_SIZE), // "CREATE INDEX TILESET_LAYER ON ${schema}TILESET(LAYER_NAME)" // )); TABLE_CREATION_MAP.put( "TILEPAGE", - Arrays.asList( - "CREATE CACHED TABLE ${schema}TILEPAGE (\n" - + // - " KEY VARCHAR(" - + TILEPAGE_KEY_SIZE - + ") PRIMARY KEY,\n" - + // - " TILESET_ID VARCHAR(" - + TILESET_KEY_SIZE - + ") REFERENCES ${schema}TILESET(KEY) ON DELETE CASCADE,\n" - + // - " PAGE_Z SMALLINT,\n" - + // - " PAGE_X INTEGER,\n" - + // - " PAGE_Y INTEGER,\n" - + // - " CREATION_TIME_MINUTES INTEGER,\n" - + // - " FREQUENCY_OF_USE FLOAT,\n" - + // - " LAST_ACCESS_TIME_MINUTES INTEGER,\n" - + // - " FILL_FACTOR FLOAT,\n" - + // - " NUM_HITS NUMERIC(" - + NUM_HITS_SIZE - + ")\n" - + // - ")", // + List.of( + """ + CREATE CACHED TABLE ${schema}TILEPAGE ( + KEY VARCHAR(%d) PRIMARY KEY, + TILESET_ID VARCHAR(%d) REFERENCES ${schema}TILESET(KEY) ON UPDATE CASCADE ON DELETE CASCADE, + PAGE_Z SMALLINT, + PAGE_X INTEGER, + PAGE_Y INTEGER, + CREATION_TIME_MINUTES INTEGER, + FREQUENCY_OF_USE FLOAT, + LAST_ACCESS_TIME_MINUTES INTEGER, + FILL_FACTOR FLOAT, + NUM_HITS NUMERIC(%d) + )""" + .formatted(TILEPAGE_KEY_SIZE, TILESET_KEY_SIZE, NUM_HITS_SIZE), // "CREATE INDEX TILEPAGE_TILESET ON ${schema}TILEPAGE(TILESET_ID, FILL_FACTOR)", "CREATE INDEX TILEPAGE_FREQUENCY ON ${schema}TILEPAGE(FREQUENCY_OF_USE DESC)", "CREATE INDEX TILEPAGE_LAST_ACCESS ON ${schema}TILEPAGE(LAST_ACCESS_TIME_MINUTES DESC)")); diff --git a/geowebcache/diskquota/jdbc/src/main/java/org/geowebcache/diskquota/jdbc/OracleDialect.java b/geowebcache/diskquota/jdbc/src/main/java/org/geowebcache/diskquota/jdbc/OracleDialect.java index a737a5cb6..b1604d057 100644 --- a/geowebcache/diskquota/jdbc/src/main/java/org/geowebcache/diskquota/jdbc/OracleDialect.java +++ b/geowebcache/diskquota/jdbc/src/main/java/org/geowebcache/diskquota/jdbc/OracleDialect.java @@ -13,7 +13,6 @@ */ package org.geowebcache.diskquota.jdbc; -import java.util.Arrays; import java.util.List; /** @@ -34,69 +33,45 @@ static int numberPrecision(int n) { public OracleDialect() { TABLE_CREATION_MAP.put( "TILESET", - Arrays.asList( // - "CREATE TABLE ${schema}TILESET (\n" - + // - " KEY VARCHAR(" - + TILESET_KEY_SIZE - + ") PRIMARY KEY,\n" - + // - " LAYER_NAME VARCHAR(" - + LAYER_NAME_SIZE - + "),\n" - + // - " GRIDSET_ID VARCHAR(" - + GRIDSET_ID_SIZE - + "),\n" - + // - " BLOB_FORMAT VARCHAR(" - + BLOB_FORMAT_SIZE - + "),\n" - + // - " PARAMETERS_ID VARCHAR(" - + PARAMETERS_ID_SIZE - + "),\n" - + // - " BYTES NUMBER(" - + numberPrecision(BYTES_SIZE) - + ") DEFAULT 0 NOT NULL\n" - + // - ") ORGANIZATION INDEX", // + List.of( // + """ + CREATE TABLE ${schema}TILESET ( + KEY VARCHAR(%d) PRIMARY KEY, + LAYER_NAME VARCHAR(%d), + GRIDSET_ID VARCHAR(%d), + BLOB_FORMAT VARCHAR(%d), + PARAMETERS_ID VARCHAR(%d), + BYTES NUMBER(%d) DEFAULT 0 NOT NULL + ) ORGANIZATION INDEX + """ + .formatted( + TILESET_KEY_SIZE, + LAYER_NAME_SIZE, + GRIDSET_ID_SIZE, + BLOB_FORMAT_SIZE, + PARAMETERS_ID_SIZE, + numberPrecision(BYTES_SIZE)), // "CREATE INDEX TILESET_LAYER ON TILESET(LAYER_NAME)" // )); TABLE_CREATION_MAP.put( "TILEPAGE", - Arrays.asList( - "CREATE TABLE ${schema}TILEPAGE (\n" - + // - " KEY VARCHAR(" - + TILEPAGE_KEY_SIZE - + ") PRIMARY KEY,\n" - + // - " TILESET_ID VARCHAR(" - + TILESET_KEY_SIZE - + ") REFERENCES ${schema}TILESET(KEY) ON DELETE CASCADE,\n" - + // - " PAGE_Z SMALLINT,\n" - + // - " PAGE_X INTEGER,\n" - + // - " PAGE_Y INTEGER,\n" - + // - " CREATION_TIME_MINUTES INTEGER,\n" - + // - " FREQUENCY_OF_USE FLOAT,\n" - + // - " LAST_ACCESS_TIME_MINUTES INTEGER,\n" - + // - " FILL_FACTOR FLOAT,\n" - + // - " NUM_HITS NUMBER(" - + numberPrecision(NUM_HITS_SIZE) - + ")\n" - + // - ") ORGANIZATION INDEX", // + List.of( + """ + CREATE TABLE ${schema}TILEPAGE ( + KEY VARCHAR(%d) PRIMARY KEY, + TILESET_ID VARCHAR(%d) REFERENCES ${schema}TILESET(KEY) ON DELETE CASCADE, + PAGE_Z SMALLINT, + PAGE_X INTEGER, + PAGE_Y INTEGER, + CREATION_TIME_MINUTES INTEGER, + FREQUENCY_OF_USE FLOAT, + LAST_ACCESS_TIME_MINUTES INTEGER, + FILL_FACTOR FLOAT, + NUM_HITS NUMBER(%d) + ) ORGANIZATION INDEX + """ + .formatted(TILEPAGE_KEY_SIZE, TILESET_KEY_SIZE, numberPrecision(NUM_HITS_SIZE)), // "CREATE INDEX TILEPAGE_TILESET ON TILEPAGE(TILESET_ID)", "CREATE INDEX TILEPAGE_FILL_FACTOR ON TILEPAGE(FILL_FACTOR)", "CREATE INDEX TILEPAGE_FREQUENCY ON TILEPAGE(FREQUENCY_OF_USE DESC)", @@ -108,6 +83,40 @@ protected void addEmtpyTableReference(StringBuilder sb) { sb.append("FROM DUAL"); } + /** + * No-op: Oracle does not support {@code ON UPDATE CASCADE} on foreign keys, so there is nothing portable to + * migrate. Companion to {@link #getRenameLayerStatement(String, String, String)}, which preserves the legacy + * LAYER_NAME-only behavior on this dialect. + */ + @Override + public void migrateForeignKeys(String schema, SimpleJdbcTemplate template) { + // intentional no-op + } + + /** + * Oracle does not support {@code ON UPDATE CASCADE} on foreign keys, so the {@code TILEPAGE.TILESET_ID -> TILESET + * .KEY} FK declared above only cascades on delete. As a result this dialect cannot safely rewrite {@code TILESET + * .KEY} during a rename without first dealing with the dangling {@code TILEPAGE} rows. + * + *
For now Oracle keeps the legacy behavior of only updating {@code LAYER_NAME}; lookups by id against the
+ * renamed layer will continue to miss the row and cause {@code getOrCreateTileSet} to insert duplicates. Fixing
+ * this on Oracle (e.g. via {@code DEFERRABLE INITIALLY DEFERRED} constraints, or by disabling the FK around the
+ * rename) is tracked separately.
+ */
+ @Override
+ public String getRenameLayerStatement(String schema, String oldLayerName, String newLayerName) {
+ StringBuilder sb = new StringBuilder("UPDATE ");
+ if (schema != null) {
+ sb.append(schema).append(".");
+ }
+ sb.append("TILESET SET LAYER_NAME = :")
+ .append(newLayerName)
+ .append(" WHERE LAYER_NAME = :")
+ .append(oldLayerName);
+
+ return sb.toString();
+ }
+
@Override
public String getLeastFrequentlyUsedPage(String schema, List Idempotent: looks up the existing FK via {@link DatabaseMetaData#getImportedKeys}, and only rewrites it when
+ * the current update rule is not {@link DatabaseMetaData#importedKeyCascade}. Dialects that cannot support
+ * {@code ON UPDATE CASCADE} (notably Oracle) override this method to no-op.
+ */
+ protected void migrateForeignKeys(String schema, SimpleJdbcTemplate template) {
+ DataSource ds = Objects.requireNonNull(((JdbcAccessor) template.getJdbcOperations()).getDataSource());
+ try {
+ JdbcUtils.extractDatabaseMetaData(ds, dbmd -> upgradeTilepageForeignKey(dbmd, schema, template));
+ } catch (MetaDataAccessException e) {
+ LOG.log(
+ Level.WARNING,
+ "Could not migrate TILEPAGE foreign key to ON UPDATE CASCADE; layer renames may leave stale rows",
+ e);
+ }
+ }
+
+ /**
+ * {@link DatabaseMetaDataCallback} body for {@link #migrateForeignKeys}: scans the FKs declared on TILEPAGE and,
+ * for any TILEPAGE -> TILESET FK whose update rule is not {@link DatabaseMetaData#importedKeyCascade}, drops it and
+ * re-adds it with {@code ON UPDATE CASCADE ON DELETE CASCADE}. Idempotent: a FK that already cascades on update is
+ * left untouched.
+ *
+ * Concurrent-startup safe: if another instance races us to the upgrade, our drop/add may throw because the
+ * legacy constraint name no longer exists. In that case we re-check the live FK state and, if the cascade form is
+ * already in place, treat it as a concurrent success rather than a failure.
+ */
+ private Void upgradeTilepageForeignKey(DatabaseMetaData dbmd, String schema, SimpleJdbcTemplate template)
+ throws SQLException {
+
+ final String tilepageName = resolveTableName(dbmd, schema, "TILEPAGE");
+ if (tilepageName == null) {
+ return null;
+ }
+ final String prefix = schema == null ? "" : schema + ".";
+ final String prefixedTilepageName = prefix + tilepageName;
+ try (ResultSet rs = dbmd.getImportedKeys(null, schema, tilepageName)) {
+ while (rs.next()) {
+ String fkName = rs.getString("FK_NAME");
+ if (!isTilesetCascadeCandidate(rs, fkName)) {
+ continue;
+ }
+ String drop = "ALTER TABLE %s DROP CONSTRAINT %s".formatted(prefixedTilepageName, fkName);
+ String add =
+ """
+ ALTER TABLE %s ADD FOREIGN KEY (TILESET_ID)
+ REFERENCES %sTILESET(KEY)
+ ON UPDATE CASCADE ON DELETE CASCADE
+ """
+ .formatted(prefixedTilepageName, prefix);
+
+ LOG.info(() -> "Upgrading TILEPAGE.TILESET_ID foreign key to ON UPDATE CASCADE (was constraint %s)"
+ .formatted(fkName));
+ JdbcOperations jdbcOperations = template.getJdbcOperations();
+ try {
+ jdbcOperations.execute(drop);
+ jdbcOperations.execute(add);
+ } catch (DataAccessException raceLikely) {
+ if (isTilepageFkAlreadyCascade(dbmd, schema, tilepageName)) {
+ LOG.fine(() -> "TILEPAGE FK was migrated concurrently by another instance "
+ + "while this instance was trying to drop %s; accepting concurrent migration"
+ .formatted(fkName));
+ return null;
+ }
+ throw raceLikely;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Re-checks the live TILEPAGE -> TILESET FK state after a failed migration attempt. Returns {@code true} when the
+ * FK is already declared {@link DatabaseMetaData#importedKeyCascade}, i.e. another instance has completed the
+ * migration in the meantime.
+ */
+ private static boolean isTilepageFkAlreadyCascade(DatabaseMetaData dbmd, String schema, String tilepageName)
+ throws SQLException {
+ try (ResultSet rs = dbmd.getImportedKeys(null, schema, tilepageName)) {
+ while (rs.next()) {
+ String pkTable = rs.getString("PKTABLE_NAME");
+ String fkColumn = rs.getString("FKCOLUMN_NAME");
+ if (!"TILESET".equalsIgnoreCase(pkTable) || !"TILESET_ID".equalsIgnoreCase(fkColumn)) {
+ continue;
+ }
+ if (rs.getShort("UPDATE_RULE") == DatabaseMetaData.importedKeyCascade) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * True when the current {@code getImportedKeys} row describes the TILEPAGE -> TILESET(KEY) FK and its update rule
+ * is something other than {@code CASCADE}.
+ */
+ private static boolean isTilesetCascadeCandidate(ResultSet rs, String fkName) throws SQLException {
+ if (fkName == null || fkName.isEmpty()) {
+ return false;
+ }
+ String pkTable = rs.getString("PKTABLE_NAME");
+ String fkColumn = rs.getString("FKCOLUMN_NAME");
+ boolean isTilesetFk = "TILESET".equalsIgnoreCase(pkTable) && "TILESET_ID".equalsIgnoreCase(fkColumn);
+ if (!isTilesetFk) {
+ return false;
+ }
+ short updateRule = rs.getShort("UPDATE_RULE");
+ return updateRule != DatabaseMetaData.importedKeyCascade;
+ }
+
+ private static String resolveTableName(DatabaseMetaData dbmd, String schema, String tableName) throws SQLException {
+ try (ResultSet rs = dbmd.getTables(null, schema, tableName.toLowerCase(), null)) {
+ if (rs.next()) {
+ return rs.getString("TABLE_NAME");
+ }
+ }
+ try (ResultSet rs = dbmd.getTables(null, schema, tableName, null)) {
+ if (rs.next()) {
+ return rs.getString("TABLE_NAME");
+ }
+ }
+ return null;
}
/** Checks if the specified table exists */
@@ -310,6 +426,19 @@ public String getUsedQuotaByLayerGridset(String schema, String layerNameParam, S
return sb.toString();
}
+ /**
+ * Returns the SQL to rename a layer, updating both {@code LAYER_NAME} and the layer-name prefix of {@code KEY} on
+ * the {@code TILESET} table.
+ *
+ * {@code KEY} is built as {@code Default form uses standard SQL {@code SUBSTRING ... FROM POSITION(...)}, supported by PostgreSQL, H2, and
+ * HSQL. Dialects with different SUBSTRING/POSITION syntax override this method.
+ */
public String getRenameLayerStatement(String schema, String oldLayerName, String newLayerName) {
StringBuilder sb = new StringBuilder("UPDATE ");
if (schema != null) {
@@ -317,6 +446,9 @@ public String getRenameLayerStatement(String schema, String oldLayerName, String
}
sb.append("TILESET SET LAYER_NAME = :")
.append(newLayerName)
+ .append(", KEY = :")
+ .append(newLayerName)
+ .append(" || SUBSTRING(KEY FROM POSITION('#' IN KEY))")
.append(" WHERE LAYER_NAME = :")
.append(oldLayerName);
diff --git a/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/AbstractForeignKeyMigrationTest.java b/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/AbstractForeignKeyMigrationTest.java
new file mode 100644
index 000000000..f7d95506f
--- /dev/null
+++ b/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/AbstractForeignKeyMigrationTest.java
@@ -0,0 +1,189 @@
+/**
+ * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General
+ * Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any
+ * later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with this program. If not, see
+ * Copyright 2026
+ */
+package org.geowebcache.diskquota.jdbc;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import javax.sql.DataSource;
+import org.junit.Test;
+
+/**
+ * Verifies the legacy-to-current path in {@link SQLDialect#migrateForeignKeys} that drops the existing {@code TILEPAGE
+ * -> TILESET} foreign key (declared with only {@code ON DELETE CASCADE}) and re-adds it with {@code ON UPDATE CASCADE
+ * ON DELETE CASCADE}.
+ *
+ * The current {@code JDBCQuotaStoreTest} suite always starts from fresh-DDL tables, so it only exercises the no-op
+ * idempotent branch of the migration; this test class fills in the upgrade path.
+ *
+ * Each test starts from a "legacy" schema built by stripping {@code " ON UPDATE CASCADE"} from the dialect's own
+ * table-creation SQL (i.e. the pre-fix shape of the FK). Subclasses provide the dialect and a DataSource pointed at the
+ * database under test.
+ */
+public abstract class AbstractForeignKeyMigrationTest {
+
+ /** Dialect under test. */
+ protected abstract SQLDialect dialect();
+
+ /** Data source pointed at a usable database where the legacy schema can be (re)created. */
+ protected abstract DataSource dataSource();
+
+ /**
+ * Recreates the legacy schema. Subclasses call this from their {@code @Before} after wiring the data source; not
+ * annotated so the dialect/dataSource setup ordering is always explicit.
+ */
+ protected void recreateLegacySchema() throws SQLException {
+ try (Connection cx = dataSource().getConnection();
+ Statement st = cx.createStatement()) {
+ dropIfExists(st, "TILEPAGE");
+ dropIfExists(st, "TILESET");
+ for (String table : dialect().TABLE_CREATION_MAP.keySet()) {
+ for (String ddl : dialect().TABLE_CREATION_MAP.get(table)) {
+ String legacy = stripCascadeOnUpdate(ddl);
+ st.execute(legacy);
+ }
+ }
+ }
+ }
+
+ /**
+ * Reproduces the pre-fix DDL by removing the {@code ON UPDATE CASCADE} clause that was added to the TILEPAGE FK.
+ */
+ private static String stripCascadeOnUpdate(String ddl) {
+ return ddl.replace("${schema}", "").replace(" ON UPDATE CASCADE", "");
+ }
+
+ private static void dropIfExists(Statement st, String table) {
+ try {
+ st.execute("DROP TABLE " + table + " CASCADE");
+ } catch (SQLException ignored) {
+ // table may not exist on the first run; the legacy CREATEs below recreate it
+ }
+ }
+
+ @Test
+ public void migrateAddsOnUpdateCascadeToTilepageForeignKey() throws SQLException {
+ short ruleBefore = requireTilepageFkUpdateRule();
+ assertNotEquals(
+ "Legacy TILEPAGE FK should not yet be ON UPDATE CASCADE",
+ (short) DatabaseMetaData.importedKeyCascade,
+ ruleBefore);
+
+ dialect().migrateForeignKeys(null, new SimpleJdbcTemplate(dataSource()));
+
+ short ruleAfter = requireTilepageFkUpdateRule();
+ assertEquals(
+ "Migration should rewrite TILEPAGE FK as ON UPDATE CASCADE",
+ (short) DatabaseMetaData.importedKeyCascade,
+ ruleAfter);
+ }
+
+ /**
+ * Simulates multiple JVMs starting at the same time against a shared database with the legacy FK still in place.
+ * Both call {@code migrateForeignKeys} concurrently; the migration must remain idempotent end-to-end - neither call
+ * should propagate an exception, and the final FK state must be cascade-on-update.
+ */
+ @Test
+ public void migrateIsConcurrentStartupSafe() throws Exception {
+ int threads = 4;
+ CyclicBarrier startGate = new CyclicBarrier(threads);
+ ExecutorService exec = Executors.newFixedThreadPool(threads);
+ try {
+ Callable This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with this program. If not, see
+ * Copyright 2026
+ */
+package org.geowebcache.diskquota.jdbc;
+
+import javax.sql.DataSource;
+import org.apache.commons.dbcp.BasicDataSource;
+import org.junit.After;
+import org.junit.Before;
+
+/**
+ * Surefire-run unit test that runs {@link AbstractForeignKeyMigrationTest} against an in-memory HSQL database.
+ *
+ * Each test instance gets a fresh, uniquely-named in-memory database (the {@link #INSTANCE_COUNTER} suffix isolates
+ * parallel/repeated runs); the connection pool is closed in {@link #tearDown()} so no state leaks across tests.
+ */
+public class HSQLForeignKeyMigrationTest extends AbstractForeignKeyMigrationTest {
+
+ private static int INSTANCE_COUNTER = 0;
+
+ private BasicDataSource dataSource;
+
+ @Before
+ public void setUpDataSource() throws Exception {
+ dataSource = new BasicDataSource();
+ dataSource.setDriverClassName("org.hsqldb.jdbcDriver");
+ dataSource.setUrl("jdbc:hsqldb:mem:legacy-fk-" + (++INSTANCE_COUNTER));
+ dataSource.setUsername("sa");
+ dataSource.setPassword("");
+ recreateLegacySchema();
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ if (dataSource != null) {
+ dataSource.close();
+ dataSource = null;
+ }
+ }
+
+ @Override
+ protected SQLDialect dialect() {
+ return new HSQLDialect();
+ }
+
+ @Override
+ protected DataSource dataSource() {
+ return dataSource;
+ }
+}
diff --git a/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/JDBCQuotaStoreTest.java b/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/JDBCQuotaStoreTest.java
index 1edc1ed16..5404c99cf 100644
--- a/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/JDBCQuotaStoreTest.java
+++ b/geowebcache/diskquota/jdbc/src/test/java/org/geowebcache/diskquota/jdbc/JDBCQuotaStoreTest.java
@@ -14,6 +14,8 @@
import java.io.IOException;
import java.math.BigInteger;
import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
@@ -211,7 +213,7 @@ protected void tearDownInternal() throws Exception {
TilePageCalculator tilePageCalculator;
- private BasicDataSource dataSource;
+ protected BasicDataSource dataSource;
private TileSet testTileSet;
@@ -233,22 +235,22 @@ protected JDBCFixtureRule makeFixtureRule() {
protected abstract String getFixtureId();
protected BasicDataSource getDataSource() throws IOException, SQLException {
- BasicDataSource dataSource = new BasicDataSource();
-
- dataSource.setDriverClassName(fixtureRule.getFixture().getProperty("driver"));
- dataSource.setUrl(fixtureRule.getFixture().getProperty("url"));
- dataSource.setUsername(fixtureRule.getFixture().getProperty("username"));
- dataSource.setPassword(fixtureRule.getFixture().getProperty("password"));
- dataSource.setPoolPreparedStatements(true);
- dataSource.setAccessToUnderlyingConnectionAllowed(true);
- dataSource.setMinIdle(1);
- dataSource.setMaxActive(4);
+ BasicDataSource ds = new BasicDataSource();
+
+ ds.setDriverClassName(fixtureRule.getFixture().getProperty("driver"));
+ ds.setUrl(fixtureRule.getFixture().getProperty("url"));
+ ds.setUsername(fixtureRule.getFixture().getProperty("username"));
+ ds.setPassword(fixtureRule.getFixture().getProperty("password"));
+ ds.setPoolPreparedStatements(true);
+ ds.setAccessToUnderlyingConnectionAllowed(true);
+ ds.setMinIdle(1);
+ ds.setMaxActive(4);
// if we cannot get a connection within 5 seconds give up
- dataSource.setMaxWait(5000);
+ ds.setMaxWait(5000);
- cleanupDatabase(dataSource);
+ cleanupDatabase(ds);
- return dataSource;
+ return ds;
}
protected void cleanupDatabase(DataSource dataSource) throws SQLException {
@@ -330,15 +332,19 @@ public void testTableSetup() throws Exception {
}
@Test
- public void testRenameLayer() throws InterruptedException {
+ public void testRenameLayer() throws Exception {
assertEquals(16, countTileSetsByLayerName("topp:states"));
store.renameLayer("topp:states", "states_renamed");
assertEquals(0, countTileSetsByLayerName("topp:states"));
assertEquals(16, countTileSetsByLayerName("states_renamed"));
+ // TILESET.KEY embeds the layer-name prefix; renaming must rewrite it too, otherwise
+ // subsequent getTileSetById lookups miss the row and getOrCreateTileSet inserts duplicates.
+ assertEquals(0, countTileSetKeysWithPrefix("topp:states#"));
+ assertEquals(16, countTileSetKeysWithPrefix("states_renamed#"));
}
@Test
- public void testRenameLayer2() throws InterruptedException {
+ public void testRenameLayer2() throws Exception {
final String oldLayerName =
tilePageCalculator.getLayerNames().iterator().next();
final String newLayerName = "renamed_layer";
@@ -350,13 +356,17 @@ public void testRenameLayer2() throws InterruptedException {
TileSet tileSet =
tilePageCalculator.getTileSetsFor(oldLayerName).iterator().next();
TilePage page = new TilePage(tileSet.getId(), 0, 0, (byte) 0);
- store.addHitsAndSetAccesTime(Collections.singleton(new PageStatsPayload(page)));
+ // await the async write so the TILEPAGE row is in place before we rename
+ store.addHitsAndSetAccesTime(Collections.singleton(new PageStatsPayload(page)))
+ .get();
store.addToQuotaAndTileCounts(tileSet, new Quota(BigInteger.valueOf(1024)), Collections.emptyList());
Quota expectedQuota = store.getUsedQuotaByLayerName(oldLayerName);
assertEquals(1024L, expectedQuota.getBytes().longValue());
assertNotNull(store.getTileSetById(tileSet.getId()));
+ int tilePagesBefore = countTilePageTilesetIdsWithPrefix(oldLayerName + "#");
+ assertTrue("expected at least one TILEPAGE row to follow the rename", tilePagesBefore > 0);
store.renameLayer(oldLayerName, newLayerName);
@@ -369,6 +379,12 @@ public void testRenameLayer2() throws InterruptedException {
// created new layer?
Quota newLayerUsedQuota = store.getUsedQuotaByLayerName(newLayerName);
assertEquals(expectedQuota.getBytes(), newLayerUsedQuota.getBytes());
+
+ // KEY column and the cascading TILEPAGE.TILESET_ID must reflect the new layer prefix
+ assertEquals(0, countTileSetKeysWithPrefix(oldLayerName + "#"));
+ assertTrue(countTileSetKeysWithPrefix(newLayerName + "#") > 0);
+ assertEquals(0, countTilePageTilesetIdsWithPrefix(oldLayerName + "#"));
+ assertEquals(tilePagesBefore, countTilePageTilesetIdsWithPrefix(newLayerName + "#"));
}
@Test
@@ -521,7 +537,7 @@ public void testDeleteLayer() throws InterruptedException {
}
@Test
- public void testVisitor() throws Exception {
+ public void testVisitor() {
Set This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with this program. If not, see
+ * Copyright 2026
+ */
+package org.geowebcache.diskquota.jdbc.tests.container;
+
+import javax.sql.DataSource;
+import org.apache.commons.dbcp.BasicDataSource;
+import org.geowebcache.diskquota.jdbc.AbstractForeignKeyMigrationTest;
+import org.geowebcache.diskquota.jdbc.PostgreSQLDialect;
+import org.geowebcache.diskquota.jdbc.SQLDialect;
+import org.geowebcache.testcontainers.jdbc.PostgresContainer;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.ClassRule;
+
+/**
+ * Integration test that runs {@link AbstractForeignKeyMigrationTest} against a real PostgreSQL spun up via
+ * Testcontainers. Validates that the FK-upgrade path works against the JDBC driver's
+ * {@code DatabaseMetaData.getImportedKeys} semantics on PostgreSQL, not just HSQL.
+ */
+public class PostgreSQLForeignKeyMigrationIT extends AbstractForeignKeyMigrationTest {
+
+ @ClassRule
+ public static final PostgresContainer POSTGRES = PostgresContainer.latest().disabledWithoutDocker();
+
+ private BasicDataSource dataSource;
+
+ @Before
+ public void setUpDataSource() throws Exception {
+ dataSource = new BasicDataSource();
+ dataSource.setDriverClassName(POSTGRES.getDriverClassName());
+ dataSource.setUrl(POSTGRES.getJdbcUrl());
+ dataSource.setUsername(POSTGRES.getUsername());
+ dataSource.setPassword(POSTGRES.getPassword());
+ recreateLegacySchema();
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ if (dataSource != null) {
+ dataSource.close();
+ dataSource = null;
+ }
+ }
+
+ @Override
+ protected SQLDialect dialect() {
+ return new PostgreSQLDialect();
+ }
+
+ @Override
+ protected DataSource dataSource() {
+ return dataSource;
+ }
+}