From 8b2954156c6a5c69418042b8b322dc04aa4f2a49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Sat, 19 Sep 2026 23:54:29 +0300 Subject: [PATCH 1/2] feat(schema): accept table-level foreign key and check constraints --- .../schema/parser/DefaultSchemaParser.java | 40 ++++-- .../parser/DefaultSchemaParserTest.java | 128 ++++++++++++++++++ 2 files changed, 158 insertions(+), 10 deletions(-) diff --git a/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java b/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java index 2db9093..a5f98c2 100644 --- a/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java +++ b/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java @@ -11,8 +11,10 @@ import net.sf.jsqlparser.schema.MultiPartName; import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.create.table.CheckConstraint; import net.sf.jsqlparser.statement.create.table.ColumnDefinition; import net.sf.jsqlparser.statement.create.table.CreateTable; +import net.sf.jsqlparser.statement.create.table.ForeignKeyIndex; import net.sf.jsqlparser.statement.create.table.Index; import java.util.ArrayList; @@ -192,19 +194,13 @@ private List parseTableConstraints(CreateTable createTable) { List constraints = new ArrayList<>(); for (Index index : indexes) { - String indexType = index.getType(); - - ConstraintType type = switch (indexType.toUpperCase(Locale.ROOT)) { - case "PRIMARY KEY" -> ConstraintType.PRIMARY_KEY; - case "UNIQUE" -> ConstraintType.UNIQUE; - default -> throw new UnsupportedOperationException( - "Unsupported table constraint: " + indexType - ); - }; + if (isIgnoredTableConstraint(index)) { + continue; + } constraints.add( new Constraint( - type, + constraintType(index), index.getColumnsNames().stream() .map(MultiPartName::unquote) .toList() @@ -214,4 +210,28 @@ private List parseTableConstraints(CreateTable createTable) { return constraints; } + + /** + * Foreign key and check constraints are accepted but not modeled, because + * they do not affect the generated Java types. + */ + private boolean isIgnoredTableConstraint(Index index) { + return index instanceof ForeignKeyIndex || index instanceof CheckConstraint; + } + + private ConstraintType constraintType(Index index) { + String indexType = index.getType(); + + String declaredType = indexType == null + ? "" + : indexType.toUpperCase(Locale.ROOT); + + return switch (declaredType) { + case "PRIMARY KEY" -> ConstraintType.PRIMARY_KEY; + case "UNIQUE" -> ConstraintType.UNIQUE; + default -> throw new UnsupportedOperationException( + "Unsupported table constraint: " + indexType + ); + }; + } } diff --git a/src/test/java/dev/sqlcj/schema/parser/DefaultSchemaParserTest.java b/src/test/java/dev/sqlcj/schema/parser/DefaultSchemaParserTest.java index 4d3d1db..3d373d6 100644 --- a/src/test/java/dev/sqlcj/schema/parser/DefaultSchemaParserTest.java +++ b/src/test/java/dev/sqlcj/schema/parser/DefaultSchemaParserTest.java @@ -291,6 +291,134 @@ CREATE TABLE users ( ); } + @Test + void shouldParseColumnSpecificationsThatDoNotAffectTypes() { + String sql = """ + CREATE TABLE orders ( + id BIGINT NOT NULL PRIMARY KEY, + quantity INTEGER NOT NULL DEFAULT 0 CHECK (quantity > 0), + status VARCHAR(32) DEFAULT 'new', + created_at TIMESTAMP DEFAULT now(), + user_id BIGINT REFERENCES users (id), + owner_id BIGINT REFERENCES users (id) ON DELETE CASCADE + ); + """; + + Schema schema = parser.parse(sql); + + Table table = schema.tables().getFirst(); + + assertEquals( + List.of( + new Column("id", ColumnType.BIGINT, false), + new Column("quantity", ColumnType.INTEGER, false), + new Column("status", ColumnType.VARCHAR, true), + new Column("created_at", ColumnType.TIMESTAMP, true), + new Column("user_id", ColumnType.BIGINT, true), + new Column("owner_id", ColumnType.BIGINT, true) + ), + table.columns() + ); + + assertEquals( + List.of( + new Constraint( + ConstraintType.PRIMARY_KEY, + List.of("id") + ) + ), + table.constraints() + ); + } + + @ParameterizedTest + @ValueSource( + strings = { + "FOREIGN KEY (user_id) REFERENCES users (id)", + "CONSTRAINT orders_user_fk FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE", + "CHECK (quantity > 0)", + "CONSTRAINT orders_quantity_check CHECK (quantity > 0)" + } + ) + void shouldIgnoreForeignKeyAndCheckTableConstraints(String tableConstraint) { + String sql = """ + CREATE TABLE orders ( + id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + quantity INTEGER NOT NULL, + %s + ); + """ + .formatted(tableConstraint); + + Schema schema = parser.parse(sql); + + Table table = schema.tables().getFirst(); + + assertEquals( + List.of( + new Column("id", ColumnType.BIGINT, false), + new Column("user_id", ColumnType.BIGINT, false), + new Column("quantity", ColumnType.INTEGER, false) + ), + table.columns() + ); + + assertTrue(table.constraints().isEmpty()); + } + + @Test + void shouldParseNamedTableConstraintsAlongsideIgnoredOnes() { + String sql = """ + CREATE TABLE orders ( + id BIGINT NOT NULL, + code VARCHAR(32) NOT NULL, + user_id BIGINT NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + CONSTRAINT orders_pk PRIMARY KEY (id), + CONSTRAINT orders_user_fk FOREIGN KEY (user_id) REFERENCES users (id), + CONSTRAINT orders_quantity_check CHECK (quantity > 0), + CONSTRAINT orders_code_unique UNIQUE (code), + FOREIGN KEY (code) REFERENCES codes (code), + CHECK (quantity < 100) + ); + """; + + Schema schema = parser.parse(sql); + + Table table = schema.tables().getFirst(); + + assertEquals( + List.of( + new Constraint( + ConstraintType.PRIMARY_KEY, + List.of("id") + ), + new Constraint( + ConstraintType.UNIQUE, + List.of("code") + ) + ), + table.constraints() + ); + } + + @Test + void shouldRejectUnsupportedSchemaStatement() { + String sql = """ + CREATE TABLE users ( + id BIGINT NOT NULL + ); + + ALTER TABLE users ADD COLUMN name VARCHAR(255); + """; + + assertThrows( + UnsupportedOperationException.class, + () -> parser.parse(sql) + ); + } + @Test void shouldThrowSchemaParseExceptionForInvalidSql() { String sql = """ From f4e33ad21ed582b60f402d1166797c9321c9f8f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Sat, 19 Sep 2026 23:54:44 +0300 Subject: [PATCH 2/2] test(integration): execute a snapshot with foreign key and check constraints --- .../compiler/PostgresIntegrationTest.java | 85 ++++++++++++++++++- 1 file changed, 82 insertions(+), 3 deletions(-) diff --git a/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java b/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java index 326ab61..527a720 100644 --- a/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java +++ b/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java @@ -74,6 +74,35 @@ updated_at TIMESTAMP(3) WITH TIME ZONE ); """; + /** + * A snapshot whose table-level foreign key and check constraints are + * accepted and ignored by the schema parser, together with the column + * defaults and named constraints that surround them. + */ + private static final String CONSTRAINT_SCHEMA = """ + CREATE TABLE customers + ( + id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + CONSTRAINT customers_pk PRIMARY KEY (id), + CONSTRAINT customers_name_unique UNIQUE (name) + ); + + CREATE TABLE customer_orders + ( + id BIGINT NOT NULL, + customer_id BIGINT NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + status VARCHAR(32) DEFAULT 'new', + created_at TIMESTAMP DEFAULT now(), + PRIMARY KEY (id), + CONSTRAINT customer_orders_customer_fk FOREIGN KEY (customer_id) + REFERENCES customers (id) ON DELETE CASCADE, + CONSTRAINT customer_orders_quantity_check CHECK (quantity > 0), + CHECK (status <> '') + ); + """; + private static final UUID EXTERNAL_ID = UUID.fromString("3f2504e0-4f89-11d3-9a0c-0305e82c3301"); private static final OffsetDateTime UPDATED_AT = OffsetDateTime.of( @@ -128,6 +157,8 @@ static void stopPostgres() { */ @BeforeEach void resetDatabase() throws Exception { + execute("DROP TABLE IF EXISTS customer_orders"); + execute("DROP TABLE IF EXISTS customers"); execute("DROP TABLE IF EXISTS users"); execute(SCHEMA); } @@ -453,16 +484,64 @@ INSERT INTO users (id, code, serial_id, revision, external_id, updated_at) } /** - * Compiles the schema snapshot and the given queries, then compiles every - * generated Java file into an isolated temporary classes directory. + * Proves that a snapshot carrying table-level foreign key and check + * constraints, column defaults, and named constraints is valid PostgreSQL + * DDL, compiles through the pipeline, and reads back the values the + * database defaults produced. */ + @Test + void shouldExecuteGeneratedQueryForSnapshotWithIgnoredTableConstraints() throws Exception { + execute(CONSTRAINT_SCHEMA); + + Path classesDirectory = generateAndCompile( + CONSTRAINT_SCHEMA, + """ + -- name: GetCustomerOrder :one + SELECT id, customer_id, quantity, status + FROM customer_orders + WHERE id = $1; + """ + ); + + execute("INSERT INTO customers (id, name) VALUES (1, 'Alice')"); + execute("INSERT INTO customer_orders (id, customer_id) VALUES (10, 1)"); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Object query = newQuery(classLoader, "GetCustomerOrder"); + + Method method = query.getClass().getMethod("getCustomerOrder", Long.class); + + Object result = method.invoke(query, 10L); + + assertNotNull(result); + + assertEquals( + List.of("id", "customer_id", "quantity", "status"), + recordComponentNames(result) + ); + + assertEquals(10L, component(result, "id")); + assertEquals(1L, component(result, "customer_id")); + assertEquals(1, component(result, "quantity")); + assertEquals("new", component(result, "status")); + } + } + private Path generateAndCompile(String queries) throws Exception { + return generateAndCompile(SCHEMA, queries); + } + + /** + * Compiles the given schema snapshot and queries, then compiles every + * generated Java file into an isolated temporary classes directory. + */ + private Path generateAndCompile(String schema, String queries) throws Exception { Path schemaFile = tempDir.resolve("schema.sql"); Path queriesFile = tempDir.resolve("queries.sql"); Path generatedDirectory = tempDir.resolve("generated"); Path classesDirectory = tempDir.resolve("classes"); - Files.writeString(schemaFile, SCHEMA); + Files.writeString(schemaFile, schema); Files.writeString(queriesFile, queries); Config config = new Config(