diff --git a/core/src/main/java/net/staticstudios/data/impl/h2/H2DataAccessor.java b/core/src/main/java/net/staticstudios/data/impl/h2/H2DataAccessor.java index 41b864d5..a4cb5e17 100644 --- a/core/src/main/java/net/staticstudios/data/impl/h2/H2DataAccessor.java +++ b/core/src/main/java/net/staticstudios/data/impl/h2/H2DataAccessor.java @@ -567,7 +567,15 @@ public void setRedisValue(String holderSchema, String holderTable, String identi Runnable runnable = () -> { if (value == null) { taskQueue.submitTask((connection, jedis) -> { - jedis.del(key); + redisListener.expectLocalDeleteEvent(key); + boolean deleted = false; + try { + deleted = jedis.del(key) > 0; + } finally { + if (!deleted) { + redisListener.cancelLocalDeleteEvent(key); + } + } }); } else { taskQueue.submitTask((connection, jedis) -> { diff --git a/core/src/main/java/net/staticstudios/data/impl/redis/RedisListener.java b/core/src/main/java/net/staticstudios/data/impl/redis/RedisListener.java index a0ba0062..c4afd874 100644 --- a/core/src/main/java/net/staticstudios/data/impl/redis/RedisListener.java +++ b/core/src/main/java/net/staticstudios/data/impl/redis/RedisListener.java @@ -14,13 +14,18 @@ import java.util.Arrays; import java.util.Map; import java.util.Set; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.regex.Pattern; public class RedisListener extends JedisPubSub { private static final Logger logger = LoggerFactory.getLogger(RedisListener.class); private final Set listenedPartialKeys = ConcurrentHashMap.newKeySet(); private final Map handlers = new ConcurrentHashMap<>(); + private final Map ignoredLocalDeleteEvents = new ConcurrentHashMap<>(); + private final CompletableFuture subscriptionReady = new CompletableFuture<>(); private final TaskQueue taskQueue; public RedisListener(DataSourceConfig ds, TaskQueue taskQueue) { @@ -32,11 +37,18 @@ public RedisListener(DataSourceConfig ds, TaskQueue taskQueue) { if (ThreadUtils.isShuttingDown()) { return; } + subscriptionReady.completeExceptionally(e); logger.error("Redis connection lost in listener thread", e); } }); listenerThread.start(); + try { + subscriptionReady.get(10, TimeUnit.SECONDS); + } catch (Exception e) { + throw new IllegalStateException("Timed out waiting for the Redis event subscription", e); + } + ThreadUtils.onShutdownRunSync(ShutdownStage.CLEANUP, () -> { this.punsubscribe(); listenerThread.interrupt(); @@ -58,6 +70,9 @@ public void onPMessage(String pattern, String channel, String key) { if (!key.startsWith("static-data:")) { return; } + if (event == RedisEvent.DEL && consumeLocalDeleteEvent(key)) { + return; + } for (Map.Entry entry : handlers.entrySet()) { if (entry.getKey().matcher(key).matches()) { @@ -75,4 +90,26 @@ public void onPMessage(String pattern, String channel, String key) { } } } + + @Override + public void onPSubscribe(String pattern, int subscribedChannels) { + subscriptionReady.complete(null); + } + + public void expectLocalDeleteEvent(String key) { + ignoredLocalDeleteEvents.merge(key, 1, Integer::sum); + } + + public void cancelLocalDeleteEvent(String key) { + consumeLocalDeleteEvent(key); + } + + private boolean consumeLocalDeleteEvent(String key) { + AtomicBoolean consumed = new AtomicBoolean(false); + ignoredLocalDeleteEvents.computeIfPresent(key, (ignoredKey, count) -> { + consumed.set(true); + return count == 1 ? null : count - 1; + }); + return consumed.get(); + } } diff --git a/core/src/test/java/net/staticstudios/data/CachedValueTest.java b/core/src/test/java/net/staticstudios/data/CachedValueTest.java index e71760e7..cb73f2c6 100644 --- a/core/src/test/java/net/staticstudios/data/CachedValueTest.java +++ b/core/src/test/java/net/staticstudios/data/CachedValueTest.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.Test; import redis.clients.jedis.Jedis; +import java.util.Objects; import java.util.UUID; import static org.junit.jupiter.api.Assertions.*; @@ -52,7 +53,7 @@ public void testFallback() { assertEquals(false, user.onCooldown.get()); assertEquals(0, user.cooldownUpdates.get()); - waitForDataPropagation(); + flushDataManagers(); Jedis jedis = getJedis(); @@ -114,9 +115,13 @@ public void testUpdateHandler() { Jedis jedis = getJedis(); String onCooldownKey = RedisUtils.buildRedisKey("public", "users", "on_cooldown", user.getIdColumns()); + dataManager.flushTaskQueue(); jedis.del(onCooldownKey); - waitForDataPropagation(); + awaitCondition( + () -> Objects.equals(false, user.onCooldown.get()) && Objects.equals(6, user.cooldownUpdates.get()), + "the external Redis deletion to reach the cached value and its update handler" + ); assertEquals(false, user.onCooldown.get()); assertEquals(6, user.cooldownUpdates.get()); @@ -139,19 +144,19 @@ public void testUpdateRedis() { user.onCooldown.set(true); user.cooldownUpdates.set(1); - waitForDataPropagation(); + dataManager.flushTaskQueue(); assertEquals("true", gson.fromJson(jedis.get(onCooldownKey), RedisEncodedValue.class).value()); assertEquals("1", gson.fromJson(jedis.get(cooldownUpdatesKey), RedisEncodedValue.class).value()); user.onCooldown.set(null); user.cooldownUpdates.set(null); - waitForDataPropagation(); + dataManager.flushTaskQueue(); assertNull(jedis.get(onCooldownKey)); assertNull(jedis.get(cooldownUpdatesKey)); user.onCooldown.set(false); //fallback user.cooldownUpdates.set(0); //fallback - waitForDataPropagation(); + dataManager.flushTaskQueue(); assertNull(jedis.get(onCooldownKey)); assertNull(jedis.get(cooldownUpdatesKey)); } @@ -175,7 +180,7 @@ public void testLoadCachedValues() { jedis.set(cooldownUpdatesKey, gson.toJson(new RedisEncodedValue(null, "5"))); - waitForDataPropagation(); + awaitCondition(() -> Objects.equals(5, user1.cooldownUpdates.get()), "the external Redis value to reach H2"); assertEquals(5, user1.cooldownUpdates.get()); @@ -204,15 +209,15 @@ public void testRefreshCachedValues() throws InterruptedException { assertEquals(2, user.counter.refresh()); assertEquals(2, user.counter.get()); - Thread.sleep(10_000); //wait for the cached value to expire - String counterKey = RedisUtils.buildRedisKey("public", "users", "counter", user.getIdColumns()); Jedis jedis = getJedis(); + awaitCondition(() -> !jedis.exists(counterKey), "the cached counter to expire"); assertFalse(jedis.exists(counterKey)); - assertEquals(0, user.counter.get()); //trigger a refresh - waitForDataPropagation(); + awaitCondition(() -> Objects.equals(0, user.counter.get()), "the expiration event to clear and refresh the H2 cached value"); + assertEquals(0, user.counter.get()); + dataManager.flushTaskQueue(); assertEquals("0", gson.fromJson(jedis.get(counterKey), RedisEncodedValue.class).value()); } @@ -234,17 +239,20 @@ public void testUpdateInterval() throws Exception { } assertEquals(4, user.throttledCounter.get()); - waitForDataPropagation(); + dataManager.flushTaskQueue(); Jedis jedis = getJedis(); String throttledCounterKey = RedisUtils.buildRedisKey("public", "users", "throttled_counter", user.getIdColumns()); assertNull(jedis.get(throttledCounterKey)); - Thread.sleep(6000); + awaitCondition(() -> { + RedisEncodedValue value = gson.fromJson(jedis.get(throttledCounterKey), RedisEncodedValue.class); + return value != null && Objects.equals("4", value.value()); + }, "the throttled cached value to be written"); RedisEncodedValue encoded = gson.fromJson(jedis.get(throttledCounterKey), RedisEncodedValue.class); assertNotNull(encoded); assertEquals("4", encoded.value()); } -} \ No newline at end of file +} diff --git a/core/src/test/java/net/staticstudios/data/PersistentManyToManyCollectionTest.java b/core/src/test/java/net/staticstudios/data/PersistentManyToManyCollectionTest.java index 2a5c634d..125ea37c 100644 --- a/core/src/test/java/net/staticstudios/data/PersistentManyToManyCollectionTest.java +++ b/core/src/test/java/net/staticstudios/data/PersistentManyToManyCollectionTest.java @@ -319,7 +319,7 @@ public void testAddHandlerUpdate() throws SQLException { .insert(InsertMode.SYNC); List friends = createFriends(5); user.friends.addAll(friends); - waitForDataPropagation(); + flushDataManagers(); assertEquals(5, user.friendAdditions.get()); List otherFriends = createFriends(5); @@ -334,8 +334,12 @@ public void testAddHandlerUpdate() throws SQLException { preparedStatement.setObject(3, friend.id.get()); preparedStatement.executeUpdate(); } - waitForDataPropagation(); - assertEquals(5 + (++i), user.friendAdditions.get()); + int expectedAdditions = 5 + (++i); + awaitCondition( + () -> user.friendAdditions.get() == expectedAdditions, + "the many-to-many update addition handler" + ); + assertEquals(expectedAdditions, user.friendAdditions.get()); } } @@ -347,7 +351,7 @@ public void testRemoveHandlerUpdate() throws SQLException { .insert(InsertMode.SYNC); List friends = createFriends(5); user.friends.addAll(friends); - waitForDataPropagation(); + flushDataManagers(); assertEquals(5, user.friendAdditions.get()); Connection pgConnection = getConnection(); @@ -359,8 +363,12 @@ public void testRemoveHandlerUpdate() throws SQLException { preparedStatement.setObject(2, friend.id.get()); preparedStatement.executeUpdate(); } - waitForDataPropagation(); - assertEquals(++i, user.friendRemovals.get()); + int expectedRemovals = ++i; + awaitCondition( + () -> user.friendRemovals.get() == expectedRemovals, + "the many-to-many delete handler" + ); + assertEquals(expectedRemovals, user.friendRemovals.get()); } } @@ -402,4 +410,4 @@ public void testRemoveHandlerDelete() { assertEquals(++i, user.friendRemovals.get()); } } -} \ No newline at end of file +} diff --git a/core/src/test/java/net/staticstudios/data/PersistentOneToManyValueCollectionTest.java b/core/src/test/java/net/staticstudios/data/PersistentOneToManyValueCollectionTest.java index 5a248862..cb5ba585 100644 --- a/core/src/test/java/net/staticstudios/data/PersistentOneToManyValueCollectionTest.java +++ b/core/src/test/java/net/staticstudios/data/PersistentOneToManyValueCollectionTest.java @@ -337,9 +337,12 @@ public void testAddHandlerUpdate() { } catch (Exception e) { throw new RuntimeException(e); } - waitForDataPropagation(); - - assertEquals(++i, user.favoriteNumberAdditions.get()); + int expectedAdditions = ++i; + awaitCondition( + () -> user.favoriteNumberAdditions.get() == expectedAdditions, + "the one-to-many value update addition handler" + ); + assertEquals(expectedAdditions, user.favoriteNumberAdditions.get()); } } @@ -381,9 +384,12 @@ public void testRemoveHandlerUpdate() { } catch (Exception e) { throw new RuntimeException(e); } - waitForDataPropagation(); - - assertEquals(++i, user.favoriteNumberRemovals.get()); + int expectedRemovals = ++i; + awaitCondition( + () -> user.favoriteNumberRemovals.get() == expectedRemovals, + "the one-to-many value update removal handler" + ); + assertEquals(expectedRemovals, user.favoriteNumberRemovals.get()); } } @@ -425,4 +431,4 @@ public void testRemoveHandlerDelete() { assertEquals(++i, user.favoriteNumberRemovals.get()); } } -} \ No newline at end of file +} diff --git a/core/src/test/java/net/staticstudios/data/PersistentValueTest.java b/core/src/test/java/net/staticstudios/data/PersistentValueTest.java index fd34ef90..60bc0ade 100644 --- a/core/src/test/java/net/staticstudios/data/PersistentValueTest.java +++ b/core/src/test/java/net/staticstudios/data/PersistentValueTest.java @@ -14,6 +14,7 @@ import java.sql.SQLException; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.UUID; import static org.junit.jupiter.api.Assertions.*; @@ -42,7 +43,7 @@ public void testReadData() throws SQLException { assertNull(user.age.get()); } - waitForDataPropagation(); + flushDataManagers(); MockEnvironment environment2 = createMockEnvironment(); DataManager dataManager2 = environment2.dataManager(); dataManager2.load(MockUser.class); @@ -74,9 +75,7 @@ public void testUniqueDataCache() throws SQLException { mockUser = dataManager.getInstance(MockUser.class, ColumnValuePair.of("id", id)); assertSame(mockUser, weakRef.get()); mockUser = null; // remove strong reference - System.gc(); - - assertNull(weakRef.get()); + awaitGarbageCollection(weakRef, "the weak unique-data cache entry to be collected"); mockUser = dataManager.getInstance(MockUser.class, ColumnValuePair.of("id", id)); // should have a cache miss } @@ -103,7 +102,7 @@ public void testUpdate() throws SQLException { assertEquals(0, rs.getObject("age")); } - waitForDataPropagation(); + flushDataManagers(); Connection pgConnection = getConnection(); try (PreparedStatement preparedStatement = pgConnection.prepareStatement("SELECT \"age\" FROM \"public\".\"users\" WHERE \"id\" = ?")) { @@ -122,7 +121,7 @@ public void testUpdate() throws SQLException { assertEquals(30, rs.getObject("age")); } - waitForDataPropagation(); + flushDataManagers(); try (PreparedStatement preparedStatement = pgConnection.prepareStatement("SELECT \"age\" FROM \"public\".\"users\" WHERE \"id\" = ?")) { preparedStatement.setObject(1, id); @@ -199,8 +198,9 @@ public void testUpdateHandlerRegistration() { assertEquals("test user", mockUser.name.get()); //first instance was created, handler should be registered assertEquals(1, dataManager.getUpdateHandlers("public", "users", "name", MockUser.class).size()); + WeakReference weakRef = new WeakReference<>(mockUser); mockUser = null; // remove strong reference - System.gc(); + awaitGarbageCollection(weakRef, "the update-handler test instance to be collected"); mockUser = dataManager.getInstance(MockUser.class, ColumnValuePair.of("id", id)); // should have a cache miss //the handler for this pv should not have been registered again assertEquals(1, dataManager.getUpdateHandlers("public", "users", "name", MockUser.class).size()); @@ -240,7 +240,10 @@ public void testReceiveUpdateFromPostgres() { throw new RuntimeException(e); } - waitForDataPropagation(); + awaitCondition( + () -> Objects.equals("updated from pg", mockUser.name.get()), + "the PostgreSQL update to reach the cached user" + ); assertEquals("updated from pg", mockUser.name.get()); assertEquals(1, mockUser.getNameUpdates()); @@ -269,7 +272,10 @@ public void testReceiveInsertFromPostgres() { throw new RuntimeException(e); } - waitForDataPropagation(); + awaitCondition( + () -> dataManager.getInstance(MockUser.class, ColumnValuePair.of("id", id)) != null, + "the PostgreSQL insert to reach the local cache" + ); MockUser mockUser = dataManager.getInstance(MockUser.class, ColumnValuePair.of("id", id)); @@ -301,7 +307,7 @@ public void testReceiveDeleteFromPostgres() { throw new RuntimeException(e); } - waitForDataPropagation(); + awaitCondition(mockUser::isDeleted, "the PostgreSQL delete to reach the cached user"); assertTrue(mockUser.isDeleted()); @@ -409,7 +415,10 @@ public void testUpdateInterval() throws Exception { assertNull(rs.getObject("views")); } - Thread.sleep(6000); + awaitCondition( + () -> Objects.equals(4, readNullableInt(connection, "SELECT views FROM users WHERE id = ?", id)), + "the delayed persistent value update to reach PostgreSQL" + ); try (PreparedStatement preparedStatement = connection.prepareStatement("SELECT views FROM users WHERE id = ?")) { preparedStatement.setObject(1, id); ResultSet rs = preparedStatement.executeQuery(); @@ -587,4 +596,18 @@ public void testDeleteStrategyNoAction() throws SQLException { assertEquals(10, rs.getInt("name_updates")); } } -} \ No newline at end of file + + private Integer readNullableInt(Connection connection, String sql, Object parameter) { + try (PreparedStatement preparedStatement = connection.prepareStatement(sql)) { + preparedStatement.setObject(1, parameter); + try (ResultSet resultSet = preparedStatement.executeQuery()) { + if (!resultSet.next()) { + return null; + } + return resultSet.getObject(1, Integer.class); + } + } catch (SQLException e) { + throw new RuntimeException(e); + } + } +} diff --git a/core/src/test/java/net/staticstudios/data/SQLParseTest.java b/core/src/test/java/net/staticstudios/data/SQLParseTest.java index 1d312b3c..cc825bdb 100644 --- a/core/src/test/java/net/staticstudios/data/SQLParseTest.java +++ b/core/src/test/java/net/staticstudios/data/SQLParseTest.java @@ -1,181 +1,305 @@ package net.staticstudios.data; import net.staticstudios.data.misc.DataTest; +import net.staticstudios.data.misc.SchemaAssertions; import net.staticstudios.data.mock.post.MockPost; -import net.staticstudios.data.parse.DDLStatement; +import net.staticstudios.data.mock.user.MockUser; +import net.staticstudios.data.parse.SQLSchema; import net.staticstudios.data.util.EnvironmentVariableAccessor; import net.staticstudios.data.util.ValueUtils; -import org.intellij.lang.annotations.Language; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; -import org.testcontainers.containers.Container; -import java.sql.Connection; -import java.sql.Statement; -import java.util.Arrays; -import java.util.LinkedHashSet; -import java.util.List; +import java.sql.SQLException; +import java.sql.Timestamp; import java.util.Set; +import java.util.UUID; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.fail; +import static net.staticstudios.data.misc.SchemaAssertions.DatabaseEngine.H2; +import static net.staticstudios.data.misc.SchemaAssertions.DatabaseEngine.POSTGRESQL; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; public class SQLParseTest extends DataTest { + private static EnvironmentVariableAccessor previousEnvironmentVariableAccessor; @BeforeAll - public static void setup() { - ValueUtils.ENVIRONMENT_VARIABLE_ACCESSOR = new EnvironmentVariableAccessor() { - @Override - public String getEnv(String name) { - return switch (name) { - case "POST_SCHEMA" -> "social_media"; - case "POST_TABLE" -> "posts"; - case "POST_ID_COLUMN" -> "post_id"; - default -> null; - }; - } - }; + public static void setupEnvironmentVariables() { + previousEnvironmentVariableAccessor = ValueUtils.ENVIRONMENT_VARIABLE_ACCESSOR; + EnvironmentVariableAccessor accessor = new EnvironmentVariableAccessor(); + accessor.set("POST_SCHEMA", "social_media"); + accessor.set("POST_TABLE", "posts"); + accessor.set("POST_ID_COLUMN", "post_id"); + ValueUtils.ENVIRONMENT_VARIABLE_ACCESSOR = accessor; } - private static String normalize(String str) { - return str.replace("\r\n", "\n").trim(); + @AfterAll + public static void restoreEnvironmentVariables() { + ValueUtils.ENVIRONMENT_VARIABLE_ACCESSOR = previousEnvironmentVariableAccessor; } - private static void assertSqlLinesEqualOrderIndependent(List expectedLines, List actualLines) { - Set expectedSet = new LinkedHashSet<>(expectedLines.stream().map(l -> { - if (l.endsWith(",")) { - l = l.substring(0, l.length() - 1); - } - return l.trim(); - }) - .toList()); - Set actualSet = new LinkedHashSet<>(actualLines.stream().map(l -> { - if (l.endsWith(",")) { - l = l.substring(0, l.length() - 1); - } - return l.trim(); - }) - .toList()); - - if (!expectedSet.equals(actualSet)) { - Set missing = new LinkedHashSet<>(expectedSet); - missing.removeAll(actualSet); - Set unexpected = new LinkedHashSet<>(actualSet); - unexpected.removeAll(expectedSet); - - StringBuilder msg = new StringBuilder(); - msg.append(String.format("Schema mismatch: expected %d distinct lines, actual %d distinct lines.%n", expectedSet.size(), actualSet.size())); - if (!missing.isEmpty()) { - msg.append(String.format("Missing (%d):%n", missing.size())); - for (String s : missing) { - msg.append(String.format(" %s%n", s)); - } - } - if (!unexpected.isEmpty()) { - msg.append(String.format("Unexpected (%d):%n", unexpected.size())); - for (String s : unexpected) { - msg.append(String.format(" %s%n", s)); - } - } - - msg.append("Full expected:\n"); - for (String s : expectedLines) { - msg.append(String.format(" %s%n", s)); - } - msg.append("Full actual:\n"); - for (String s : actualLines) { - msg.append(String.format(" %s%n", s)); - } - - fail(msg.toString()); - } - - assertFalse(actualLines.isEmpty(), String.format("No SQL lines were produced by pg_dump after cleaning. Expected %d distinct lines but got %d distinct lines.", expectedSet.size(), actualSet.size())); + @Test + public void testEnvironmentBackedSchemaMatchesPostgresAndH2() throws SQLException { + DataManager dataManager = load(MockPost.class); + + assertBuilderTables(dataManager, "social_media", Set.of( + "posts", + "posts_metadata", + "posts_interactions", + "posts_related" + )); + assertBuilderColumns(dataManager, "social_media", "posts", Set.of( + "post_id", + "text_content", + "likes" + )); + + assertDatabasesMatch(dataManager, "social_media"); + } + + @Test + public void testLegacyPostgresObjectNamesRemainStable() { + DataManager dataManager = load(MockPost.class); + SQLSchema schema = dataManager.getSQLBuilder().getSchema("social_media"); + assertNotNull(schema); + + Set foreignKeyNames = schema.getTables().stream() + .flatMap(table -> table.getForeignKeys().stream()) + .map(foreignKey -> foreignKey.getName()) + .collect(java.util.stream.Collectors.toSet()); + assertEquals(Set.of( + "fk_o2o_post_id_to_metadata_id", + "fk_fcol_post_id_to_post_id", + "fk_pc_m2m_posts_post_id_to_post_id", + "fk_pc_m2m_posts_ref_post_id_to_post_id" + ), foreignKeyNames); + + String postgresTriggerSql = schema.getTables().stream() + .flatMap(table -> table.getTriggers().stream()) + .map(trigger -> trigger.getPgSQL()) + .collect(java.util.stream.Collectors.joining("\n")); + assertTrue(postgresTriggerSql.contains( + "static_data_v3_social_media_posts_social_media_posts_metadata_delete_trigger" + )); + assertTrue(postgresTriggerSql.contains( + "static_data_v3_social_media_posts_social_media_posts_interactions_delete_trigger" + )); + assertTrue(postgresTriggerSql.contains("static_data_v3_m2m_5120bceb_delete_trigger")); } - @Disabled("this test is so weird, it passes sometimes and fails other time.") @Test - public void testParse() throws Exception { //todo: address flakiness - DataManager dm = getMockEnvironments().getFirst().dataManager(); - dm.extractMetadata(MockPost.class); - Connection postgresConnection = getConnection(); - List ddlStatements = dm.getSQLBuilder().parse(MockPost.class); - for (DDLStatement ddl : ddlStatements) { - System.out.println(ddl.postgresqlStatement()); - try (Statement statement = postgresConnection.createStatement()) { - statement.execute(ddl.postgresqlStatement()); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - try (Statement statement = postgresConnection.createStatement()) { - statement.execute("DROP FUNCTION IF EXISTS public.propagate_data_update_v3"); - } - - Container.ExecResult result = postgres.execInContainer("pg_dump", - "--referringSchema-only", - "--no-owner", - "--no-privileges", - "--no-comments", - "--section=pre-data", - "--section=post-data", - "-U", postgres.getUsername(), - postgres.getDatabaseName() - ); - String schemaDump = result.getStdout(); - StringBuilder cleanedDump = new StringBuilder(); - for (String line : schemaDump.split("\n")) { - if (line.startsWith("--") || line.startsWith("SET") || line.startsWith("SELECT") || line.trim().isEmpty()) { - continue; - } - cleanedDump.append(line).append("\n"); - } - - @Language("SQL") String expected = """ - CREATE SCHEMA social_media; - CREATE TABLE social_media.posts ( - post_id integer NOT NULL, - likes integer DEFAULT 0 NOT NULL, - text_content text NOT NULL - ); - CREATE TABLE social_media.posts_interactions ( - post_id integer NOT NULL, - interactions integer DEFAULT 0 NOT NULL - ); - CREATE TABLE social_media.posts_metadata ( - metadata_id integer NOT NULL, - flag boolean NOT NULL - ); - CREATE TABLE social_media.posts_related ( - posts_post_id integer NOT NULL, - posts_ref_post_id integer NOT NULL - ); - ALTER TABLE ONLY social_media.posts_interactions - ADD CONSTRAINT posts_interactions_pkey PRIMARY KEY (post_id); - ALTER TABLE ONLY social_media.posts_metadata - ADD CONSTRAINT posts_metadata_pkey PRIMARY KEY (metadata_id); - ALTER TABLE ONLY social_media.posts - ADD CONSTRAINT posts_pkey PRIMARY KEY (post_id); - ALTER TABLE ONLY social_media.posts_related - ADD CONSTRAINT posts_related_pkey PRIMARY KEY (posts_post_id, posts_ref_post_id); - CREATE INDEX idx_social_media_posts_text_content ON social_media.posts USING btree (text_content); - ALTER TABLE ONLY social_media.posts - ADD CONSTRAINT fk_post_id_to_metadata_id FOREIGN KEY (post_id) REFERENCES social_media.posts_metadata(metadata_id) ON UPDATE CASCADE ON DELETE SET NULL; - ALTER TABLE ONLY social_media.posts - ADD CONSTRAINT fk_post_id_to_post_id FOREIGN KEY (post_id) REFERENCES social_media.posts_interactions(post_id) ON UPDATE CASCADE ON DELETE CASCADE; - ALTER TABLE ONLY social_media.posts_related - ADD CONSTRAINT fk_posts_post_id_to_post_id FOREIGN KEY (posts_post_id) REFERENCES social_media.posts(post_id) ON UPDATE CASCADE ON DELETE CASCADE; - ALTER TABLE ONLY social_media.posts_related - ADD CONSTRAINT fk_posts_ref_post_id_to_post_id FOREIGN KEY (posts_ref_post_id) REFERENCES social_media.posts(post_id) ON UPDATE CASCADE ON DELETE CASCADE; - """; - - List expectedLines = Arrays.asList(normalize(expected).split("\n")); - List actualLines = Arrays.asList(normalize(cleanedDump.toString()).split("\n")); - - assertSqlLinesEqualOrderIndependent(expectedLines, actualLines); + public void testFullUserSchemaMatchesPostgresAndH2() throws SQLException { + DataManager dataManager = load(MockUser.class); + + assertBuilderTables(dataManager, "public", Set.of( + "users", + "user_settings", + "user_sessions", + "user_preferences", + "user_metadata", + "user_friends", + "favorite_numbers" + )); + assertBuilderColumns(dataManager, "public", "users", Set.of( + "id", + "settings_id", + "best_buddy_id", + "age", + "name", + "views", + "counter", + "__virtual__cv_settings_updates", + "__virtual__cv_session_additions", + "__virtual__cv_session_removals", + "__virtual__cv_friend_additions", + "__virtual__cv_friend_removals", + "__virtual__cv_favorite_number_additions", + "__virtual__cv_favorite_number_removals", + "__virtual__cv_cooldown_updates", + "__virtual__cv_throttled_counter", + "__virtual__cv_on_cooldown", + "__virtual__cv_counter" + )); + + assertDatabasesMatch(dataManager, "public"); + } + + @Test + public void testBroadSchemaContractMatchesPostgresAndH2() throws SQLException { + DataManager dataManager = load(SchemaContractParent.class); + + assertBuilderTables(dataManager, "schema_contract", Set.of( + "contract_parents", + "contract_profiles", + "contract_children", + "contract_tags", + "contract_values" + )); + assertBuilderTables(dataManager, "schema_contract_links", Set.of("parent_tags")); + assertBuilderTables(dataManager, "schema_contract_external", Set.of("parent_details")); + assertBuilderColumns(dataManager, "schema_contract", "contract_parents", Set.of( + "tenant_id", + "parent_id", + "profile_tenant_id", + "profile_id", + "label", + "code", + "active", + "long_value", + "real_value", + "double_value", + "created_at", + "optional_count", + "__virtual__cv_cached_score" + )); + + assertDatabasesMatch(dataManager, "schema_contract", "schema_contract_links", "schema_contract_external"); + } + + private DataManager load(Class rootType) { + DataManager dataManager = getMockEnvironments().getFirst().dataManager(); + dataManager.load(rootType); + dataManager.finishLoading(); + return dataManager; + } + + private void assertDatabasesMatch(DataManager dataManager, String... schemaNames) throws SQLException { + SchemaAssertions.assertMatches(dataManager, getConnection(), POSTGRESQL, schemaNames); + SchemaAssertions.assertMatches(dataManager, getH2Connection(dataManager), H2, schemaNames); + } + + private void assertBuilderTables(DataManager dataManager, String schemaName, Set expectedTables) { + SQLSchema schema = dataManager.getSQLBuilder().getSchema(schemaName); + assertNotNull(schema); + assertEquals(expectedTables, schema.getTables().stream().map(table -> table.getName()).collect(java.util.stream.Collectors.toSet())); + } + + private void assertBuilderColumns(DataManager dataManager, String schemaName, String tableName, Set expectedColumns) { + SQLSchema schema = dataManager.getSQLBuilder().getSchema(schemaName); + assertNotNull(schema); + assertNotNull(schema.getTable(tableName)); + assertEquals(expectedColumns, schema.getTable(tableName).getColumns().stream().map(column -> column.getName()).collect(java.util.stream.Collectors.toSet())); + } + + @Data(schema = "schema_contract", table = "contract_profiles") + static class SchemaContractProfile extends UniqueData { + @IdColumn(name = "tenant_id") + public PersistentValue tenantId; + + @IdColumn(name = "profile_id") + public PersistentValue profileId; + + @Column(name = "bio", nullable = true) + public PersistentValue bio; } - //todo: when a delete strategy is set to no action where it was previously set to cascade, the old trigger should be dropped. Add a test for this. moreover, what happens when we change the name of something? will the old trigger stay or what? handle this -} \ No newline at end of file + @Data(schema = "schema_contract", table = "contract_children") + static class SchemaContractChild extends UniqueData { + @IdColumn(name = "child_id") + public PersistentValue childId; + + @Column(name = "owner_tenant_id", nullable = true) + public PersistentValue ownerTenantId; + + @Column(name = "owner_parent_id", nullable = true) + public PersistentValue ownerParentId; + + @Column(name = "payload") + public PersistentValue payload; + } + + @Data(schema = "schema_contract", table = "contract_tags") + static class SchemaContractTag extends UniqueData { + @IdColumn(name = "tenant_id") + public PersistentValue tenantId; + + @IdColumn(name = "tag_id") + public PersistentValue tagId; + + @Column(name = "name", unique = true) + public PersistentValue name; + } + + @Data(schema = "schema_contract", table = "contract_parents") + static class SchemaContractParent extends UniqueData { + @IdColumn(name = "tenant_id") + public PersistentValue tenantId; + + @IdColumn(name = "parent_id") + public PersistentValue parentId; + + @Column(name = "profile_tenant_id", nullable = true) + public PersistentValue profileTenantId; + + @Column(name = "profile_id", nullable = true) + public PersistentValue profileId; + + @DefaultValue("O'Reilly") + @Column(name = "label", index = true) + public PersistentValue label; + + @Column(name = "code", unique = true) + public PersistentValue code; + + @DefaultValue("true") + @Column(name = "active") + public PersistentValue active; + + @Column(name = "long_value") + public PersistentValue longValue; + + @Column(name = "real_value") + public PersistentValue realValue; + + @Column(name = "double_value") + public PersistentValue doubleValue; + + @Column(name = "created_at") + public PersistentValue createdAt; + + @Column(name = "optional_count", nullable = true) + public PersistentValue optionalCount; + + @Delete(DeleteStrategy.CASCADE) + @ForeignColumn( + schema = "schema_contract_external", + table = "parent_details", + name = "details", + link = "tenant_id=tenant_id, parent_id=parent_id", + nullable = true, + index = true + ) + public PersistentValue details; + + @Delete(DeleteStrategy.CASCADE) + @OneToOne(link = "profile_tenant_id=tenant_id, profile_id=profile_id") + public Reference profile; + + @Delete(DeleteStrategy.SET_NULL) + @OneToMany(link = "tenant_id=owner_tenant_id, parent_id=owner_parent_id") + public PersistentCollection children; + + @Delete(DeleteStrategy.SET_NULL) + @ManyToMany( + link = "tenant_id=tenant_id, parent_id=tag_id", + joinTableSchema = "schema_contract_links", + joinTable = "parent_tags" + ) + public PersistentCollection tags; + + @Delete(DeleteStrategy.CASCADE) + @OneToMany( + link = "tenant_id=owner_tenant_id, parent_id=owner_parent_id", + table = "contract_values", + column = "payload", + indexed = true, + nullable = false + ) + public PersistentCollection values; + + @Identifier(value = "cached_score", index = true) + public CachedValue cachedScore = CachedValue.of(this, Integer.class).withFallback(0); + } +} diff --git a/core/src/test/java/net/staticstudios/data/misc/DataTest.java b/core/src/test/java/net/staticstudios/data/misc/DataTest.java index 7d654c9e..96674209 100644 --- a/core/src/test/java/net/staticstudios/data/misc/DataTest.java +++ b/core/src/test/java/net/staticstudios/data/misc/DataTest.java @@ -14,12 +14,14 @@ import redis.clients.jedis.Jedis; import java.io.IOException; +import java.lang.ref.WeakReference; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.sql.*; import java.util.LinkedList; import java.util.List; import java.util.Objects; +import java.util.function.BooleanSupplier; public class DataTest { public static int NUM_ENVIRONMENTS = 1; @@ -137,6 +139,7 @@ public int getWaitForDataPropagationTime() { } public void waitForDataPropagation() { + flushDataManagers(); try { Thread.sleep(getWaitForDataPropagationTime()); } catch (InterruptedException e) { @@ -144,6 +147,39 @@ public void waitForDataPropagation() { } } + public void flushDataManagers() { + for (MockEnvironment mockEnvironment : mockEnvironments) { + mockEnvironment.dataManager().flushTaskQueue(); + } + } + + public void awaitCondition(BooleanSupplier condition, String description) { + long timeoutMillis = Objects.equals(System.getenv("GITHUB_ACTIONS"), "true") ? 15_000 : 10_000; + long deadline = System.nanoTime() + timeoutMillis * 1_000_000; + while (System.nanoTime() < deadline) { + if (condition.getAsBoolean()) { + return; + } + try { + Thread.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError("Interrupted while waiting for " + description, e); + } + } + if (condition.getAsBoolean()) { + return; + } + throw new AssertionError("Timed out after " + timeoutMillis + "ms waiting for " + description); + } + + public void awaitGarbageCollection(WeakReference reference, String description) { + awaitCondition(() -> { + System.gc(); + return reference.get() == null; + }, description); + } + public Connection getH2Connection(DataManager dataManager) { Connection h2Connection; try { diff --git a/core/src/test/java/net/staticstudios/data/misc/SchemaAssertions.java b/core/src/test/java/net/staticstudios/data/misc/SchemaAssertions.java new file mode 100644 index 00000000..bad0eb2c --- /dev/null +++ b/core/src/test/java/net/staticstudios/data/misc/SchemaAssertions.java @@ -0,0 +1,394 @@ +package net.staticstudios.data.misc; + +import net.staticstudios.data.DataManager; +import net.staticstudios.data.parse.ForeignKey; +import net.staticstudios.data.parse.SQLColumn; +import net.staticstudios.data.parse.SQLSchema; +import net.staticstudios.data.parse.SQLTable; +import net.staticstudios.data.util.ColumnMetadata; +import net.staticstudios.data.util.SQLUtils; +import net.staticstudios.data.utils.Link; + +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public final class SchemaAssertions { + private static final Pattern CREATE_TRIGGER_NAME = Pattern.compile( + "CREATE\\s+TRIGGER(?:\\s+IF\\s+NOT\\s+EXISTS)?\\s+(?:\"([^\"]+)\"|([^\\s;]+))", + Pattern.CASE_INSENSITIVE + ); + + private SchemaAssertions() { + } + + public static void assertMatches(DataManager dataManager, Connection connection, DatabaseEngine engine, String... schemaNames) throws SQLException { + for (String schemaName : schemaNames) { + SQLSchema expectedSchema = dataManager.getSQLBuilder().getSchema(schemaName); + assertNotNull(expectedSchema, "SQLBuilder did not contain expected schema " + schemaName); + assertSchemaMatches(connection, engine, expectedSchema); + } + } + + private static void assertSchemaMatches(Connection connection, DatabaseEngine engine, SQLSchema expectedSchema) throws SQLException { + String schemaName = expectedSchema.getName(); + Map expectedTables = new HashMap<>(); + for (SQLTable table : expectedSchema.getTables()) { + expectedTables.put(table.getName(), table); + } + + Set actualTables = readTableNames(connection, schemaName); + assertEquals(expectedTables.keySet(), actualTables, engine + " tables in schema " + schemaName); + + for (SQLTable expectedTable : expectedTables.values()) { + assertTableMatches(connection, engine, expectedTable); + } + } + + private static void assertTableMatches(Connection connection, DatabaseEngine engine, SQLTable expectedTable) throws SQLException { + String schemaName = expectedTable.getSchema().getName(); + String tableName = expectedTable.getName(); + String context = engine + " table " + schemaName + "." + tableName; + + Map expectedColumns = new HashMap<>(); + Set expectedIndexes = new HashSet<>(); + Set expectedUniqueColumns = new HashSet<>(); + for (SQLColumn column : expectedTable.getColumns()) { + if (engine == DatabaseEngine.POSTGRESQL && column.isVirtual()) { + continue; + } + String sqlType = engine == DatabaseEngine.H2 + ? SQLUtils.getH2SqlType(column.getType()) + : SQLUtils.getPgSqlType(column.getType()); + expectedColumns.put(column.getName(), new ColumnShape( + normalizeType(sqlType), + column.isNullable(), + normalizeDefault(column.getDefaultValue()) + )); + if (column.isIndexed() && !column.isUnique()) { + expectedIndexes.add(("idx_" + schemaName + "_" + tableName + "_" + column.getName()).toLowerCase(Locale.ROOT)); + } + if (column.isUnique() && expectedTable.getIdColumns().stream().noneMatch(id -> id.name().equals(column.getName()))) { + expectedUniqueColumns.add(column.getName()); + } + } + + DatabaseMetaData metadata = connection.getMetaData(); + Map actualColumns = readColumns(metadata, schemaName, tableName); + assertEquals(expectedColumns, actualColumns, context + " columns"); + + List expectedPrimaryKey = expectedTable.getIdColumns().stream().map(ColumnMetadata::name).toList(); + assertEquals(expectedPrimaryKey, readPrimaryKey(metadata, schemaName, tableName), context + " primary key"); + + Map> expectedForeignKeysByLegacyName = new HashMap<>(); + for (ForeignKey foreignKey : expectedTable.getForeignKeys()) { + ForeignKeyShape shape = new ForeignKeyShape( + foreignKey.getLinkingColumns().stream().map(Link::columnInReferringTable).toList(), + foreignKey.getReferencedSchema(), + foreignKey.getReferencedTable(), + foreignKey.getLinkingColumns().stream().map(Link::columnInReferencedTable).toList(), + foreignKey.getOnUpdate() == null ? null : foreignKey.getOnUpdate().toString(), + foreignKey.getOnDelete() == null ? null : foreignKey.getOnDelete().toString() + ); + String effectiveName = effectiveIdentifier(foreignKey.getName(), engine); + expectedForeignKeysByLegacyName.computeIfAbsent(effectiveName, ignored -> new HashSet<>()).add(shape); + } + assertLegacyNamedForeignKeysMatch( + expectedForeignKeysByLegacyName, + readForeignKeys(metadata, schemaName, tableName), + context + ); + + List actualIndexes = readIndexes(metadata, schemaName, tableName); + Set actualNamedIndexes = new HashSet<>(); + for (IndexShape index : actualIndexes) { + if (index.name().toLowerCase(Locale.ROOT).startsWith("idx_")) { + actualNamedIndexes.add(index.name().toLowerCase(Locale.ROOT)); + } + } + assertEquals(expectedIndexes, actualNamedIndexes, context + " generated indexes"); + for (String uniqueColumn : expectedUniqueColumns) { + assertTrue(actualIndexes.stream().anyMatch(index -> index.unique() && index.columns().equals(List.of(uniqueColumn))), + context + " should have a single-column unique constraint for " + uniqueColumn); + } + + Set expectedTriggerNames = new HashSet<>(); + expectedTable.getTriggers().stream() + .map(trigger -> engine == DatabaseEngine.H2 ? trigger.getH2SQL() : trigger.getPgSQL()) + .map(SchemaAssertions::createdTriggerName) + .filter(java.util.Objects::nonNull) + .map(name -> effectiveIdentifier(name, engine)) + .forEach(expectedTriggerNames::add); + long expectedTriggerCount = expectedTriggerNames.size(); + assertEquals(expectedTriggerCount, readStaticDataTriggerCount(connection, engine, schemaName, tableName), context + " Static Data triggers"); + } + + private static void assertLegacyNamedForeignKeysMatch( + Map> expectedByName, + Set actual, + String context + ) { + // Static Data has historically used unqualified foreign-key names. When two relationships on the same + // table share that name, IF NOT EXISTS retains one of them. Preserve and test that behavior so upgrading + // does not rename production constraints. + assertEquals(expectedByName.size(), actual.size(), context + " foreign-key count after legacy-name collisions"); + Set unmatchedActual = new HashSet<>(actual); + for (Map.Entry> entry : expectedByName.entrySet()) { + Set matches = new HashSet<>(unmatchedActual); + matches.retainAll(entry.getValue()); + assertEquals(1, matches.size(), context + " foreign key named " + entry.getKey()); + unmatchedActual.remove(matches.iterator().next()); + } + assertTrue(unmatchedActual.isEmpty(), context + " had unexpected foreign keys " + unmatchedActual); + } + + private static String createdTriggerName(String sql) { + Matcher matcher = CREATE_TRIGGER_NAME.matcher(sql); + if (!matcher.find()) { + return null; + } + return matcher.group(1) == null ? matcher.group(2) : matcher.group(1); + } + + private static String effectiveIdentifier(String identifier, DatabaseEngine engine) { + String effective = identifier; + if (engine == DatabaseEngine.POSTGRESQL && effective.length() > 63) { + effective = effective.substring(0, 63); + } + return effective.toLowerCase(Locale.ROOT); + } + + private static Set readTableNames(Connection connection, String schemaName) throws SQLException { + Set tables = new HashSet<>(); + try (PreparedStatement statement = connection.prepareStatement( + "SELECT table_name FROM information_schema.tables WHERE table_schema = ? AND table_type = 'BASE TABLE'" + )) { + statement.setString(1, schemaName); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + tables.add(resultSet.getString(1)); + } + } + } + return tables; + } + + private static Map readColumns(DatabaseMetaData metadata, String schemaName, String tableName) throws SQLException { + Map columns = new HashMap<>(); + try (ResultSet resultSet = metadata.getColumns(null, schemaName, tableName, null)) { + while (resultSet.next()) { + columns.put(resultSet.getString("COLUMN_NAME"), new ColumnShape( + normalizeType(resultSet.getString("TYPE_NAME")), + resultSet.getInt("NULLABLE") != DatabaseMetaData.columnNoNulls, + normalizeDefault(resultSet.getString("COLUMN_DEF")) + )); + } + } + return columns; + } + + private static List readPrimaryKey(DatabaseMetaData metadata, String schemaName, String tableName) throws SQLException { + Map columns = new HashMap<>(); + try (ResultSet resultSet = metadata.getPrimaryKeys(null, schemaName, tableName)) { + while (resultSet.next()) { + columns.put(resultSet.getShort("KEY_SEQ"), resultSet.getString("COLUMN_NAME")); + } + } + return columns.entrySet().stream().sorted(Map.Entry.comparingByKey()).map(Map.Entry::getValue).toList(); + } + + private static Set readForeignKeys(DatabaseMetaData metadata, String schemaName, String tableName) throws SQLException { + Map keys = new LinkedHashMap<>(); + try (ResultSet resultSet = metadata.getImportedKeys(null, schemaName, tableName)) { + while (resultSet.next()) { + String keyName = resultSet.getString("FK_NAME"); + ForeignKeyRows rows = keys.computeIfAbsent(keyName, ignored -> new ForeignKeyRows( + resultSetValue(resultSet, "PKTABLE_SCHEM"), + resultSetValue(resultSet, "PKTABLE_NAME"), + ruleName(resultSetValueShort(resultSet, "UPDATE_RULE")), + ruleName(resultSetValueShort(resultSet, "DELETE_RULE")) + )); + short sequence = resultSet.getShort("KEY_SEQ"); + rows.localColumns.put(sequence, resultSet.getString("FKCOLUMN_NAME")); + rows.referencedColumns.put(sequence, resultSet.getString("PKCOLUMN_NAME")); + } + } + + Set foreignKeys = new LinkedHashSet<>(); + for (ForeignKeyRows rows : keys.values()) { + foreignKeys.add(new ForeignKeyShape( + orderedValues(rows.localColumns), + rows.referencedSchema, + rows.referencedTable, + orderedValues(rows.referencedColumns), + rows.onUpdate, + rows.onDelete + )); + } + return foreignKeys; + } + + private static List readIndexes(DatabaseMetaData metadata, String schemaName, String tableName) throws SQLException { + Map indexes = new LinkedHashMap<>(); + try (ResultSet resultSet = metadata.getIndexInfo(null, schemaName, tableName, false, false)) { + while (resultSet.next()) { + String indexName = resultSet.getString("INDEX_NAME"); + String columnName = resultSet.getString("COLUMN_NAME"); + if (indexName == null || columnName == null || resultSet.getShort("TYPE") == DatabaseMetaData.tableIndexStatistic) { + continue; + } + IndexRows rows = indexes.computeIfAbsent(indexName, ignored -> new IndexRows(!resultSetValueBoolean(resultSet, "NON_UNIQUE"))); + rows.columns.put(resultSet.getShort("ORDINAL_POSITION"), columnName); + } + } + List result = new ArrayList<>(); + for (Map.Entry entry : indexes.entrySet()) { + result.add(new IndexShape(entry.getKey(), entry.getValue().unique, orderedValues(entry.getValue().columns))); + } + return result; + } + + private static long readStaticDataTriggerCount(Connection connection, DatabaseEngine engine, String schemaName, String tableName) throws SQLException { + String sql = engine == DatabaseEngine.POSTGRESQL + ? "SELECT COUNT(*) FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid JOIN pg_namespace n ON n.oid = c.relnamespace " + + "WHERE n.nspname = ? AND c.relname = ? AND NOT t.tgisinternal AND t.tgname LIKE 'static_data_v3_%'" + : "SELECT COUNT(*) FROM information_schema.triggers WHERE event_object_schema = ? AND event_object_table = ? AND trigger_name LIKE 'static_data_v3_%'"; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, schemaName); + statement.setString(2, tableName); + try (ResultSet resultSet = statement.executeQuery()) { + resultSet.next(); + return resultSet.getLong(1); + } + } + } + + private static String normalizeType(String type) { + String normalized = type.toUpperCase(Locale.ROOT).replaceAll("\\s+", " ").trim(); + return switch (normalized) { + case "CHARACTER VARYING", "VARCHAR", "TEXT" -> "TEXT"; + case "INT4", "INTEGER" -> "INTEGER"; + case "INT8", "BIGINT" -> "BIGINT"; + case "FLOAT4", "REAL" -> "REAL"; + case "FLOAT8", "DOUBLE", "DOUBLE PRECISION" -> "DOUBLE PRECISION"; + case "BOOL", "BOOLEAN" -> "BOOLEAN"; + case "TIMESTAMPTZ", "TIMESTAMP WITH TIME ZONE" -> "TIMESTAMP WITH TIME ZONE"; + default -> normalized; + }; + } + + private static String normalizeDefault(String defaultValue) { + if (defaultValue == null) { + return null; + } + String normalized = defaultValue + .replaceAll("::(?:text|character varying|boolean|integer|bigint|real|double precision)$", "") + .replaceAll("^\\((.*)\\)$", "$1") + .trim(); + if (normalized.equalsIgnoreCase("true") || normalized.equalsIgnoreCase("false")) { + return normalized.toUpperCase(Locale.ROOT); + } + return normalized; + } + + private static String ruleName(short rule) { + return switch (rule) { + case DatabaseMetaData.importedKeyCascade -> "CASCADE"; + case DatabaseMetaData.importedKeySetNull -> "SET NULL"; + case DatabaseMetaData.importedKeySetDefault -> "SET DEFAULT"; + case DatabaseMetaData.importedKeyNoAction, DatabaseMetaData.importedKeyRestrict -> "NO ACTION"; + default -> throw new IllegalArgumentException("Unknown JDBC foreign-key rule " + rule); + }; + } + + private static List orderedValues(Map values) { + return values.entrySet().stream().sorted(Map.Entry.comparingByKey()).map(Map.Entry::getValue).toList(); + } + + private static String resultSetValue(ResultSet resultSet, String column) { + try { + return resultSet.getString(column); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + private static short resultSetValueShort(ResultSet resultSet, String column) { + try { + return resultSet.getShort(column); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + private static boolean resultSetValueBoolean(ResultSet resultSet, String column) { + try { + return resultSet.getBoolean(column); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + public enum DatabaseEngine { + H2, + POSTGRESQL + } + + private record ColumnShape(String type, boolean nullable, String defaultValue) { + } + + private record ForeignKeyShape( + List localColumns, + String referencedSchema, + String referencedTable, + List referencedColumns, + String onUpdate, + String onDelete + ) { + } + + private record IndexShape(String name, boolean unique, List columns) { + } + + private static final class ForeignKeyRows { + private final String referencedSchema; + private final String referencedTable; + private final String onUpdate; + private final String onDelete; + private final Map localColumns = new HashMap<>(); + private final Map referencedColumns = new HashMap<>(); + + private ForeignKeyRows(String referencedSchema, String referencedTable, String onUpdate, String onDelete) { + this.referencedSchema = referencedSchema; + this.referencedTable = referencedTable; + this.onUpdate = onUpdate; + this.onDelete = onDelete; + } + } + + private static final class IndexRows { + private final boolean unique; + private final Map columns = new HashMap<>(); + + private IndexRows(boolean unique) { + this.unique = unique; + } + } +}