Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/precise-invalid-query-position.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 38 additions & 7 deletions packages/eslint-plugin/src/rules/check-sql.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]),
};
}
}
Expand All @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
48 changes: 41 additions & 7 deletions packages/plugins/kysely/src/plugin.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
},
],
});
});
Expand Down Expand Up @@ -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
);
`);
});

Expand All @@ -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<DB>;
${code}`;

Expand Down Expand Up @@ -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<string>\`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<SqlBool>\`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<SqlBool>\`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<boolean>\`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<SqlBool>\`CASE WHEN2 pet.name = 'x' THEN true ELSE NULL END\`.as("flag")).execute();`,
),
errors: [{ messageId: "invalidQuery", line: 4, column: 96, endLine: 4, endColumn: 99 }],
},
],
});
Expand Down
Loading