From df814d24718b378ab26fe3e0725398d396e00463 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 20 Jul 2026 08:28:58 +0000 Subject: [PATCH] fix(deparser): add MergeWhenClause visitor and dot-join SecLabelStmt object names --- __fixtures__/generated/generated.json | 8 ++ .../kitchen-sink/misc/merge-seclabel.sql | 11 +++ .../kitchen-sink/misc-merge-seclabel.test.ts | 16 ++++ packages/deparser/src/deparser.ts | 86 ++++++++++++++++++- 4 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 __fixtures__/kitchen-sink/misc/merge-seclabel.sql create mode 100644 packages/deparser/__tests__/kitchen-sink/misc-merge-seclabel.test.ts diff --git a/__fixtures__/generated/generated.json b/__fixtures__/generated/generated.json index 654ea6977..b542439a1 100644 --- a/__fixtures__/generated/generated.json +++ b/__fixtures__/generated/generated.json @@ -21294,6 +21294,14 @@ "misc/missing-types-12.sql": "ALTER SUBSCRIPTION my_subscription OWNER TO new_owner", "misc/missing-types-13.sql": "ALTER STATISTICS my_statistics OWNER TO new_owner", "misc/missing-types-14.sql": "ALTER TABLESPACE my_tablespace OWNER TO new_owner", + "misc/merge-seclabel-1.sql": "MERGE INTO tgt.users u USING src.staged s ON u.id = s.id WHEN MATCHED THEN UPDATE SET name = s.name WHEN NOT MATCHED THEN INSERT (id, name) VALUES (s.id, s.name)", + "misc/merge-seclabel-2.sql": "MERGE INTO tgt.users u USING src.staged s ON u.id = s.id WHEN MATCHED AND s.deleted THEN DELETE WHEN MATCHED THEN UPDATE SET name = s.name, updated_at = now() WHEN NOT MATCHED THEN DO NOTHING", + "misc/merge-seclabel-3.sql": "MERGE INTO tgt.users u USING src.staged s ON u.id = s.id WHEN NOT MATCHED BY SOURCE THEN DELETE WHEN NOT MATCHED THEN INSERT (id) VALUES (s.id)", + "misc/merge-seclabel-4.sql": "MERGE INTO tgt.users u USING src.staged s ON u.id = s.id WHEN NOT MATCHED THEN INSERT DEFAULT VALUES", + "misc/merge-seclabel-5.sql": "MERGE INTO tgt.users u USING src.staged s ON u.id = s.id WHEN MATCHED THEN UPDATE SET name = s.name RETURNING u.id, u.name", + "misc/merge-seclabel-6.sql": "SECURITY LABEL FOR anon ON COLUMN myschema.users.name IS 'MASKED WITH FUNCTION anon.fake_name()'", + "misc/merge-seclabel-7.sql": "SECURITY LABEL FOR anon ON TABLE myschema.users IS 'sensitive'", + "misc/merge-seclabel-8.sql": "SECURITY LABEL ON FUNCTION myschema.fn() IS 'labeled'", "misc/launchql-ext-types-1.sql": "CREATE DOMAIN attachment AS jsonb CHECK ( value ?& ARRAY['url', 'mime'] AND (value->>'url') ~ '^(https?)://[^\\s/$.?#].[^\\s]*$' )", "misc/launchql-ext-types-2.sql": "COMMENT ON DOMAIN attachment IS E'@name launchqlInternalTypeAttachment'", "misc/launchql-ext-types-3.sql": "CREATE DOMAIN email AS citext CHECK ( value ~ '^[a-zA-Z0-9.!#$%&''*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$' )", diff --git a/__fixtures__/kitchen-sink/misc/merge-seclabel.sql b/__fixtures__/kitchen-sink/misc/merge-seclabel.sql new file mode 100644 index 000000000..bf072ea07 --- /dev/null +++ b/__fixtures__/kitchen-sink/misc/merge-seclabel.sql @@ -0,0 +1,11 @@ +-- MergeWhenClause deparse support (previously: "Deparser does not handle node type: MergeWhenClause") +MERGE INTO tgt.users u USING src.staged s ON u.id = s.id WHEN MATCHED THEN UPDATE SET name = s.name WHEN NOT MATCHED THEN INSERT (id, name) VALUES (s.id, s.name); +MERGE INTO tgt.users u USING src.staged s ON u.id = s.id WHEN MATCHED AND s.deleted THEN DELETE WHEN MATCHED THEN UPDATE SET name = s.name, updated_at = now() WHEN NOT MATCHED THEN DO NOTHING; +MERGE INTO tgt.users u USING src.staged s ON u.id = s.id WHEN NOT MATCHED BY SOURCE THEN DELETE WHEN NOT MATCHED THEN INSERT (id) VALUES (s.id); +MERGE INTO tgt.users u USING src.staged s ON u.id = s.id WHEN NOT MATCHED THEN INSERT DEFAULT VALUES; +MERGE INTO tgt.users u USING src.staged s ON u.id = s.id WHEN MATCHED THEN UPDATE SET name = s.name RETURNING u.id, u.name; + +-- SecLabelStmt qualified object names must be dot-joined (previously emitted "schema", table, column) +SECURITY LABEL FOR anon ON COLUMN myschema.users.name IS 'MASKED WITH FUNCTION anon.fake_name()'; +SECURITY LABEL FOR anon ON TABLE myschema.users IS 'sensitive'; +SECURITY LABEL ON FUNCTION myschema.fn() IS 'labeled'; diff --git a/packages/deparser/__tests__/kitchen-sink/misc-merge-seclabel.test.ts b/packages/deparser/__tests__/kitchen-sink/misc-merge-seclabel.test.ts new file mode 100644 index 000000000..7272ff931 --- /dev/null +++ b/packages/deparser/__tests__/kitchen-sink/misc-merge-seclabel.test.ts @@ -0,0 +1,16 @@ + +import { FixtureTestUtils } from '../../test-utils'; +const fixtures = new FixtureTestUtils(); + +it('misc-merge-seclabel', async () => { + await fixtures.runFixtureTests([ + "misc/merge-seclabel-1.sql", + "misc/merge-seclabel-2.sql", + "misc/merge-seclabel-3.sql", + "misc/merge-seclabel-4.sql", + "misc/merge-seclabel-5.sql", + "misc/merge-seclabel-6.sql", + "misc/merge-seclabel-7.sql", + "misc/merge-seclabel-8.sql" +]); +}); diff --git a/packages/deparser/src/deparser.ts b/packages/deparser/src/deparser.ts index ff34e10e6..6ed691666 100644 --- a/packages/deparser/src/deparser.ts +++ b/packages/deparser/src/deparser.ts @@ -8720,7 +8720,16 @@ export class Deparser implements DeparserVisitor { } if (node.object) { - output.push(this.visit(node.object, context)); + // Qualified names arrive as a List of String nodes and must be + // dot-joined (visiting the List directly would comma-join them). + if (typeof node.object === 'object' && 'List' in node.object) { + const list = node.object.List as t.List; + const objectParts = ListUtils.unwrapList(list.items) + .map(item => this.visit(item, context)); + output.push(objectParts.join('.')); + } else { + output.push(this.visit(node.object, context)); + } } output.push('IS'); @@ -9318,6 +9327,81 @@ export class Deparser implements DeparserVisitor { output.push(whenClauses); } + if (node.returningList && node.returningList.length > 0) { + output.push('RETURNING'); + const returningList = ListUtils.unwrapList(node.returningList) + .map(target => this.visit(target, context)) + .join(', '); + output.push(returningList); + } + + return output.join(' '); + } + + MergeWhenClause(node: t.MergeWhenClause, context: DeparserContext): string { + const output: string[] = ['WHEN']; + + switch (node.matchKind) { + case 'MERGE_WHEN_MATCHED': + output.push('MATCHED'); + break; + case 'MERGE_WHEN_NOT_MATCHED_BY_SOURCE': + output.push('NOT MATCHED BY SOURCE'); + break; + case 'MERGE_WHEN_NOT_MATCHED_BY_TARGET': + output.push('NOT MATCHED'); + break; + } + + if (node.condition) { + output.push('AND'); + output.push(this.visit(node.condition, context)); + } + + output.push('THEN'); + + switch (node.commandType) { + case 'CMD_UPDATE': { + output.push('UPDATE SET'); + const assignments = ListUtils.unwrapList(node.targetList) + .map(target => this.visit(target, context.spawn('UpdateStmt', { update: true }))) + .join(', '); + output.push(assignments); + break; + } + case 'CMD_INSERT': { + output.push('INSERT'); + const targetList = ListUtils.unwrapList(node.targetList); + if (targetList.length > 0) { + const columns = targetList + .map(target => QuoteUtils.quoteIdentifier(target.ResTarget?.name)) + .join(', '); + output.push(`(${columns})`); + } + if (node.override === 'OVERRIDING_USER_VALUE') { + output.push('OVERRIDING USER VALUE'); + } else if (node.override === 'OVERRIDING_SYSTEM_VALUE') { + output.push('OVERRIDING SYSTEM VALUE'); + } + const values = ListUtils.unwrapList(node.values); + if (values.length > 0) { + const valueItems = values + .map(value => this.visit(value, context)) + .join(', '); + output.push(`VALUES (${valueItems})`); + } else { + output.push('DEFAULT VALUES'); + } + break; + } + case 'CMD_DELETE': + output.push('DELETE'); + break; + case 'CMD_NOTHING': + output.push('DO NOTHING'); + break; + } + return output.join(' '); }