diff --git a/.changeset/precise-invalid-query-position.md b/.changeset/precise-invalid-query-position.md new file mode 100644 index 00000000..833abfbf --- /dev/null +++ b/.changeset/precise-invalid-query-position.md @@ -0,0 +1,5 @@ +--- +"@ts-safeql/eslint-plugin": patch +--- + +Point invalid-query errors at the offending identifier. When Postgres reports an unknown column, table, relation, type, or function, the squiggle now lands on that specific token instead of the whole query — most noticeably for Kysely builder chains, where the error previously underlined the entire embedded `sql` fragment. diff --git a/packages/eslint-plugin/src/rules/check-sql.utils.ts b/packages/eslint-plugin/src/rules/check-sql.utils.ts index 86595e65..5e40732b 100644 --- a/packages/eslint-plugin/src/rules/check-sql.utils.ts +++ b/packages/eslint-plugin/src/rules/check-sql.utils.ts @@ -755,18 +755,28 @@ function getQueryErrorPosition({ error, tag, sourceCode }: GetWordRangeInPositio ] : getSourceRange(position, sourceMaps); - const syntaxErrorToken = error.message.match(/syntax error at or near "([^"]+)"/)?.[1]; + const errorToken = extractErrorToken(error.message); - if (syntaxErrorToken) { + if (errorToken !== undefined) { const templateText = sourceCode.text.slice(getNodeStartOffset(tag), tag.range[1]); - const tokenIndex = findNearestMatchIndex(templateText, syntaxErrorToken, sourceRange[0]); + + const [fragmentStart, fragmentEnd] = matchingSourceMap + ? [ + matchingSourceMap.original.start, + matchingSourceMap.original.start + matchingSourceMap.original.text.length, + ] + : [0, templateText.length]; + + const tokenIndex = findNearestMatchIndex( + templateText.slice(fragmentStart, fragmentEnd), + errorToken, + sourceRange[0] - fragmentStart, + ); if (tokenIndex !== undefined) { + const start = fragmentStart + tokenIndex; return { - sourceLocation: getSourceLocation(tag, sourceCode, [ - tokenIndex, - tokenIndex + syntaxErrorToken.length, - ]), + sourceLocation: getSourceLocation(tag, sourceCode, [start, start + errorToken.length]), }; } } @@ -781,6 +791,27 @@ function getQueryErrorPosition({ error, tag, sourceCode }: GetWordRangeInPositio }; } +const ERROR_TOKEN_PATTERNS: RegExp[] = [ + /syntax error at or near "([^"]+)"/, + /column "?([^\s"]+)"? of relation "[^"]+" does not exist/, + /column "?([^\s"]+)"? does not exist/, + /relation "([^"]+)" does not exist/, + /type "([^"]+)" does not exist/, + /missing FROM-clause entry for table "([^"]+)"/, + /function ([\w.]+)\(/, +]; + +function extractErrorToken(message: string): string | undefined { + for (const pattern of ERROR_TOKEN_PATTERNS) { + const token = message.match(pattern)?.[1]; + if (token !== undefined) { + return token; + } + } + + return undefined; +} + function getSourceRange(position: number, sourceMaps: QuerySourceMapEntry[]): [number, number] { let positionOffset = 0; diff --git a/packages/eslint-plugin/src/rules/check-sql/check-sql.plugins.test.ts b/packages/eslint-plugin/src/rules/check-sql/check-sql.plugins.test.ts index 5020e4c8..554be1a1 100644 --- a/packages/eslint-plugin/src/rules/check-sql/check-sql.plugins.test.ts +++ b/packages/eslint-plugin/src/rules/check-sql/check-sql.plugins.test.ts @@ -16,7 +16,7 @@ ruleTester.run("plugin position", checkSqlRule, { `, error: 'relation "missing_person" does not exist', line: 3, - columns: [19, 45], + columns: [28, 42], }), invalidQueryAt({ connection: connections.withPluginSourcemap, diff --git a/packages/plugins/kysely/src/plugin.integration.test.ts b/packages/plugins/kysely/src/plugin.integration.test.ts index 96434cb2..8d2fe24c 100644 --- a/packages/plugins/kysely/src/plugin.integration.test.ts +++ b/packages/plugins/kysely/src/plugin.integration.test.ts @@ -122,6 +122,12 @@ RuleTester.describe("kysely integration — sql tag", () => { output: k("sql<{ id: number }>`SELECT id FROM person`"), errors: [{ messageId: "incorrectTypeAnnotations" }], }, + { + name: "unknown column in an insert squiggles the column, not the relation", + options: withConnection(), + code: k("sql`INSERT INTO person (nope) VALUES ('x')`"), + errors: [{ messageId: "invalidQuery", line: 1, column: 94, endLine: 1, endColumn: 98 }], + }, ], }); }); @@ -193,6 +199,12 @@ RuleTester.describe("kysely integration — builder embedded sql (opt-in)", () = name TEXT NOT NULL, bio TEXT ); + + CREATE TABLE pet ( + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + owner_id INTEGER REFERENCES person(id), + name TEXT NOT NULL + ); `); }); @@ -206,7 +218,7 @@ RuleTester.describe("kysely integration — builder embedded sql (opt-in)", () = // builder (no raw sql) is left to Kysely's own types. const kSql = (code: string) => `import { Kysely, sql, type SqlBool } from "kysely"; -interface DB { person: { id: number; first_name: string; name: string; bio: string | null } } +interface DB { person: { id: number; first_name: string; name: string; bio: string | null }; pet: { id: number; owner_id: number | null; name: string } } declare const db: Kysely; ${code}`; @@ -321,22 +333,44 @@ db.selectFrom("person").select(sql\`\${sql.ref(col)}\`.as("x")).execute();`, ], }, { - name: "nonexistent column in embedded sql is detected (squiggle on the fragment)", + name: "nonexistent column in embedded sql squiggles the offending identifier", options: withBuilderConnection(databaseName), code: kSql( `db.selectFrom("person").select(sql\`upper(nonexistent)\`.as("x")).execute();`, ), - // The error must land on the embedded `sql` fragment, not a misplaced - // offset in the compiled SQL. - errors: [{ messageId: "invalidQuery", line: 4, column: 32 }], + errors: [{ messageId: "invalidQuery", line: 4, column: 50, endLine: 4, endColumn: 61 }], }, { - name: "invalid function in embedded sql is detected (squiggle on the fragment)", + name: "invalid function in embedded sql squiggles the function name", options: withBuilderConnection(databaseName), code: kSql( `db.selectFrom("person").select("id").where(sql\`bogus_fn(id)\`).execute();`, ), - errors: [{ messageId: "invalidQuery", line: 4, column: 44 }], + errors: [{ messageId: "invalidQuery", line: 4, column: 57, endLine: 4, endColumn: 65 }], + }, + { + name: "unknown table in embedded sql squiggles the table reference", + options: withBuilderConnection(databaseName), + code: kSql( + `db.selectFrom("person").select("id").where(sql\`other.id is not null\`).execute();`, + ), + errors: [{ messageId: "invalidQuery", line: 4, column: 57, endLine: 4, endColumn: 62 }], + }, + { + name: "unknown qualified column in embedded sql squiggles the column reference", + options: withBuilderConnection(databaseName), + code: kSql( + `db.selectFrom("person").select(sql\`person.bio2 is not null\`.as("c")).execute();`, + ), + errors: [{ messageId: "invalidQuery", line: 4, column: 45, endLine: 4, endColumn: 56 }], + }, + { + name: "recurring identifier squiggles the occurrence inside the failing fragment", + options: withBuilderConnection(databaseName), + code: kSql( + `db.selectFrom("person").innerJoin("pet", "person.id", "pet.id").select(sql\`CASE WHEN2 pet.name = 'x' THEN true ELSE NULL END\`.as("flag")).execute();`, + ), + errors: [{ messageId: "invalidQuery", line: 4, column: 96, endLine: 4, endColumn: 99 }], }, ], });