From 46765bd77251e9196d6445fd770823ee678033e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Wed, 14 Oct 2026 22:14:33 +0300 Subject: [PATCH] test(compiler): run reference service subqueries, CTEs, and bulk writes Drop the subquery casts the membership checks no longer need, and add a NOT EXISTS check, a CTE report, a subquery source, a multi-row insert, an unnest bulk insert, an INSERT ... SELECT, an UPDATE ... FROM, and a DELETE ... USING to the reference service, each executed against PostgreSQL 16 through the generated repository. The unsupported catalogue gains a recursive and a data-modifying CTE. --- docs/queries.md | 12 +- .../ReferenceServiceIntegrationTest.java | 218 ++++++++++++++++++ .../resources/reference-service/queries.sql | 75 +++++- .../queries/data_modifying_cte.sql | 7 + .../unsupported/queries/recursive_cte.sql | 9 + 5 files changed, 317 insertions(+), 4 deletions(-) create mode 100644 sqlcj-cli/src/test/resources/reference-service/unsupported/queries/data_modifying_cte.sql create mode 100644 sqlcj-cli/src/test/resources/reference-service/unsupported/queries/recursive_cte.sql diff --git a/docs/queries.md b/docs/queries.md index 0b530cc..83aca63 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -1956,11 +1956,19 @@ Reference service: role, its conflict-target predicate over a partial unique index, and the aliased `RETURNING` of an optimistic update, against PostgreSQL 16 through the generated repository. +- `ReferenceServiceIntegrationTest.shouldExecuteTheGeneratedNestedQueriesAndMultiRowWrites` + executes the reference service's `NOT EXISTS` check, its CTE report joined to + `projects`, its aliased subquery source with a `LIMIT` placeholder, its + two-row insert sharing one placeholder, its + `INSERT ... SELECT ... unnest(:userIds::bigint[]) ON CONFLICT DO NOTHING`, + its `INSERT ... SELECT` from a table, its `UPDATE ... FROM` returning the + shared task row, and its `DELETE ... USING`, against PostgreSQL 16 through + the generated repository. - `ReferenceServiceIntegrationTest.shouldRejectEachUnsupportedCatalogueQuery` covers the whole located diagnostic line, and the absence of generated output, of every entry of the reference service's unsupported query catalogue: the two set operations, the `RIGHT`, `FULL`, `USING`, `NATURAL`, - `CROSS`, comma, and `LATERAL` joins, an uncast - `lower(:email)`, and a placeholder beside a subscripted column, and + `CROSS`, comma, and `LATERAL` joins, a recursive and a data-modifying CTE, an + uncast `lower(:email)`, and a placeholder beside a subscripted column, and `ReferenceServiceIntegrationTest.shouldCatalogueExactlyTheUnsupportedQueryFiles` covers that the catalogue directory holds exactly those queries. diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/ReferenceServiceIntegrationTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/ReferenceServiceIntegrationTest.java index 3a1249f..64e799e 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/ReferenceServiceIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/ReferenceServiceIntegrationTest.java @@ -143,6 +143,17 @@ class ReferenceServiceIntegrationTest { "ListProjectsWithLatestTask", "LATERAL is not supported." ), + new UnsupportedQuery( + "recursive_cte.sql", + "ListPriorityLevels", + "WITH RECURSIVE is not supported." + ), + new UnsupportedQuery( + "data_modifying_cte.sql", + "DeleteDoneTasks", + "A data-modifying statement in WITH is not supported; " + + "run it as a query of its own." + ), new UnsupportedQuery( "uncast_lower.sql", "FindUserByEmail", @@ -746,6 +757,213 @@ void shouldExecuteTheGeneratedMilestoneQueryShapes() throws Exception { } } + /** + * The generated repository executes the nested-query and multi-row write + * shapes this milestone adds against the migrated database: the + * {@code NOT EXISTS} check, the CTE report, the aliased subquery source + * with a {@code LIMIT} placeholder, the multi-row insert, the + * {@code unnest} bulk insert, the {@code INSERT ... SELECT} from a table, + * the joined update returning the shared task row, and the {@code USING} + * delete. + * + *

The database is shared with the other tests of this class, so this one + * works inside its own organization, projects, and users, and writes no + * audit entry. + */ + @Test + void shouldExecuteTheGeneratedNestedQueriesAndMultiRowWrites() throws Exception { + Path generatedDirectory = tempDir.resolve("generated-nested"); + + generate(List.of(migrations().toString()), generatedDirectory); + + Path classesDirectory = compile(generatedDirectory, tempDir.resolve("classes-nested")); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Object repository = newRepository(classLoader); + + Long organizationId = (Long) component( + invoke(repository, "createOrganization", "Nest Test", "{}"), + "id" + ); + + Long authorId = (Long) component( + invoke(repository, "createUser", organizationId, "edsger@example.com", "Edsger Dijkstra"), + "id" + ); + + Long otherAuthorId = (Long) component( + invoke(repository, "createUser", organizationId, "barbara@example.com", "Barbara Liskov"), + "id" + ); + + Long nestedProjectId = (Long) component( + invoke(repository, "createProject", organizationId, "NEST", "Nested"), + "id" + ); + + Long copyProjectId = (Long) component( + invoke(repository, "createProject", organizationId, "COPY", "Copies"), + "id" + ); + + assertEquals( + 2, + invoke(repository, "createTwoTasks", nestedProjectId, "Alpha", "Beta"), + "the multi-row insert writes both rows of its VALUES list" + ); + + assertEquals( + 2, + invoke(repository, "addMembers", organizationId, List.of(authorId, otherAuthorId)), + "the unnest bulk insert writes one membership per array element" + ); + + assertEquals( + 0, + invoke(repository, "addMembers", organizationId, List.of(authorId, otherAuthorId)), + "the unnest bulk insert keeps the existing memberships" + ); + + List uncommented = (List) invoke( + repository, + "listTasksUncommentedBy", + nestedProjectId, + authorId + ); + + assertEquals( + List.of("Alpha", "Beta"), + componentsOf(uncommented, "title"), + "both inserted tasks are uncommented" + ); + + Long alphaId = (Long) component(uncommented.get(0), "id"); + + assertNotNull(invoke(repository, "addComment", alphaId, authorId, "Mine.")); + + assertEquals( + List.of("Beta"), + componentsOf( + (List) invoke( + repository, + "listTasksUncommentedBy", + nestedProjectId, + authorId + ), + "title" + ), + "NOT EXISTS drops the task its correlated author commented on" + ); + + assertEquals( + List.of("Alpha", "Beta"), + componentsOf( + (List) invoke( + repository, + "listTasksUncommentedBy", + nestedProjectId, + otherAuthorId + ), + "title" + ), + "NOT EXISTS keeps every task the other author left uncommented" + ); + + List onePriority = (List) invoke( + repository, + "countOpenTasksByProject", + (short) 1, + organizationId + ); + + assertEquals(List.of("NEST"), componentsOf(onePriority, "code")); + + assertEquals( + List.of(1L), + componentsOf(onePriority, "openTasks"), + "the CTE report counts the tasks its placeholder admits" + ); + + List everyPriority = (List) invoke( + repository, + "countOpenTasksByProject", + (short) 5, + organizationId + ); + + assertEquals(List.of("NEST"), componentsOf(everyPriority, "code")); + + assertEquals(List.of(2L), componentsOf(everyPriority, "openTasks")); + + assertEquals( + List.of("Beta"), + componentsOf( + (List) invoke(repository, "listRecentTaskTitles", nestedProjectId, 1), + "title" + ), + "the subquery source applies its own LIMIT placeholder" + ); + + assertEquals( + 2, + invoke(repository, "copyOpenTasks", copyProjectId, nestedProjectId), + "INSERT ... SELECT copies the source project's open tasks" + ); + + List raised = (List) invoke( + repository, + "raiseProjectTaskPriority", + (short) 5, + organizationId, + "COPY" + ); + + assertEquals(2, raised.size(), "the joined update raises the copied tasks"); + + assertEquals( + "TasksRow", + raised.get(0).getClass().getSimpleName(), + "RETURNING t.* returns the shared row record" + ); + + assertEquals( + List.of("Alpha", "Beta"), + componentsOf(raised, "title").stream() + .map(String.class::cast) + .sorted() + .toList() + ); + + assertEquals( + List.of(copyProjectId, copyProjectId), + componentsOf(raised, "projectId") + ); + + assertEquals( + List.of((short) 5, (short) 5), + componentsOf(raised, "priority"), + "the joined update sets the priority its placeholder states" + ); + + assertEquals( + 1, + invoke(repository, "deleteProjectComments", nestedProjectId), + "the USING delete removes the comments of its project's tasks" + ); + + List bothProjects = (List) invoke( + repository, + "countOpenTasksByProject", + (short) 5, + organizationId + ); + + assertEquals(List.of("COPY", "NEST"), componentsOf(bothProjects, "code")); + + assertEquals(List.of(2L, 2L), componentsOf(bothProjects, "openTasks")); + } + } + /** * Every entry of the unsupported query catalogue is rejected with its whole * located diagnostic line against the migration history, and writes no diff --git a/sqlcj-cli/src/test/resources/reference-service/queries.sql b/sqlcj-cli/src/test/resources/reference-service/queries.sql index 0dbea89..49dd8ab 100644 --- a/sqlcj-cli/src/test/resources/reference-service/queries.sql +++ b/sqlcj-cli/src/test/resources/reference-service/queries.sql @@ -100,7 +100,7 @@ FROM projects p WHERE EXISTS (SELECT 1 FROM memberships m WHERE m.organization_id = p.organization_id - AND m.user_id = :userId::bigint) + AND m.user_id = :userId) ORDER BY p.id; -- name: ListOrganizationTasks :many @@ -109,7 +109,7 @@ SELECT t.id, FROM tasks t WHERE t.project_id IN (SELECT p.id FROM projects p - WHERE p.organization_id = :organizationId::bigint) + WHERE p.organization_id = :organizationId) ORDER BY t.id; -- name: ListTasksWithRecentComments :many @@ -172,3 +172,74 @@ UPDATE users SET deleted_at = now() WHERE id = :id AND deleted_at IS NULL; + +-- name: ListTasksUncommentedBy :many +SELECT t.id, + t.title +FROM tasks t +WHERE t.project_id = :projectId + AND NOT EXISTS (SELECT 1 + FROM comments c + WHERE c.task_id = t.id + AND c.author_id = :authorId) +ORDER BY t.id; + +-- name: CountOpenTasksByProject :many +WITH open_tasks AS (SELECT project_id + FROM tasks + WHERE status = 'open' + AND priority <= :maxPriority) +SELECT p.code, + COUNT(*) AS open_tasks +FROM projects p + JOIN open_tasks o ON o.project_id = p.id +WHERE p.organization_id = :organizationId +GROUP BY p.code +ORDER BY p.code; + +-- name: ListRecentTaskTitles :many +SELECT recent.id, + recent.title +FROM (SELECT t.id, + t.title + FROM tasks t + WHERE t.project_id = :projectId + ORDER BY t.id DESC + LIMIT :limit) recent +ORDER BY recent.title; + +-- name: CreateTwoTasks :exec +INSERT INTO tasks (project_id, title, priority) +VALUES (:projectId, :firstTitle, 1), + (:projectId, :secondTitle, 2); + +-- name: AddMembers :exec +INSERT INTO memberships (organization_id, user_id) +SELECT :organizationId, unnest(:userIds::bigint[]) +ON CONFLICT DO NOTHING; + +-- name: CopyOpenTasks :exec +INSERT INTO tasks (project_id, title, priority, labels) +SELECT :targetProjectId, + title, + priority, + labels +FROM tasks +WHERE project_id = :sourceProjectId + AND status = 'open'; + +-- name: RaiseProjectTaskPriority :many +UPDATE tasks t +SET priority = :priority +FROM projects p +WHERE p.id = t.project_id + AND p.organization_id = :organizationId + AND p.code = :code +RETURNING t.*; + +-- name: DeleteProjectComments :exec +DELETE +FROM comments c + USING tasks t +WHERE t.id = c.task_id + AND t.project_id = :projectId; diff --git a/sqlcj-cli/src/test/resources/reference-service/unsupported/queries/data_modifying_cte.sql b/sqlcj-cli/src/test/resources/reference-service/unsupported/queries/data_modifying_cte.sql new file mode 100644 index 0000000..b676666 --- /dev/null +++ b/sqlcj-cli/src/test/resources/reference-service/unsupported/queries/data_modifying_cte.sql @@ -0,0 +1,7 @@ +-- Outside the supported set: a WITH body writes no rows. +-- name: DeleteDoneTasks :many +WITH removed AS (DELETE FROM tasks + WHERE status = 'done' + RETURNING id) +SELECT id +FROM removed; diff --git a/sqlcj-cli/src/test/resources/reference-service/unsupported/queries/recursive_cte.sql b/sqlcj-cli/src/test/resources/reference-service/unsupported/queries/recursive_cte.sql new file mode 100644 index 0000000..4e64ba7 --- /dev/null +++ b/sqlcj-cli/src/test/resources/reference-service/unsupported/queries/recursive_cte.sql @@ -0,0 +1,9 @@ +-- Outside the supported set: sqlcj analyzes no recursive CTE. +-- name: ListPriorityLevels :many +WITH RECURSIVE levels AS (SELECT 1 AS level + UNION ALL + SELECT level + 1 + FROM levels + WHERE level < 5) +SELECT level +FROM levels;