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
87 changes: 63 additions & 24 deletions packages/trilean-sql/README.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions packages/trilean-sql/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"keywords": [
"sql",
"postgres",
"sqlite",
"predicate",
"three-valued-logic",
"query-builder",
Expand All @@ -82,9 +83,11 @@
"@electric-sql/pglite": "^0.5.8",
"@exadev/eslint-config": "^2.10.2",
"@testcontainers/postgresql": "^12.1.0",
"@types/better-sqlite3": "^9.6.0",
"@types/node": "^26.4.0",
"@types/pg": "^8.15.6",
"@vitest/coverage-v8": "^4.1.11",
"better-sqlite3": "^12.11.1",
"eslint": "^10.9.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
Expand Down
216 changes: 213 additions & 3 deletions packages/trilean-sql/src/compile.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import type { PredicateNode } from "trilean";
import { describe, expect, it, vi } from "vitest";
import { compilePredicateNode } from "./compile";
import { InvalidColumnError, UnsupportedNodeError } from "./errors";
import type { SqlCompileOptions } from "./options";
import { subjectOptions } from "./test-support/columns";
import {
InvalidColumnError,
UnknownDialectError,
UnsupportedNodeError,
} from "./errors";
import { findUnpushableNodeKind } from "./guard";
import type { SqlCompileOptions, SqlDialect } from "./options";
import { sqliteSubjectOptions, subjectOptions } from "./test-support/columns";

function compile(
node: PredicateNode,
Expand Down Expand Up @@ -459,3 +464,208 @@ describe("refusal", () => {
},
);
});

describe("the sqlite dialect", () => {
function compileSqlite(node: PredicateNode) {
return compile(node, sqliteSubjectOptions);
}

it("renders every placeholder as a bare '?', with no number and no cast", () => {
// SQLite binds by position in emission order rather than by an index written into the text, and it has no type to cast a parameter to. Asserted across a nested tree because the numbering is exactly what a bare '?' drops: the three parameters below are told apart only by the order they appear in.
expect(
compileSqlite({
kind: "allOf",
operands: [
{
kind: "compare",
op: "gt",
left: { kind: "reference", key: "age" },
right: { kind: "numberLiteral", value: LOWER_BOUND },
},
{
kind: "memberOf",
op: "in",
operand: { kind: "reference", key: "name" },
candidates: [
{ kind: "textLiteral", value: "b" },
{ kind: "textLiteral", value: "c" },
],
},
],
}),
).toEqual({
sql: '(("age" > ?) AND ("name" IN (?, ?)))',
params: [LOWER_BOUND, "b", "c"],
});
});

it("compares two literals without either side needing a type", () => {
// The case PostgreSQL cannot execute uncast at all. SQLite answers it from the bound values themselves, so there is nothing to annotate.
expect(
compileSqlite({
kind: "compare",
op: "lt",
left: { kind: "numberLiteral", value: 1 },
right: { kind: "numberLiteral", value: 2 },
}),
).toEqual({ sql: "(? < ?)", params: [1, 2] });
});

it("renders an instant literal as a plain parameter, with no timestamp type to cast to", () => {
expect(
compileSqlite({
kind: "compare",
op: "gte",
left: { kind: "reference", key: "joined" },
right: { kind: "instantLiteral", value: "2020-01-01T00:00:00+02:00" },
}),
).toEqual({
sql: '("joined" >= ?)',
params: ["2020-01-01T00:00:00+02:00"],
});
});

it.each([
["equals", "="],
["notEquals", "<>"],
["matches", "REGEXP"],
["notMatches", "NOT REGEXP"],
] as const)("compiles textCompare '%s' to '%s'", (op, sqlOperator) => {
// `=` and `<>` are ANSI-standard and identical to the PostgreSQL dialect's; only the two pattern operators differ, and SQLite's are the reserved REGEXP syntax for a function the connection registers itself.
expect(
compileSqlite({
kind: "textCompare",
op,
left: { kind: "reference", key: "name" },
right: { kind: "textLiteral", value: "^a" },
}),
).toEqual({ sql: `("name" ${sqlOperator} ?)`, params: ["^a"] });
});

it("compiles an empty candidate list without a boolean annotation on the NULL", () => {
// SQLite has no boolean type to annotate, and the annotation is not what the encoding depends on: the integration suite executes both of these and gets the same three-valued answers the `::boolean` forms give PostgreSQL.
expect(
compileSqlite({
kind: "memberOf",
op: "in",
operand: { kind: "reference", key: "name" },
candidates: [],
}),
).toEqual({ sql: '("name" IS NULL AND NULL)', params: [] });

expect(
compileSqlite({
kind: "memberOf",
op: "notIn",
operand: { kind: "reference", key: "name" },
candidates: [],
}),
).toEqual({ sql: '("name" IS NOT NULL OR NULL)', params: [] });
});

it("emits the dialect-neutral structure identically to PostgreSQL", () => {
// Everything the two dialects share, in one tree: the connectives, the six comparison operators, `IS NOT NULL`, `NOT IN`, and double-quoted identifiers. The only difference between this expectation and the PostgreSQL one is the placeholders.
const node: PredicateNode = {
kind: "not",
operand: {
kind: "and",
left: ageOver,
right: {
kind: "or",
left: { kind: "exists", operand: { kind: "reference", key: "note" } },
right: {
kind: "memberOf",
op: "notIn",
operand: { kind: "reference", key: "age" },
candidates: [{ kind: "numberLiteral", value: EXCLUDED_AGE }],
},
},
},
};

expect(compileSqlite(node).sql).toBe(
'(NOT (("age" > ?) AND (("note" IS NOT NULL) OR ("age" NOT IN (?)))))',
);
expect(compile(node).sql).toBe(
'(NOT (("age" > $1::double precision) AND (("note" IS NOT NULL) OR ("age" NOT IN ($2::double precision)))))',
);
});

it("quotes and neutralises identifiers exactly as the PostgreSQL dialect does", () => {
// Double-quoting with an embedded quote doubled is ANSI-standard, so the injection defence is the same string in both dialects rather than a per-dialect rule.
const hostile: SqlCompileOptions = {
dialect: "sqlite",
columnFor: () => ({ column: 'note"; DROP TABLE subjects; --' }),
};
expect(
compile(
{ kind: "exists", operand: { kind: "reference", key: "anything" } },
hostile,
).sql,
).toBe('("note""; DROP TABLE subjects; --" IS NOT NULL)');
});

it("compiles an empty allOf and anyOf to the same identities", () => {
expect(compileSqlite({ kind: "allOf", operands: [] }).sql).toBe("(TRUE)");
expect(compileSqlite({ kind: "anyOf", operands: [] }).sql).toBe("(FALSE)");
});
});

