From d711b83f5af1bb5b51540f8c61e86adb3d28641c Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 18 Oct 2026 08:33:44 +0800 Subject: [PATCH] Make DuckDB canonical relation index reconciliation idempotent --- .../core/duck/DuckDataServiceExecutor.java | 13 ++++ .../teaql/duckdb/DuckDbIntegrationTest.java | 70 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/teaql-duckdb/src/main/java/io/teaql/core/duck/DuckDataServiceExecutor.java b/teaql-duckdb/src/main/java/io/teaql/core/duck/DuckDataServiceExecutor.java index 573f5f55e..5f595b4ed 100644 --- a/teaql-duckdb/src/main/java/io/teaql/core/duck/DuckDataServiceExecutor.java +++ b/teaql-duckdb/src/main/java/io/teaql/core/duck/DuckDataServiceExecutor.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.Map; +import java.util.Optional; public class DuckDataServiceExecutor extends SqlDataServiceExecutor { @@ -58,6 +59,18 @@ public List> getTableColumns(String tableName) { + "FROM information_schema.columns WHERE table_name = ?"; return getExecutionAdapter().queryForList(sql, new Object[] {tableName}); } + + @Override + public Optional indexExists( + UserContext context, String tableName, String indexName) { + List> rows = getExecutionAdapter().queryForList( + "SELECT 1 AS present FROM duckdb_indexes() " + + "WHERE database_name = current_database() " + + "AND schema_name = current_schema() " + + "AND table_name = ? AND index_name = ? LIMIT 1", + new Object[] {tableName, indexName}); + return Optional.of(!rows.isEmpty()); + } }; for (EntityDescriptor descriptor : descriptors) { diff --git a/teaql-duckdb/src/test/java/io/teaql/duckdb/DuckDbIntegrationTest.java b/teaql-duckdb/src/test/java/io/teaql/duckdb/DuckDbIntegrationTest.java index 00fae1538..c1a14925b 100644 --- a/teaql-duckdb/src/test/java/io/teaql/duckdb/DuckDbIntegrationTest.java +++ b/teaql-duckdb/src/test/java/io/teaql/duckdb/DuckDbIntegrationTest.java @@ -16,6 +16,7 @@ import io.teaql.core.duck.DuckDataServiceExecutor; import io.teaql.core.meta.SimpleEntityMetaFactory; import io.teaql.core.sql.GenericSQLProperty; +import io.teaql.core.sql.GenericSQLRelation; import io.teaql.core.sql.SQLEntityDescriptor; import io.teaql.provider.jdbc.JdbcSqlExecutor; import io.teaql.runtime.DefaultUserContext; @@ -40,6 +41,14 @@ public class DuckDbIntegrationTest { + public static class IndexedParent extends BaseEntity { + @Override public String typeName() { return "IndexedParent"; } + } + + public static class IndexedChild extends BaseEntity { + @Override public String typeName() { return "IndexedChild"; } + } + private static UserContext context; private static JdbcSqlExecutor sql; private static InspectableDuckDataServiceExecutor executor; @@ -216,6 +225,67 @@ public void exposesExpectedEngineVersion() throws Exception { } } + @Test + public void repeatedEnsureSchemaFindsCanonicalRelationIndexInDuckDbCatalog() throws Exception { + Path indexDatabase = databaseDirectory.resolve("relation-index-" + UUID.randomUUID() + ".db"); + String indexUrl = "jdbc:duckdb:" + indexDatabase; + JdbcSqlExecutor indexSql = new JdbcSqlExecutor(new DriverManagerDataSource(indexUrl)); + SimpleEntityMetaFactory metadata = new SimpleEntityMetaFactory(); + + SQLEntityDescriptor parent = new SQLEntityDescriptor(); + parent.setType("IndexedParent"); + parent.setTargetType(IndexedParent.class); + parent.setEntitySupplier(IndexedParent::new); + parent.setDataService("duckdb"); + parent.with("table_name", "indexed_parent_data"); + ((GenericSQLProperty) parent.addSimpleProperty("id", Long.class)).setColumnType("BIGINT"); + ((GenericSQLProperty) parent.addSimpleProperty("version", Long.class)).setColumnType("BIGINT"); + metadata.register(parent); + + SQLEntityDescriptor child = new SQLEntityDescriptor(); + child.setType("IndexedChild"); + child.setTargetType(IndexedChild.class); + child.setEntitySupplier(IndexedChild::new); + child.setDataService("duckdb"); + child.with("table_name", "indexed_child_data"); + ((GenericSQLProperty) child.addSimpleProperty("id", Long.class)).setColumnType("BIGINT"); + ((GenericSQLProperty) child.addSimpleProperty("version", Long.class)).setColumnType("BIGINT"); + GenericSQLRelation relation = (GenericSQLRelation) child.addObjectProperty( + metadata, "parent", "IndexedParent", "children", IndexedParent.class, + GenericSQLRelation::new); + relation.setTableName("indexed_child_data"); + relation.setColumnName("parent"); + relation.setColumnType("BIGINT"); + metadata.register(child); + + UserContext indexContext = new DefaultUserContext(TeaQLRuntime.builder() + .metadata(metadata) + .dataService("duckdb", new DuckDataServiceExecutor("duckdb", indexSql)) + .idGenerationService((ignored, entity) -> 1L) + .build()); + try { + indexContext.ensureSchema(); + var first = indexSql.queryForList( + "SELECT index_name FROM duckdb_indexes() WHERE database_name = current_database() " + + "AND schema_name = current_schema() AND table_name = ?", + new Object[] {"indexed_child_data"}); + assertEquals("first ensure must create one relation index", 1, first.size()); + indexContext.ensureSchema(); + var second = indexSql.queryForList( + "SELECT index_name FROM duckdb_indexes() WHERE database_name = current_database() " + + "AND schema_name = current_schema() AND table_name = ?", + new Object[] {"indexed_child_data"}); + assertEquals("second ensure must reuse the same relation index", first, second); + } finally { + try (Connection connection = DriverManager.getConnection(indexUrl); + Statement statement = connection.createStatement()) { + statement.execute("CHECKPOINT"); + } + Files.deleteIfExists(Path.of(indexDatabase + ".wal")); + Files.deleteIfExists(indexDatabase); + } + } + @Test public void executesTeaQlCrudAndTypedQuery() { Task task = new Task().updateTitle("Verify DuckDB").updateStatus("OPEN");