From a57aa8e1f05d046f520c9cf6c7fca3fbe58fb30c Mon Sep 17 00:00:00 2001 From: Benjamin Simon Date: Wed, 24 Jun 2026 18:24:17 +0200 Subject: [PATCH 1/2] fix(rds): split schema-qualified identifiers in query builders select() and the other @aws-appsync/utils/rds query builders quoted the entire raw identifier as a single unit, so select({table: "domain.item"}) emitted SELECT * FROM "domain.item" -- one literal relation Postgres looks up verbatim, which doesn't exist. The same defect hit column-qualified names (columns: ['persons.name'] -> "persons.name"). Add a quoteIdentifier() helper that splits qualified identifiers on `.` and quotes each segment, matching AWS AppSync ("domain"."item", "persons"."name"), and route every identifier-quoting site (table/column names across select/insert/update/remove/where/orderBy/returning) through it. Tests assert parity against snapshots captured from real AWS via test:aws; unqualified names remain quoted as a single segment. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__snapshots__/resolvers.test.js.snap | 64 ++++++++++++++++ __tests__/resolvers.test.js | 74 +++++++++++++++++++ rds/index.js | 37 ++++++---- 3 files changed, 160 insertions(+), 15 deletions(-) diff --git a/__tests__/__snapshots__/resolvers.test.js.snap b/__tests__/__snapshots__/resolvers.test.js.snap index 4982c6f..9572cc5 100644 --- a/__tests__/__snapshots__/resolvers.test.js.snap +++ b/__tests__/__snapshots__/resolvers.test.js.snap @@ -277,6 +277,70 @@ exports[`rds resolvers postgresql update 1`] = ` } `; +exports[`rds resolvers schema-qualified identifiers mysql select qualified table 1`] = ` +{ + "statements": [ + "SELECT * FROM \`domain\`.\`item\`", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers schema-qualified identifiers postgresql insert into qualified table 1`] = ` +{ + "statements": [ + "INSERT INTO "private"."persons" ("name") VALUES (:P0)", + ], + "variableMap": { + ":P0": "test", + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers schema-qualified identifiers postgresql qualified column in where clause 1`] = ` +{ + "statements": [ + "SELECT * FROM "private"."persons" WHERE "persons"."id" = :P0", + ], + "variableMap": { + ":P0": 123, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers schema-qualified identifiers postgresql select qualified columns 1`] = ` +{ + "statements": [ + "SELECT "id", "persons"."name" FROM "private"."persons"", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers schema-qualified identifiers postgresql select qualified table 1`] = ` +{ + "statements": [ + "SELECT * FROM "domain"."item"", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers schema-qualified identifiers unqualified name is quoted as one segment 1`] = ` +{ + "statements": [ + "SELECT * FROM "item"", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + exports[`rds resolvers toJsonObject 1`] = ` [ [ diff --git a/__tests__/resolvers.test.js b/__tests__/resolvers.test.js index ee5e725..ee2133a 100644 --- a/__tests__/resolvers.test.js +++ b/__tests__/resolvers.test.js @@ -1014,6 +1014,80 @@ describe("rds resolvers", () => { }); }); + + // Schema/table-qualified identifiers (e.g. "schema.table" or "table.column") must be split on + // `.` and each segment quoted individually, matching AWS AppSync (e.g. `"schema"."table"`), + // rather than quoting the whole string as one literal identifier (`"schema.table"`). + describe("schema-qualified identifiers", () => { + test("postgresql select qualified table", async () => { + const code = ` + export function request(ctx) { + return rds.createPgStatement(rds.select({ table: "domain.item" })); + } + export function response(ctx) {} + `; + await checkResolverValid(code, {}, "request"); + }); + + test("postgresql select qualified columns", async () => { + const code = ` + export function request(ctx) { + return rds.createPgStatement(rds.select({ + table: "private.persons", + columns: ["id", "persons.name"], + })); + } + export function response(ctx) {} + `; + await checkResolverValid(code, {}, "request"); + }); + + test("postgresql qualified column in where clause", async () => { + const code = ` + export function request(ctx) { + return rds.createPgStatement(rds.select({ + table: "private.persons", + where: { "persons.id": { eq: 123 } }, + })); + } + export function response(ctx) {} + `; + await checkResolverValid(code, {}, "request"); + }); + + test("postgresql insert into qualified table", async () => { + const code = ` + export function request(ctx) { + return rds.createPgStatement(rds.insert({ + table: "private.persons", + values: { name: "test" }, + })); + } + export function response(ctx) {} + `; + await checkResolverValid(code, {}, "request"); + }); + + test("mysql select qualified table", async () => { + const code = ` + export function request(ctx) { + return rds.createMySQLStatement(rds.select({ table: "domain.item" })); + } + export function response(ctx) {} + `; + await checkResolverValid(code, {}, "request"); + }); + + test("unqualified name is quoted as one segment", async () => { + const code = ` + export function request(ctx) { + return rds.createPgStatement(rds.select({ table: "item" })); + } + export function response(ctx) {} + `; + await checkResolverValid(code, {}, "request"); + }); + }); }); describe("error handling", () => { diff --git a/rds/index.js b/rds/index.js index 1c943cc..15c91ca 100644 --- a/rds/index.js +++ b/rds/index.js @@ -154,7 +154,7 @@ class StatementBuilder { let query; if (columns) { - const columnNames = columns.map(name => `${this.quoteChar}${name}${this.quoteChar}`).join(', '); + const columnNames = columns.map(name => this.quoteIdentifier(name)).join(', '); query = `SELECT ${columnNames} FROM ${tableName}`; } else { query = `SELECT * FROM ${tableName}`; @@ -170,7 +170,7 @@ class StatementBuilder { let orderByParts = []; for (let { column, dir } of orderBy) { dir = dir || "ASC"; - orderByParts.push(`${this.quoteChar}${column}${this.quoteChar} ${dir}`); + orderByParts.push(`${this.quoteIdentifier(column)} ${dir}`); } query = `${query} ORDER BY ${orderByParts.join(', ')}`; @@ -202,7 +202,7 @@ class StatementBuilder { } if (returning) { - const columnNames = returning.map(name => `${this.quoteChar}${name}${this.quoteChar}`).join(', '); + const columnNames = returning.map(name => this.quoteIdentifier(name)).join(', '); query = `${query} RETURNING ${columnNames}`; } @@ -218,7 +218,7 @@ class StatementBuilder { let columnTextItems = []; let valuesTextItems = []; for (const [columnName, value] of Object.entries(values)) { - columnTextItems.push(`${this.quoteChar}${columnName}${this.quoteChar}`); + columnTextItems.push(this.quoteIdentifier(columnName)); const placeholder = this.newVariable(value); valuesTextItems.push(placeholder); } @@ -240,7 +240,7 @@ class StatementBuilder { let columnDefinitionItems = []; for (const [columnName, value] of Object.entries(values)) { const placeholder = this.newVariable(value); - columnDefinitionItems.push(`${this.quoteChar}${columnName}${this.quoteChar} = ${placeholder}`); + columnDefinitionItems.push(`${this.quoteIdentifier(columnName)} = ${placeholder}`); } query = `${query} ${columnDefinitionItems.join(', ')}`; @@ -310,30 +310,37 @@ class StatementBuilder { } switch (conditionType) { case "eq": - return `${startGrouping}${this.quoteChar}${columnName}${this.quoteChar} = ${value}${endGrouping}`; + return `${startGrouping}${this.quoteIdentifier(columnName)} = ${value}${endGrouping}`; case "ne": - return `${startGrouping}${this.quoteChar}${columnName}${this.quoteChar} != ${value}${endGrouping}`; + return `${startGrouping}${this.quoteIdentifier(columnName)} != ${value}${endGrouping}`; case "gt": - return `${startGrouping}${this.quoteChar}${columnName}${this.quoteChar} > ${value}${endGrouping}`; + return `${startGrouping}${this.quoteIdentifier(columnName)} > ${value}${endGrouping}`; case "lt": - return `${startGrouping}${this.quoteChar}${columnName}${this.quoteChar} < ${value}${endGrouping}`; + return `${startGrouping}${this.quoteIdentifier(columnName)} < ${value}${endGrouping}`; case "ge": - return `${startGrouping}${this.quoteChar}${columnName}${this.quoteChar} >= ${value}${endGrouping}`; + return `${startGrouping}${this.quoteIdentifier(columnName)} >= ${value}${endGrouping}`; case "le": - return `${startGrouping}${this.quoteChar}${columnName}${this.quoteChar} <= ${value}${endGrouping}`; + return `${startGrouping}${this.quoteIdentifier(columnName)} <= ${value}${endGrouping}`; case "contains": - return `${startGrouping}${this.quoteChar}${columnName}${this.quoteChar} LIKE ${value}${endGrouping}`; + return `${startGrouping}${this.quoteIdentifier(columnName)} LIKE ${value}${endGrouping}`; case "notContains": - return `${startGrouping}${this.quoteChar}${columnName}${this.quoteChar} NOT LIKE ${value}${endGrouping}`; + return `${startGrouping}${this.quoteIdentifier(columnName)} NOT LIKE ${value}${endGrouping}`; case "attributeExists": - return `${startGrouping}${this.quoteChar}${columnName}${this.quoteChar} IS ${value? "NOT " : ""}NULL${endGrouping}`; + return `${startGrouping}${this.quoteIdentifier(columnName)} IS ${value? "NOT " : ""}NULL${endGrouping}`; default: throw new Error(`Unhandled condition type ${conditionType}`); } } getTableName(rawName) { - return `${this.quoteChar}${rawName}${this.quoteChar}`; + return this.quoteIdentifier(rawName); + } + + quoteIdentifier(rawName) { + // Split schema/table-qualified identifiers (e.g. "schema.table" or "table.column") on `.` + // and quote each segment individually, matching AWS AppSync (e.g. `"schema"."table"`) + // rather than quoting the whole string as one literal identifier (`"schema.table"`). + return rawName.split('.').map(part => `${this.quoteChar}${part}${this.quoteChar}`).join('.'); } } From 4d54255db3c00f1467089cf4cb34e54e9bf5ba3b Mon Sep 17 00:00:00 2001 From: Benjamin Simon Date: Wed, 24 Jun 2026 19:35:49 +0200 Subject: [PATCH 2/2] bump package and readme --- README.md | 1 + package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 79dc290..147359c 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ This package provides an implementation for the `@aws-appsync/utils` package tha ## Changelog: +- v0.1.2: fix `rds` query builders with schema qualified identifiers - v0.1.1: security updates - v0.1.0: first pinned version of the library diff --git a/package-lock.json b/package-lock.json index 15b4a54..7ad4317 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@localstack/appsync-utils", - "version": "0.1.0", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@localstack/appsync-utils", - "version": "0.1.0", + "version": "0.1.2", "license": "Apache-2.0", "dependencies": { "uuid": "^14.0.1" diff --git a/package.json b/package.json index 24300c3..ca28e3b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@localstack/appsync-utils", - "version": "0.1.0", + "version": "0.1.2", "description": "Implementation of the AppSync utils helpers", "type": "module", "main": "index.js",