describe("a dialect this version does not implement", () => {
// `SqlDialect` is closed, so this is what a caller reading the name from configuration and asserting it into the union at the boundary reaches -- the only way an unimplemented name gets this far, and the reason the assertion is here rather than in the source under test.
const unimplemented = "mysql" as SqlDialect;
const mysqlOptions: SqlCompileOptions = {
dialect: unimplemented,
columnFor: () => ({ column: "age", paramType: "number" }),
};

const anyTree: PredicateNode = {
kind: "compare",
op: "gt",
left: { kind: "reference", key: "age" },
right: { kind: "numberLiteral", value: ADULT_AGE },
};

it("is refused by name, not as an internal error from an empty table lookup", () => {
expect(() => compilePredicateNode(anyTree, mysqlOptions)).toThrow(
UnknownDialectError,
);
expect(() => compilePredicateNode(anyTree, mysqlOptions)).toThrow(
/unknown dialect "mysql": this version compiles "postgres", "sqlite"/,
);
});

it("carries the offending name and the implemented ones as fields", () => {
try {
compilePredicateNode(anyTree, mysqlOptions);
expect.unreachable("compiling an unimplemented dialect must throw");
} catch (error) {
expect(error).toBeInstanceOf(UnknownDialectError);
expect(error).toMatchObject({
dialect: "mysql",
implemented: ["postgres", "sqlite"],
});
}
});

it("is refused before the tree is walked, so the dialect is what gets reported", () => {
// A tree the guard would object to on its own. The dialect is the earlier problem and has to be the one named, since every refusal reason the walk could produce describes an engine that is not the one asked for.
expect(() =>
compilePredicateNode(
{
kind: "compare",
op: "eq",
left: { kind: "reference", key: "age" },
right: { kind: "numberLiteral", value: Number.NaN },
},
mysqlOptions,
),
).toThrow(UnknownDialectError);
});

it("never reports such a tree as pushable, which would promise a compilation that cannot happen", () => {
expect(() => findUnpushableNodeKind(anyTree, mysqlOptions)).toThrow(
UnknownDialectError,
);
});
});
61 changes: 41 additions & 20 deletions packages/trilean-sql/src/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import { InvalidColumnError, UnsupportedNodeError } from "./errors";
import { findUnpushableNodeKind } from "./guard";
import type {
CompiledSql,
DialectConfig,
SqlColumnBinding,
SqlCompileOptions,
SqlParamType,
} from "./options";
import { POSTGRES_TYPE_NAME } from "./options";
import { assertImplementedDialect, DIALECT_CONFIG } from "./options";

