From e51533a425bf44492d70e45f78a0046a07586b6f Mon Sep 17 00:00:00 2001 From: Benjamin Simon Date: Thu, 20 Aug 2026 18:29:01 +0200 Subject: [PATCH 1/2] feat(rds): beginsWith, between and size conditions, from table alias Real AWS supports three `where` conditions and one `select` key that this library did not, so a resolver using any of them hit `Unhandled condition type` or a TypeError on the missing table. - `beginsWith` binds its value with a trailing wildcard (`LIKE 'x%'`) - `between` binds two values (`BETWEEN :P0 AND :P1`), guarding the arity with the two distinct messages AWS uses - `size` compares `LENGTH (col)` and accepts eq/ne/gt/ge/lt/le plus a nested `between`, repeating the target once per operator - `from` is accepted as an alias for `table`, in select() only: AWS ignores the key entirely in insert/update/remove, and rejects it alongside `table` only in select() - `contains` and `notContains` now require string values, as `beginsWith` does - an empty `size` renders nothing, so buildWhereStatement no longer wraps an empty body in the grouping parens Verified against real AWS AppSync EvaluateCode: 156 inputs match byte-for-byte across both dialects, including the #283 regression set. Snapshots recorded from AWS. Co-Authored-By: Claude Opus 5 --- .../__snapshots__/resolvers.test.js.snap | 406 ++++++++++++++++++ __tests__/resolvers.test.js | 238 ++++++++++ rds/index.js | 166 +++++-- 3 files changed, 764 insertions(+), 46 deletions(-) diff --git a/__tests__/__snapshots__/resolvers.test.js.snap b/__tests__/__snapshots__/resolvers.test.js.snap index cc90cf8..243b696 100644 --- a/__tests__/__snapshots__/resolvers.test.js.snap +++ b/__tests__/__snapshots__/resolvers.test.js.snap @@ -50,6 +50,412 @@ exports[`error handling unauthorized 1`] = ` } `; +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size beginsWith in a delete statement 1`] = ` +{ + "statements": [ + "DELETE FROM "persons" WHERE "name" LIKE :P0", + ], + "variableMap": { + ":P0": "te%", + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size beginsWith in mysql 1`] = ` +{ + "statements": [ + "SELECT * FROM \`persons\` WHERE \`name\` LIKE :P0", + ], + "variableMap": { + ":P0": "te%", + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size beginsWith keeps a wildcard already in the value 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE "name" LIKE :P0", + ], + "variableMap": { + ":P0": "a%b%", + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size beginsWith renders a trailing wildcard 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE "name" LIKE :P0", + ], + "variableMap": { + ":P0": "te%", + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size beginsWith with an empty string 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE "name" LIKE :P0", + ], + "variableMap": { + ":P0": "%", + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size between a null bound 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE "age" BETWEEN NULL AND :P0", + ], + "variableMap": { + ":P0": 65, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size between and beginsWith in an or group 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE ("age" BETWEEN :P0 AND :P1) OR ("name" LIKE :P2)", + ], + "variableMap": { + ":P0": 18, + ":P1": 65, + ":P2": "te%", + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size between in an update statement 1`] = ` +{ + "statements": [ + "UPDATE "persons" SET "active" = :P0 WHERE "age" BETWEEN :P1 AND :P2", + ], + "variableMap": { + ":P0": false, + ":P1": 18, + ":P2": 65, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size between in mysql 1`] = ` +{ + "statements": [ + "SELECT * FROM \`persons\` WHERE \`age\` BETWEEN :P0 AND :P1", + ], + "variableMap": { + ":P0": 18, + ":P1": 65, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size between two numbers 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE "age" BETWEEN :P0 AND :P1", + ], + "variableMap": { + ":P0": 18, + ":P1": 65, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size between two strings 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE "name" BETWEEN :P0 AND :P1", + ], + "variableMap": { + ":P0": "a", + ":P1": "m", + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size between two type hints 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE "birthday" BETWEEN :P0 AND :P1", + ], + "variableMap": { + ":P0": "2020-01-01", + ":P1": "2020-12-31", + }, + "variableTypeHintMap": { + ":P0": "DATE", + ":P1": "DATE", + }, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size empty size object 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons"", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size empty size object alongside a real condition in a group 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE AND ("id" = :P0)", + ], + "variableMap": { + ":P0": 1, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size empty size object alongside a real condition on one column 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE AND "name" = :P0", + ], + "variableMap": { + ":P0": "test", + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size empty size object in a group 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons"", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size size and beginsWith in a group 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE (LENGTH ("name") = :P0) AND ("country" LIKE :P1)", + ], + "variableMap": { + ":P0": 3, + ":P1": "de%", + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size size and beginsWith on one column 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE LENGTH ("name") = :P0 AND "name" LIKE :P1", + ], + "variableMap": { + ":P0": 3, + ":P1": "te%", + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size size compares the column length 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE LENGTH ("name") = :P0", + ], + "variableMap": { + ":P0": 3, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size size in an update statement 1`] = ` +{ + "statements": [ + "UPDATE "persons" SET "active" = :P0 WHERE LENGTH ("name") = :P1", + ], + "variableMap": { + ":P0": false, + ":P1": 3, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size size in mysql 1`] = ` +{ + "statements": [ + "SELECT * FROM \`persons\` WHERE LENGTH (\`name\`) = :P0", + ], + "variableMap": { + ":P0": 3, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size size nested in an or of an and 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE ((LENGTH ("name") = :P0))", + ], + "variableMap": { + ":P0": 3, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size size on a qualified column 1`] = ` +{ + "statements": [ + "SELECT * FROM "private"."persons" WHERE LENGTH ("persons"."name") = :P0", + ], + "variableMap": { + ":P0": 3, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size size with a nested between 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE LENGTH ("name") BETWEEN :P0 AND :P1", + ], + "variableMap": { + ":P0": 2, + ":P1": 8, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size size with a null value 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE LENGTH ("name") = NULL", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size size with each comparison operator 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE LENGTH ("a") != :P0 AND LENGTH ("b") > :P1 AND LENGTH ("c") >= :P2 AND LENGTH ("d") < :P3 AND LENGTH ("e") <= :P4", + ], + "variableMap": { + ":P0": 1, + ":P1": 2, + ":P2": 3, + ":P3": 4, + ":P4": 5, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size size with order by and limit 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE LENGTH ("name") > :P0 ORDER BY "name" DESC LIMIT :P1", + ], + "variableMap": { + ":P0": 2, + ":P1": 5, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size size with two comparisons on one column 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE LENGTH ("name") = :P0 AND LENGTH ("name") > :P1", + ], + "variableMap": { + ":P0": 3, + ":P1": 1, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias from as a table alias insert uses table and ignores from 1`] = ` +{ + "statements": [ + "INSERT INTO "persons" ("name") VALUES (:P0)", + ], + "variableMap": { + ":P0": "test", + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias from as a table alias select with a qualified from 1`] = ` +{ + "statements": [ + "SELECT "id" FROM "domain"."color"", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias from as a table alias select with from 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons"", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias from as a table alias select with from and the other clauses 1`] = ` +{ + "statements": [ + "SELECT "id" FROM "domain"."color" WHERE "id" = :P0 LIMIT :P1", + ], + "variableMap": { + ":P0": 1, + ":P1": 2, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers additional conditions and the from alias from as a table alias select with from in mysql 1`] = ` +{ + "statements": [ + "SELECT * FROM \`persons\`", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + exports[`rds resolvers attributeExists nested or 1`] = ` { "statements": [ diff --git a/__tests__/resolvers.test.js b/__tests__/resolvers.test.js index aad3d07..380c9a7 100644 --- a/__tests__/resolvers.test.js +++ b/__tests__/resolvers.test.js @@ -1392,6 +1392,244 @@ describe("rds resolvers", () => { ).toThrow("Expected column to be * or an array."); }); }); + + describe("additional conditions and the from alias", () => { + const pg = (expr) => ` + export function request(ctx) { + return rds.createPgStatement(${expr}); + } + export function response(ctx) {} + `; + + const mysql = (expr) => ` + export function request(ctx) { + return rds.createMySQLStatement(${expr}); + } + export function response(ctx) {} + `; + + describe("beginsWith, between and size", () => { + // beginsWith is a prefix match: the bound value carries a trailing wildcard only + test("beginsWith renders a trailing wildcard", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { beginsWith: "te" } } })`), {}, "request"); + }); + + test("beginsWith keeps a wildcard already in the value", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { beginsWith: "a%b" } } })`), {}, "request"); + }); + + test("beginsWith with an empty string", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { beginsWith: "" } } })`), {}, "request"); + }); + + test("beginsWith in a delete statement", async () => { + await checkResolverValid(pg(`rds.remove({ table: "persons", where: { name: { beginsWith: "te" } } })`), {}, "request"); + }); + + test("beginsWith in mysql", async () => { + await checkResolverValid(mysql(`rds.select({ table: "persons", where: { name: { beginsWith: "te" } } })`), {}, "request"); + }); + + test("between two numbers", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { age: { between: [18, 65] } } })`), {}, "request"); + }); + + test("between two strings", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { between: ["a", "m"] } } })`), {}, "request"); + }); + + // each bound is bound separately, so type hints survive + test("between two type hints", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { birthday: { between: [rds.typeHint.DATE("2020-01-01"), rds.typeHint.DATE("2020-12-31")] } } })`), {}, "request"); + }); + + test("between a null bound", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { age: { between: [null, 65] } } })`), {}, "request"); + }); + + test("between in an update statement", async () => { + await checkResolverValid(pg(`rds.update({ table: "persons", values: { active: false }, where: { age: { between: [18, 65] } } })`), {}, "request"); + }); + + test("between in mysql", async () => { + await checkResolverValid(mysql(`rds.select({ table: "persons", where: { age: { between: [18, 65] } } })`), {}, "request"); + }); + + // size compares against the column length + test("size compares the column length", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { size: { eq: 3 } } } })`), {}, "request"); + }); + + test("size with each comparison operator", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { a: { size: { ne: 1 } }, b: { size: { gt: 2 } }, c: { size: { ge: 3 } }, d: { size: { lt: 4 } }, e: { size: { le: 5 } } } })`), {}, "request"); + }); + + test("size with a nested between", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { size: { between: [2, 8] } } } })`), {}, "request"); + }); + + // the length target is repeated for each operator + test("size with two comparisons on one column", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { size: { eq: 3, gt: 1 } } } })`), {}, "request"); + }); + + // unlike a direct comparison, a nullish size value is inlined rather than rejected + test("size with a null value", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { size: { eq: null } } } })`), {}, "request"); + }); + + test("size on a qualified column", async () => { + await checkResolverValid(pg(`rds.select({ table: "private.persons", where: { "persons.name": { size: { eq: 3 } } } })`), {}, "request"); + }); + + test("size in an update statement", async () => { + await checkResolverValid(pg(`rds.update({ table: "persons", values: { active: false }, where: { name: { size: { eq: 3 } } } })`), {}, "request"); + }); + + test("size in mysql", async () => { + await checkResolverValid(mysql(`rds.select({ table: "persons", where: { name: { size: { eq: 3 } } } })`), {}, "request"); + }); + + // an empty size object renders nothing, and must not leave grouping parens behind + test("empty size object", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { size: {} } } })`), {}, "request"); + }); + + test("empty size object in a group", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { and: [{ name: { size: {} } }] } })`), {}, "request"); + }); + + test("empty size object alongside a real condition in a group", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { and: [{ name: { size: {} } }, { id: { eq: 1 } }] } })`), {}, "request"); + }); + + test("empty size object alongside a real condition on one column", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { size: {}, eq: "test" } } })`), {}, "request"); + }); + + test("size and beginsWith in a group", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { and: [{ name: { size: { eq: 3 } } }, { country: { beginsWith: "de" } }] } })`), {}, "request"); + }); + + test("size and beginsWith on one column", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { size: { eq: 3 }, beginsWith: "te" } } })`), {}, "request"); + }); + + test("between and beginsWith in an or group", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { or: [{ age: { between: [18, 65] } }, { name: { beginsWith: "te" } }] } })`), {}, "request"); + }); + + test("size nested in an or of an and", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { or: [{ and: [{ name: { size: { eq: 3 } } }] }] } })`), {}, "request"); + }); + + test("size with order by and limit", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { size: { gt: 2 } } }, orderBy: [{ column: "name", dir: "desc" }], limit: 5 })`), {}, "request"); + }); + + // The wildcard conditions all require a string. These are asserted directly rather than + // snapshotted: the same messages come back from AWS behind a `code.js::` prefix + // that a local run cannot reproduce. + test("rejects a non-string for beginsWith", () => { + expect(() => rds.createPgStatement(rds.select({ table: "persons", where: { name: { beginsWith: 5 } } }))) + .toThrow("name.beginsWith expects a string value to be passed."); + }); + + test("rejects null for beginsWith", () => { + expect(() => rds.createPgStatement(rds.select({ table: "persons", where: { name: { beginsWith: null } } }))) + .toThrow("Value for name.beginsWith can't be null."); + }); + + test("rejects a non-string for contains", () => { + expect(() => rds.createPgStatement(rds.select({ table: "persons", where: { name: { contains: 5 } } }))) + .toThrow("name.contains expects a string value to be passed."); + }); + + test("rejects a non-string for notContains", () => { + expect(() => rds.createPgStatement(rds.select({ table: "persons", where: { name: { notContains: 5 } } }))) + .toThrow("name.notContains expects a string value to be passed."); + }); + + test("rejects between with the wrong number of values", () => { + expect(() => rds.createPgStatement(rds.select({ table: "persons", where: { age: { between: [1] } } }))) + .toThrow("age.between condition expects an array with 2 values but received an array with length 1."); + }); + + test("rejects between with a value that is not an array", () => { + expect(() => rds.createPgStatement(rds.select({ table: "persons", where: { age: { between: 5 } } }))) + .toThrow("age.between condition expects an array with length of 2."); + }); + + test("rejects a nested between with the wrong number of values", () => { + expect(() => rds.createPgStatement(rds.select({ table: "persons", where: { name: { size: { between: [1] } } } }))) + .toThrow("name.size condition expects an array with 2 values but received an array with length 1."); + }); + + test("rejects an unsupported size operator", () => { + expect(() => rds.createPgStatement(rds.select({ table: "persons", where: { name: { size: { contains: "x" } } } }))) + .toThrow("name.size has invalid size operator."); + }); + + test("rejects a size that is not an object", () => { + expect(() => rds.createPgStatement(rds.select({ table: "persons", where: { name: { size: 3 } } }))) + .toThrow("Expected name.size to be an Object."); + }); + }); + + describe("from as a table alias", () => { + test("select with from", async () => { + await checkResolverValid(pg(`rds.select({ from: "persons" })`), {}, "request"); + }); + + test("select with a qualified from", async () => { + await checkResolverValid(pg(`rds.select({ from: "domain.color", columns: ["id"] })`), {}, "request"); + }); + + test("select with from and the other clauses", async () => { + await checkResolverValid(pg(`rds.select({ from: "domain.color", columns: ["id"], where: { id: { eq: 1 } }, limit: 2 })`), {}, "request"); + }); + + test("select with from in mysql", async () => { + await checkResolverValid(mysql(`rds.select({ from: "persons" })`), {}, "request"); + }); + + // only select() knows the alias, so insert falls back to `table` without complaining + test("insert uses table and ignores from", async () => { + await checkResolverValid(pg(`rds.insert({ from: "ignored", table: "persons", values: { name: "test" } })`), {}, "request"); + }); + + test("rejects from and table together in select", () => { + expect(() => rds.createPgStatement(rds.select({ from: "a", table: "b" }))) + .toThrow("'from' and 'table' keys cannot be used together"); + }); + + test("rejects a select with neither table nor from", () => { + expect(() => rds.createPgStatement(rds.select({ columns: ["id"] }))) + .toThrow("'table' or 'from' key is required."); + }); + + test("rejects a null table", () => { + expect(() => rds.createPgStatement(rds.select({ table: null }))) + .toThrow("'table' or 'from' key is required."); + }); + + // insert/update/remove do not accept the alias at all + test("rejects an insert given only from", () => { + expect(() => rds.createPgStatement(rds.insert({ from: "persons", values: { name: "test" } }))) + .toThrow("'table' or 'from' key is required."); + }); + + test("rejects an update given only from", () => { + expect(() => rds.createPgStatement(rds.update({ from: "persons", values: { name: "test" }, where: { id: { eq: 1 } } }))) + .toThrow("'table' or 'from' key is required."); + }); + + test("rejects a remove given only from", () => { + expect(() => rds.createPgStatement(rds.remove({ from: "persons", where: { id: { eq: 1 } } }))) + .toThrow("'table' or 'from' key is required."); + }); + }); + }); }); describe("error handling", () => { diff --git a/rds/index.js b/rds/index.js index 5485af8..5d44687 100644 --- a/rds/index.js +++ b/rds/index.js @@ -92,6 +92,17 @@ export function remove(s) { return { type: "REMOVE", properties: s }; } +// SQL operators for the simple comparison conditions, shared by the direct form (`{ eq: 1 }`) and +// the `size` form (`{ size: { eq: 1 } }`) +const COMPARISON_OPERATORS = { + eq: '=', + ne: '!=', + gt: '>', + lt: '<', + ge: '>=', + le: '<=', +}; + class StatementBuilder { constructor({ quoteChar, supportsReturning = true }) { this.quoteChar = quoteChar; @@ -161,7 +172,7 @@ class StatementBuilder { renderStructuredStatement(type, properties) { switch (type) { case "SELECT": { - const { table, columns, where, orderBy, limit, offset } = properties; + const { columns, where, orderBy, limit, offset } = properties; const parts = ["SELECT"]; if (columns) { @@ -170,7 +181,7 @@ class StatementBuilder { parts.push('*'); } - parts.push(`FROM ${this.getTableName(table)}`); + parts.push(`FROM ${this.resolveTableName(properties, true)}`); parts.push(...this.buildWhereParts(where)); // an empty sort list drops the whole clause, ORDER BY keyword included, like AWS @@ -191,8 +202,8 @@ class StatementBuilder { break; } case "REMOVE": { - const { table, where, returning, } = properties; - const parts = [`DELETE FROM ${this.getTableName(table)}`]; + const { where, returning, } = properties; + const parts = [`DELETE FROM ${this.resolveTableName(properties)}`]; parts.push(...this.buildWhereParts(where)); @@ -204,8 +215,8 @@ class StatementBuilder { break; } case "INSERT": { - const { table, values, returning } = properties; - const parts = [`INSERT INTO ${this.getTableName(table)}`]; + const { values, returning } = properties; + const parts = [`INSERT INTO ${this.resolveTableName(properties)}`]; let columnTextItems = []; let valuesTextItems = []; @@ -223,8 +234,8 @@ class StatementBuilder { break; } case "UPDATE": { - const { table, values, where } = properties; - const parts = [`UPDATE ${this.getTableName(table)}`, 'SET']; + const { values, where } = properties; + const parts = [`UPDATE ${this.resolveTableName(properties)}`, 'SET']; let columnDefinitionItems = []; for (const [columnName, value] of Object.entries(values)) { @@ -341,55 +352,118 @@ class StatementBuilder { const columnName = Object.keys(defn)[0]; const condition = defn[columnName]; - const conditionTypes = Object.keys(condition); - // a column carrying no condition contributes nothing, e.g. an optional filter that the - // resolver left empty - if (conditionTypes.length === 0) { - return ""; - } - // several conditions on the same column are ANDed together - const statements = conditionTypes.map( + const statements = Object.keys(condition).map( conditionType => this.buildCondition(columnName, condition[conditionType], conditionType) ); - return `${startGrouping}${statements.join(" AND ")}${endGrouping}`; + // a column that renders nothing - carrying no condition at all, e.g. an optional filter the + // resolver left empty, or only an empty `size` - contributes nothing, and must not pick up the + // grouping on its way out or it would produce `WHERE ()` + const joined = statements.join(" AND "); + return joined === "" ? "" : `${startGrouping}${joined}${endGrouping}`; } buildCondition(columnName, rawValue, conditionType) { - let value; - if (conditionType === "attributeExists") { - value = rawValue; - } else if (conditionType === "contains") { - // AWS binds the value wrapped in wildcards, so `contains` is a real substring match rather - // than an equality test. `notContains` is deliberately left unwrapped - AWS does not wrap it - // either. - value = this.newVariable(`%${rawValue}%`); - } else { - value = this.newVariable(rawValue); - } + const column = this.quoteIdentifier(columnName); + const path = `${columnName}.${conditionType}`; + switch (conditionType) { - case "eq": - return `${this.quoteIdentifier(columnName)} = ${value}`; - case "ne": - return `${this.quoteIdentifier(columnName)} != ${value}`; - case "gt": - return `${this.quoteIdentifier(columnName)} > ${value}`; - case "lt": - return `${this.quoteIdentifier(columnName)} < ${value}`; - case "ge": - return `${this.quoteIdentifier(columnName)} >= ${value}`; - case "le": - return `${this.quoteIdentifier(columnName)} <= ${value}`; + case "size": + return this.buildSizeCondition(column, rawValue, path); + case "between": + return this.buildBetweenCondition(column, rawValue, path); + case "attributeExists": + return `${column} IS ${rawValue? "NOT " : ""}NULL`; case "contains": - return `${this.quoteIdentifier(columnName)} LIKE ${value}`; + // the wildcards make `contains` a substring match rather than an equality test + return `${column} LIKE ${this.newVariable(`%${this.requireString(rawValue, path)}%`)}`; + case "beginsWith": + return `${column} LIKE ${this.newVariable(`${this.requireString(rawValue, path)}%`)}`; case "notContains": - return `${this.quoteIdentifier(columnName)} NOT LIKE ${value}`; - case "attributeExists": - return `${this.quoteIdentifier(columnName)} IS ${value? "NOT " : ""}NULL`; - default: - throw new Error(`Unhandled condition type ${conditionType}`); + // deliberately unwrapped - AWS adds no wildcards to notContains + return `${column} NOT LIKE ${this.newVariable(this.requireString(rawValue, path))}`; + default: { + const operator = COMPARISON_OPERATORS[conditionType]; + if (!operator) { + throw new Error(`Unhandled condition type ${conditionType}`); + } + + return `${column} ${operator} ${this.newVariable(rawValue)}`; + } + } + } + + buildBetweenCondition(target, rawValue, path) { + // AWS uses two distinct messages here: one for a value that is not an array at all, another + // for an array of the wrong length + if (!Array.isArray(rawValue)) { + throw new Error(`${path} condition expects an array with length of 2.`); + } + + if (rawValue.length !== 2) { + throw new Error(`${path} condition expects an array with 2 values but received an array with length ${rawValue.length}.`); + } + + // mapping in order keeps the bound variables numbered low-then-high + const [low, high] = rawValue.map(bound => this.renderValue(bound)); + return `${target} BETWEEN ${low} AND ${high}`; + } + + buildSizeCondition(column, rawValue, path) { + if ((typeof rawValue !== 'object') || (rawValue === null) || Array.isArray(rawValue)) { + throw new Error(`Expected ${path} to be an Object.`); + } + + // the comparison runs against the column's length, with the target repeated for each operator. + // An empty object renders nothing, like any other empty condition. + const target = `LENGTH (${column})`; + const statements = Object.keys(rawValue).map(operator => { + if (operator === "between") { + // the path stays the outer `.size` in the error message, matching AWS + return this.buildBetweenCondition(target, rawValue[operator], path); + } + + const comparison = COMPARISON_OPERATORS[operator]; + if (!comparison) { + throw new Error(`${path} has invalid size operator.`); + } + + // unlike a direct comparison, a nullish value here is inlined rather than rejected + return `${target} ${comparison} ${this.renderValue(rawValue[operator])}`; + }); + + return statements.join(" AND "); + } + + requireString(value, path) { + // AWS rejects a non-string for the wildcard conditions instead of coercing it + if (value == null) { + throw new Error(`Value for ${path} can't be null.`); } + + if (typeof value !== 'string') { + throw new Error(`${path} expects a string value to be passed.`); + } + + return value; + } + + resolveTableName(properties, allowFrom = false) { + const { table, from } = properties; + + // `from` is an alias for `table`, but only in select(): insert/update/remove ignore the key + // entirely, and only select() rejects the two being passed together + if (allowFrom && (table != null) && (from != null)) { + throw new Error("'from' and 'table' keys cannot be used together"); + } + + const name = allowFrom ? (table ?? from) : table; + if (name == null) { + throw new Error("'table' or 'from' key is required."); + } + + return this.getTableName(name); } getTableName(rawName) { From c540fb1ecfb54421ca9fd45bbbd90140a96cc02e Mon Sep 17 00:00:00 2001 From: Benjamin Simon Date: Thu, 20 Aug 2026 20:07:53 +0200 Subject: [PATCH 2/2] test(rds): state plainly that the error assertions are not AWS-checked The `toThrow` assertions call the module directly and never reach `checkResolverValid`, so they are unaffected by TEST_TARGET and compare against string literals rather than AWS. The previous comments said the messages "come back from AWS", which reads as though the suite verifies them - it does not, under either target. Co-Authored-By: Claude Opus 5 --- __tests__/resolvers.test.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/__tests__/resolvers.test.js b/__tests__/resolvers.test.js index 380c9a7..6ab0b1f 100644 --- a/__tests__/resolvers.test.js +++ b/__tests__/resolvers.test.js @@ -1366,8 +1366,11 @@ describe("rds resolvers", () => { await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { notContains: "test" } } })`), {}, "request"); }); - // These inputs are rejected outright. AWS reports the same messages prefixed with a source - // position we cannot reproduce locally, so they are asserted directly rather than snapshotted. + // These inputs are rejected outright. Note that these assertions never reach + // `checkResolverValid`, so they are NOT compared against AWS under either TEST_TARGET - they + // pin string literals that were checked against `EvaluateCode` by hand when written. They can + // become recorded snapshots once the harness captures a thrown error and strips the + // `code.js::` prefix AWS puts in front of its message. test("rejects an orderBy dir that is neither ascending nor descending", () => { expect(() => rds.createPgStatement(rds.select({ table: "persons", orderBy: [{ column: "id", dir: "; DROP TABLE persons" }] })) @@ -1527,9 +1530,9 @@ describe("rds resolvers", () => { await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { size: { gt: 2 } } }, orderBy: [{ column: "name", dir: "desc" }], limit: 5 })`), {}, "request"); }); - // The wildcard conditions all require a string. These are asserted directly rather than - // snapshotted: the same messages come back from AWS behind a `code.js::` prefix - // that a local run cannot reproduce. + // The wildcard conditions all require a string. As above, these assertions never reach + // `checkResolverValid`, so they are NOT compared against AWS under either TEST_TARGET - they + // pin string literals checked against `EvaluateCode` by hand when written. test("rejects a non-string for beginsWith", () => { expect(() => rds.createPgStatement(rds.select({ table: "persons", where: { name: { beginsWith: 5 } } }))) .toThrow("name.beginsWith expects a string value to be passed.");