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
9 changes: 9 additions & 0 deletions .changeset/kysely-embedded-fragment-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@ts-safeql/plugin-kysely": minor
"@ts-safeql/plugin-utils": minor
"@ts-safeql/eslint-plugin": patch
---

Validate the `<T>` annotation on raw `sql` fragments embedded in Kysely query-builder chains.

SafeQL now checks that annotation against the type the database returns and autofixes it on a mismatch. A selection like ``sql<number>`name || bio`.as("credit_line")`` whose column is `string` gets flagged; a ``.where(sql<number>`bio is not null`)`` condition gets corrected to `boolean`. Conditions accept both `SqlBool` and `boolean`, and fragments wrapped in parentheses or `as` are checked like bare ones.
2 changes: 1 addition & 1 deletion demos/plugin-kysely/src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function builderSelectExpr() {
export function builderAggregate() {
return db
.selectFrom("person")
.select(sql<number>`count(*)`.as("total"))
.select(sql<string>`count(*)`.as("total"))
.execute();
}

Expand Down
129 changes: 108 additions & 21 deletions packages/eslint-plugin/src/rules/check-sql.rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { ResolvedTarget } from "@ts-safeql/generate";
import {
PluginManager,
matchesQueryNodeSelector,
type IncorrectTypeAnnotationReport,
type PluginResolvedTarget,
type ResolvedQueryTypeCheckContext,
type ResolvedQueryTypeCheckResult,
type QueryNodeSelector,
type SafeQLPlugin,
type QuerySourceMapEntry,
Expand Down Expand Up @@ -87,6 +90,9 @@ type CheckNode = TSESTree.Node;
type TerminalCallQuery = {
text: string;
sourcemaps: QuerySourceMapEntry[];
typeCheck?: (
ctx: ResolvedQueryTypeCheckContext,
) => readonly ResolvedQueryTypeCheckResult[] | undefined;
};

function check(params: {
Expand Down Expand Up @@ -271,6 +277,7 @@ function resolveQueryFromPlugins(params: {
return {
text: result.text,
sourcemaps: result.sourcemaps,
typeCheck: result.typeCheck,
};
}

Expand Down Expand Up @@ -401,6 +408,18 @@ function resolvePluginTargetMatch(
return undefined;
}

function makeGetComparableString(
connection: RuleOptionConnection,
): (target: PluginResolvedTarget) => string {
return (target) =>
getResolvedTargetComparableString({
target: target as ExpectedResolvedTarget,
nullAsOptional: connection.nullAsOptional ?? false,
nullAsUndefined: connection.nullAsUndefined ?? false,
inferLiterals: connection.inferLiterals ?? defaultInferLiteralOptions,
});
}

function reportPluginTypeCheck(params: {
context: RuleContext;
tag: TSESTree.TaggedTemplateExpression;
Expand All @@ -412,30 +431,51 @@ function reportPluginTypeCheck(params: {
}): void {
const { context, tag, connection, checker, parser, output, typeCheck } = params;

const nullAsOptional = connection.nullAsOptional ?? false;
const nullAsUndefined = connection.nullAsUndefined ?? false;
const enforceType = connection.enforceType ?? "fix";

const report = typeCheck({
const result = typeCheck({
node: tag,
output,
checker,
parser,
sourceCode: context.sourceCode,
getComparableString: (target) =>
getResolvedTargetComparableString({
target: target as ExpectedResolvedTarget,
nullAsOptional,
nullAsUndefined,
inferLiterals: connection.inferLiterals ?? defaultInferLiteralOptions,
}),
getComparableString: makeGetComparableString(connection),
});

if (!report) return;
if (result) {
reportTypeCheckResult({ context, connection, result, defaultNode: tag.tag });
}
}

function isIncorrectTypeAnnotationReport(
result: ResolvedQueryTypeCheckResult,
): result is IncorrectTypeAnnotationReport {
return "kind" in result && result.kind === "incorrect-type-annotation";
}

// Dispatches whatever a plugin's `typeCheck` returns: a structured annotation mismatch routes
// through the core's standard incorrect-annotation report; a free-form report becomes a plugin error.
function reportTypeCheckResult(params: {
context: RuleContext;
connection: RuleOptionConnection;
result: ResolvedQueryTypeCheckResult;
defaultNode: TSESTree.Node;
}): void {
const { context, connection, result, defaultNode } = params;

if (isIncorrectTypeAnnotationReport(result)) {
return reportIncorrectTypeAnnotations({
context,
typeParameter: result.typeParameter,
expected: result.expected,
actual: result.actual,
enforceType: connection.enforceType,
});
}

const enforceType = connection.enforceType ?? "fix";

const reportNode = report.node ?? tag.tag;
const reportData = { error: report.message };
const reportFixData = report.fix;
const reportNode = result.node ?? defaultNode;
const reportData = { error: result.message };
const reportFixData = result.fix;
const reportFix = reportFixData
? (fixer: TSESLint.RuleFixer) => fixer.replaceText(reportFixData.node, reportFixData.text)
: undefined;
Expand Down Expand Up @@ -512,7 +552,7 @@ function reportCheck(params: {
}),
E.bindW("query", ({ parser, checker }) =>
overrideQuery !== undefined
? E.right(overrideQuery)
? E.right({ text: overrideQuery.text, sourcemaps: overrideQuery.sourcemaps })
: tag.type === "TaggedTemplateExpression"
? mapTemplateLiteralToQueryText(
tag.quasi,
Expand Down Expand Up @@ -605,10 +645,19 @@ function reportCheck(params: {
});

// A terminal taking no `<T>` infers its row type from the builder's own
// schema, so it needs no annotation check; only the embedded raw `sql`
// (validated above) matters. A `<T>` terminal falls through below.
// schema; any embedded type checks are delegated to the plugin via
// `ResolvedQuery.typeCheck`.
if (tag.type === "CallExpression" && !calleeAcceptsTypeArgument(tag, checker, parser)) {
return;
return reportResolvedQueryTypeCheck({
context,
connection,
tag,
output: result.output,
typeCheck: overrideQuery?.typeCheck,
checker,
parser,
reservedTypes,
});
}

const isMissingTypeAnnotations = queryTypeParameter === undefined;
Expand Down Expand Up @@ -681,7 +730,45 @@ function reportCheck(params: {
);
}

// True when the terminal declares its own `<T>` (annotation model); false when the row type comes from the receiver.
function reportResolvedQueryTypeCheck(params: {
context: RuleContext;
connection: RuleOptionConnection;
tag: TSESTree.CallExpression;
output: ResolvedTarget | null;
typeCheck?: (
ctx: ResolvedQueryTypeCheckContext,
) => readonly ResolvedQueryTypeCheckResult[] | undefined;
checker: ts.TypeChecker;
parser: ParserServices;
reservedTypes: Set<string>;
}): void {
const { context, connection, tag, output, typeCheck, checker, parser, reservedTypes } = params;

if (typeCheck === undefined) {
return;
}

const results = typeCheck({
terminal: tag,
output: output as PluginResolvedTarget | null,
checker,
parser,
sourceCode: context.sourceCode,
getComparableString: makeGetComparableString(connection),
resolveExpectedType: (typeNode) =>
getResolvedTargetByTypeNode({
checker,
parser,
typeNode,
reservedTypes,
}) as PluginResolvedTarget | null,
});

for (const result of results ?? []) {
reportTypeCheckResult({ context, connection, result, defaultNode: tag });
}
}

function calleeAcceptsTypeArgument(
node: TSESTree.CallExpression,
checker: ts.TypeChecker,
Expand Down
14 changes: 13 additions & 1 deletion packages/plugin-utils/src/index.contract.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { ParserServices, TSESLint } from "@typescript-eslint/utils";
import type ts from "typescript";
import type { ParserServices } from "@typescript-eslint/utils";

import type {
QuerySourceMapEntry,
ResolvedQuery,
ResolvedQueryTypeCheckContext,
SafeQLPlugin,
ResolveQueryContext,
QueryNodeSelector,
Expand Down Expand Up @@ -55,6 +56,17 @@ const _testPlugin: SafeQLPlugin = {
},
};

const _typeCheckContext: ResolvedQueryTypeCheckContext = {
terminal: {} as ResolvedQueryTypeCheckContext["terminal"],
output: null,
checker: {} as ts.TypeChecker,
parser: {} as ParserServices,
sourceCode: {} as TSESLint.SourceCode,
getComparableString: () => "string",
resolveExpectedType: () => null,
};

void _assertResolvedQueryType;
void _typeCheckContext;
void _queryContext;
void _testPlugin;
33 changes: 31 additions & 2 deletions packages/plugin-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,40 @@ export function matchesQueryNodeSelector(
);
}

export type ResolvedQuery = {
// Passed to `ResolvedQuery.typeCheck`, the deferred check the core runs once the query's row
// type is known. A builder plugin uses it to validate the `<T>` a user wrote on an embedded
// `sql` fragment against the type the database actually produced.
export interface ResolvedQueryTypeCheckContext {
terminal: TSESTree.CallExpression;
output: PluginResolvedTarget | null;
checker: ts.TypeChecker;
parser: ParserServices;
sourceCode: Readonly<TSESLint.SourceCode>;
getComparableString(target: PluginResolvedTarget): string;
// Resolve a user-written `<T>` into the same shape the database output uses, so the two are comparable.
resolveExpectedType(typeNode: TSESTree.TypeNode): PluginResolvedTarget | null;
}

// A wrong `<T>` annotation. The core renders it like any other incorrect annotation: its
// standard message plus an autofix that rewrites the type parameter to `actual`.
export interface IncorrectTypeAnnotationReport {
kind: "incorrect-type-annotation";
typeParameter: TSESTree.TSTypeParameterInstantiation;
expected: string | null;
actual: string | null;
}

export type ResolvedQueryTypeCheckResult = TypeCheckReport | IncorrectTypeAnnotationReport;

export interface ResolvedQuery {
kind: "sql";
text: string;
sourcemaps: QuerySourceMapEntry[];
};
// A query can embed several fragments, so the check reports one result per problem (none → `undefined`).
typeCheck?: (
ctx: ResolvedQueryTypeCheckContext,
) => readonly ResolvedQueryTypeCheckResult[] | undefined;
}

// The freshly-created, empty shadow database to apply the project's migrations to.
export interface MigrateContext {
Expand Down
Loading
Loading