const COMPARISON_SQL: Readonly<Record<ComparisonOperator, string>> = {
gt: ">",
Expand All @@ -23,20 +24,26 @@ const COMPARISON_SQL: Readonly<Record<ComparisonOperator, string>> = {
neq: "<>",
};

/** `~` and `!~` are PostgreSQL's own regular-expression match operators, so a pattern is matched by the database rather than shipped back to be matched in process. See the "Regular expressions" caveat in README.md: PostgreSQL's advanced regular expressions and ECMAScript's are close but not the same language. */
const TEXT_COMPARISON_SQL: Readonly<Record<TextComparisonOperator, string>> = {
equals: "=",
notEquals: "<>",
matches: "~",
notMatches: "!~",
};

/**
* The SQL type a literal placeholder is cast to: the one implied by the literal's own trilean kind.
* The operator each `textCompare` op emits, for one dialect.
*
* Casting every placeholder means a fragment's meaning never depends on how a particular driver decided to infer an untyped parameter, and it is what makes a comparison between two literals (`$1 < $2`, which PostgreSQL rejects outright as having undeterminable parameter types) compile to something executable at all.
* `=` and `<>` are ANSI-standard equality and identical everywhere; only the two pattern operators are the dialect's own. Built as a complete record rather than resolved per node so that a `TextComparisonOperator` added to trilean later is a compile error here, instead of an undefined operator spliced into the emitted SQL.
*/
function textComparisonSqlFor(
dialect: Readonly<DialectConfig>,
): Readonly<Record<TextComparisonOperator, string>> {
return {
equals: "=",
notEquals: "<>",
matches: dialect.matches,
notMatches: dialect.notMatches,
};
}

/**
* The value kind a literal placeholder is rendered for: the one implied by the literal's own trilean kind. What a dialect does with it -- PostgreSQL casts the placeholder to the corresponding type, SQLite ignores it -- is `DialectConfig.placeholder`'s business.
*
* A mapped column's declared `paramType` deliberately does not override this. It cannot differ: the guard has already refused any comparison whose operand kinds disagree, and each declared kind maps to the same PostgreSQL type as the literal kind it must then match. Consulting it here would be a branch that can never change the output.
* A mapped column's declared `paramType` deliberately does not override this. It cannot differ: the guard has already refused any comparison whose operand kinds disagree, and each declared kind implies the same SQL type as the literal kind it must then match. Consulting it here would be a branch that can never change the output.
*/
const PARAM_TYPE_OF_LITERAL: Readonly<
Record<
Expand All @@ -52,6 +59,9 @@ const PARAM_TYPE_OF_LITERAL: Readonly<

interface CompileContext {
readonly options: SqlCompileOptions;
/** Resolved once per compilation rather than looked up per node, alongside the `columnFor` memoisation, since the dialect cannot change mid-tree. */
readonly dialect: Readonly<DialectConfig>;
readonly textComparison: Readonly<Record<TextComparisonOperator, string>>;
readonly params: unknown[];
}

Expand All @@ -61,11 +71,11 @@ function placeholder(
castTo: SqlParamType,
): string {
context.params.push(value);
return `$${String(context.params.length)}::${POSTGRES_TYPE_NAME[castTo]}`;
return context.dialect.placeholder(context.params.length, castTo);
}

/**
* Renders a column as a PostgreSQL identifier: each dot-separated segment double-quoted, with any embedded double quote doubled.
* Renders a column as a SQL identifier: each dot-separated segment double-quoted, with any embedded double quote doubled. Double-quoting is ANSI-standard and means the same thing in both dialects, so this needs no per-dialect branch.
*
* A column name cannot be a bind parameter -- it is part of the statement's structure, not its data -- so it is the one caller-supplied string that reaches the SQL text. Quoting it unconditionally is what keeps that safe: the doubling makes even a name containing `"; DROP TABLE ...` a single, inert identifier that simply does not exist. Quoting also means a name is taken literally rather than case-folded, so `columnFor` must return the column's real, case-exact name.
*/
Expand Down Expand Up @@ -173,15 +183,16 @@ function compilePredicate(
case "textCompare": {
const left = compileExpression(node.left, context);
const right = compileExpression(node.right, context);
return `(${left} ${TEXT_COMPARISON_SQL[node.op]} ${right})`;
return `(${left} ${context.textComparison[node.op]} ${right})`;
}
case "memberOf": {
const operand = compileExpression(node.operand, context);
if (node.candidates.length === 0) {
// `IN ()` is a syntax error, and the two constants it would be tempting to fold to are both wrong: an empty `in` is false and an empty `notIn` is true only once the operand itself is known, and stay unknown while it is NULL. These two forms reproduce that exactly -- `NULL IS NULL AND NULL` is NULL while `<value> IS NULL AND NULL` is FALSE, and the `notIn` form is its mirror image -- which a bare FALSE/TRUE would not, most visibly under a surrounding NOT.
// `IN ()` is a syntax error, and the two constants it would be tempting to fold to are both wrong: an empty `in` is false and an empty `notIn` is true only once the operand itself is known, and stay unknown while it is NULL. These two forms reproduce that exactly -- `NULL IS NULL AND NULL` is NULL while `<value> IS NULL AND NULL` is FALSE, and the `notIn` form is its mirror image -- which a bare FALSE/TRUE would not, most visibly under a surrounding NOT. The suffix is the dialect's own boolean annotation on that bare NULL, empty for a dialect with no boolean type to annotate.
const nullLiteral = `NULL${context.dialect.emptyMemberOfNullSuffix}`;
return node.op === "in"
? `(${operand} IS NULL AND NULL::boolean)`
: `(${operand} IS NOT NULL OR NULL::boolean)`;
? `(${operand} IS NULL AND ${nullLiteral})`
: `(${operand} IS NOT NULL OR ${nullLiteral})`;
}
const candidates = node.candidates
.map((candidate) => compileExpression(candidate, context))
Expand All @@ -200,19 +211,23 @@ function compilePredicate(
}

/**
* Compiles a trilean predicate tree into a parameterised PostgreSQL boolean expression.
* Compiles a trilean predicate tree into a parameterised boolean expression in the dialect `options` names.
*
* Three-valued logic is not reimplemented on top of SQL; it is delegated to it. SQL's `AND`, `OR` and `NOT` over `TRUE`/`FALSE`/`NULL` are Kleene's strong three-valued tables, which are the same tables trilean's own `combineAnd`, `combineOr` and `not` implement, and a comparison against a NULL column yields `NULL` exactly where the evaluator would have returned `indeterminate` from an unresolved reference. A row excluded by `WHERE` because its condition was unknown is therefore excluded for the same reason, and by the same rule, as a subject the evaluator declines to judge. No indeterminacy column, sentinel value or `CASE` scaffolding is emitted, because none is needed.
*
* Every caller-supplied literal becomes a bind parameter. Nothing but structure, operators, and quoted column identifiers is ever written into the returned `sql`.
*
* @throws {UnknownDialectError} if `options.dialect` names a dialect this version does not implement.
* @throws {UnsupportedNodeError} if any node in the tree is one this compiler will not translate -- see `findUnpushableNodeKind`, which this runs first and which a caller can run itself to choose between pushdown and in-process evaluation without provoking an exception.
* @throws {InvalidColumnError} if `columnFor` returns a column that cannot be rendered as an identifier.
*/
export function compilePredicateNode(
node: PredicateNode,
options: Readonly<SqlCompileOptions>,
): CompiledSql {
// Before the walk rather than after it, so an unimplemented dialect is reported as itself rather than as whichever node the guard happened to object to first under another dialect's rules.
assertImplementedDialect(options.dialect);

// `columnFor` is called by the guard walk and again while compiling, so it is memoised for the duration of one compilation -- a caller's mapping may be a lookup of real cost, and it must not matter how many times the compiler happens to ask.
const bindings = new Map<string, SqlColumnBinding>();
const memoised: SqlCompileOptions = {
Expand All @@ -229,6 +244,12 @@ export function compilePredicateNode(
const unpushable = findUnpushableNodeKind(node, memoised);
if (unpushable !== undefined) throw new UnsupportedNodeError(unpushable);

const context: CompileContext = { options: memoised, params: [] };
const dialect = DIALECT_CONFIG[options.dialect];
const context: CompileContext = {
options: memoised,
dialect,
textComparison: textComparisonSqlFor(dialect),
params: [],
};
return { sql: compilePredicate(node, context), params: context.params };
}
Loading
Loading