mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 08:02:18 +00:00
The audit parameter of a data table config save carried the whole settings blob, generated role passwords included. Redact it the way every other export of that blob already is. Both SDKs pasted the caller's role straight into the `-- role` annotation, where a newline ends the comment and leaves the rest running as whatever the first line named. Check the value against the role-name grammar the server enforces.
695 lines
25 KiB
TypeScript
695 lines
25 KiB
TypeScript
/**
|
|
* Standalone tests for `wmill.datatable()` / `wmill.ducklake()` SQL template
|
|
* functions.
|
|
*
|
|
* The real `sqlUtils.ts` imports `./services.gen` (auto-generated, not in
|
|
* the repo) so we can't import it here. Instead we re-implement the same
|
|
* type-inference / template-building / `.query()` pipeline inline (sans the
|
|
* network calls) and assert the `content` + `args` shapes the executor
|
|
* would receive. Each test maps 1:1 to a behaviour this PR introduces or
|
|
* fixes (BigInt, homogeneous arrays, `.query()` positional, etc.) so they
|
|
* also serve as a regression backstop.
|
|
*
|
|
* Run with: bun test typescript-client/tests/sqlUtils.test.ts
|
|
*/
|
|
import { expect, test, describe } from "bun:test";
|
|
|
|
// =============================================================================
|
|
// Pure SDK logic (mirror of typescript-client/sqlUtils.ts — kept minimal,
|
|
// only the parts that decide content / args).
|
|
// =============================================================================
|
|
|
|
class RawSql {
|
|
readonly __brand = "RawSql" as const;
|
|
constructor(public readonly value: string) {}
|
|
}
|
|
|
|
interface SqlProvider {
|
|
annotations(): string;
|
|
formatArgDecl(argNum: number, argType: string): string;
|
|
formatArgUsage(
|
|
argNum: number,
|
|
explicitType: string | undefined,
|
|
inferredType: string
|
|
): string;
|
|
preamble(): string;
|
|
language: "postgresql" | "duckdb";
|
|
extraArgs: Record<string, any>;
|
|
providerName: string;
|
|
}
|
|
|
|
const ROLE_NAME_RE = /^[A-Za-z0-9_-]{1,63}$/;
|
|
|
|
function datatableProvider(
|
|
name: string,
|
|
schema?: string,
|
|
role?: string
|
|
): SqlProvider {
|
|
if (role !== undefined && !ROLE_NAME_RE.test(role)) {
|
|
throw new Error(
|
|
`Invalid data table role '${role}': must be 1-63 characters of letters, digits, '_' or '-'`
|
|
);
|
|
}
|
|
return {
|
|
providerName: "datatable",
|
|
language: "postgresql",
|
|
extraArgs: { database: `datatable://${name}` },
|
|
annotations: () => (role ? `-- role ${role}\n` : ""),
|
|
formatArgDecl: (argNum) => `-- $${argNum} arg${argNum}`,
|
|
formatArgUsage: (argNum, explicitType, inferredType) =>
|
|
explicitType !== undefined
|
|
? `$${argNum}`
|
|
: `$${argNum}::${inferredType}`,
|
|
preamble: () => (schema ? `SET search_path TO "${schema}";\n` : ""),
|
|
};
|
|
}
|
|
|
|
function ducklakeProvider(name: string, schema?: string): SqlProvider {
|
|
return {
|
|
providerName: "ducklake",
|
|
language: "duckdb",
|
|
extraArgs: {},
|
|
annotations: () => "",
|
|
formatArgDecl: (argNum, argType) => `-- $arg${argNum} (${argType})`,
|
|
formatArgUsage: (argNum) => `$arg${argNum}`,
|
|
preamble: () =>
|
|
`ATTACH 'ducklake://${name}' AS dl;USE dl${schema ? `."${schema}"` : ""};\n`,
|
|
};
|
|
}
|
|
|
|
function parseName(name: string | undefined): { name: string; schema?: string } {
|
|
if (!name) return { name: "main" };
|
|
let [assetName, schemaName] = name.split(":");
|
|
if (schemaName) return { name: assetName || "main", schema: schemaName };
|
|
return { name };
|
|
}
|
|
|
|
function inferSqlType(value: any): string {
|
|
if (typeof value === "bigint") return "BIGINT";
|
|
if (typeof value === "number") {
|
|
if (Number.isInteger(value)) return "BIGINT";
|
|
return "DOUBLE PRECISION";
|
|
} else if (value === null || value === undefined) {
|
|
return "TEXT";
|
|
} else if (typeof value === "string") {
|
|
return "TEXT";
|
|
} else if (Array.isArray(value)) {
|
|
return inferSqlArrayType(value);
|
|
} else if (value instanceof Date) {
|
|
return "TIMESTAMPTZ";
|
|
} else if (typeof value === "object") {
|
|
return "JSON";
|
|
} else if (typeof value === "boolean") {
|
|
return "BOOLEAN";
|
|
} else {
|
|
return "TEXT";
|
|
}
|
|
}
|
|
|
|
function inferSqlArrayType(value: any[]): string {
|
|
if (value.length === 0) return "JSON";
|
|
let scalarType: string | undefined = undefined;
|
|
for (const elem of value) {
|
|
let elemType: string;
|
|
if (typeof elem === "bigint") elemType = "BIGINT";
|
|
else if (typeof elem === "number")
|
|
elemType = Number.isInteger(elem) ? "BIGINT" : "DOUBLE PRECISION";
|
|
else if (typeof elem === "string") elemType = "TEXT";
|
|
else if (typeof elem === "boolean") elemType = "BOOLEAN";
|
|
else return "JSON";
|
|
if (scalarType === undefined) scalarType = elemType;
|
|
else if (scalarType === "BIGINT" && elemType === "DOUBLE PRECISION")
|
|
scalarType = "DOUBLE PRECISION";
|
|
else if (scalarType === "DOUBLE PRECISION" && elemType === "BIGINT") {
|
|
// already widened
|
|
} else if (scalarType !== elemType) {
|
|
return "JSON";
|
|
}
|
|
}
|
|
return `${scalarType}[]`;
|
|
}
|
|
|
|
function parseTypeAnnotation(
|
|
prevTemplateString: string | undefined,
|
|
nextTemplateString: string | undefined
|
|
): string | undefined {
|
|
if (!nextTemplateString) return;
|
|
nextTemplateString = nextTemplateString.trimStart();
|
|
if (nextTemplateString.startsWith("::")) {
|
|
return nextTemplateString.substring(2).trimStart().split(/\s+/)[0];
|
|
}
|
|
prevTemplateString = prevTemplateString?.trimEnd();
|
|
if (
|
|
prevTemplateString?.endsWith("(") &&
|
|
prevTemplateString
|
|
.substring(0, prevTemplateString.length - 1)
|
|
.trim()
|
|
.toUpperCase()
|
|
.endsWith("CAST") &&
|
|
nextTemplateString.toUpperCase().startsWith("AS ")
|
|
) {
|
|
return nextTemplateString.substring(2).trimStart().split(/\s+/)[0];
|
|
}
|
|
}
|
|
|
|
function serializeArgValue(v: any): any {
|
|
if (typeof v === "bigint") return v.toString();
|
|
if (v instanceof Date) return v.toISOString();
|
|
if (typeof v === "number" && !Number.isFinite(v)) {
|
|
if (Number.isNaN(v)) return "NaN";
|
|
return v > 0 ? "Infinity" : "-Infinity";
|
|
}
|
|
return v;
|
|
}
|
|
|
|
function buildContentAndArgs(
|
|
provider: SqlProvider,
|
|
strings: TemplateStringsArray | string[],
|
|
values: any[]
|
|
): { content: string; args: Record<string, any> } {
|
|
let argIndex = 0;
|
|
const valueInfos = values.map((v, i) => {
|
|
if (v instanceof RawSql)
|
|
return { raw: true as const, value: v.value, originalIndex: i };
|
|
argIndex++;
|
|
return {
|
|
raw: false as const,
|
|
value: v,
|
|
originalIndex: i,
|
|
argNum: argIndex,
|
|
};
|
|
});
|
|
|
|
let argDecls = valueInfos
|
|
.filter((info): info is Extract<typeof valueInfos[number], { raw: false }> => !info.raw)
|
|
.map((info) => {
|
|
let argType =
|
|
parseTypeAnnotation(
|
|
strings[info.originalIndex],
|
|
strings[info.originalIndex + 1]
|
|
) || inferSqlType(info.value);
|
|
return provider.formatArgDecl(info.argNum, argType);
|
|
});
|
|
|
|
let content = provider.annotations();
|
|
content += argDecls.length ? argDecls.join("\n") + "\n" : "";
|
|
content += provider.preamble();
|
|
|
|
let contentBody = "";
|
|
for (let i = 0; i < strings.length; i++) {
|
|
contentBody += strings[i];
|
|
if (i < valueInfos.length) {
|
|
let info = valueInfos[i];
|
|
if (info.raw) {
|
|
contentBody += info.value;
|
|
} else {
|
|
let explicitType = parseTypeAnnotation(
|
|
strings[info.originalIndex],
|
|
strings[info.originalIndex + 1]
|
|
);
|
|
let inferredType = inferSqlType(info.value);
|
|
contentBody += provider.formatArgUsage(
|
|
info.argNum,
|
|
explicitType,
|
|
inferredType
|
|
);
|
|
}
|
|
}
|
|
}
|
|
content += contentBody;
|
|
|
|
const args = {
|
|
...Object.fromEntries(
|
|
valueInfos
|
|
.filter((info): info is Extract<typeof valueInfos[number], { raw: false }> => !info.raw)
|
|
.map((info) => [`arg${info.argNum}`, serializeArgValue(info.value)])
|
|
),
|
|
...provider.extraArgs,
|
|
};
|
|
return { content, args };
|
|
}
|
|
|
|
function buildDatatableQuery(
|
|
provider: SqlProvider,
|
|
sqlString: string,
|
|
params: any[]
|
|
): { content: string; args: Record<string, any> } {
|
|
let argDecls = params
|
|
.map((v, i) => `-- $${i + 1} arg${i + 1} (${inferSqlType(v)})`)
|
|
.join("\n");
|
|
let content =
|
|
provider.annotations() +
|
|
(argDecls ? argDecls + "\n" : "") +
|
|
provider.preamble() +
|
|
sqlString;
|
|
let args = {
|
|
...Object.fromEntries(
|
|
params.map((v, i) => [`arg${i + 1}`, serializeArgValue(v)])
|
|
),
|
|
...provider.extraArgs,
|
|
};
|
|
return { content, args };
|
|
}
|
|
|
|
function templateTag(provider: SqlProvider) {
|
|
return (strings: TemplateStringsArray, ...values: any[]) =>
|
|
buildContentAndArgs(provider, strings, values);
|
|
}
|
|
|
|
const dt = (name = "main", role?: string) =>
|
|
templateTag(datatableProvider(name, undefined, role));
|
|
const dl = (name = "main") => {
|
|
const { name: n, schema } = parseName(name);
|
|
return templateTag(ducklakeProvider(n, schema));
|
|
};
|
|
const datatableQuery = (name = "main", role?: string) => {
|
|
const provider = datatableProvider(name, undefined, role);
|
|
return (sql: string, ...params: any[]) =>
|
|
buildDatatableQuery(provider, sql, params);
|
|
};
|
|
|
|
// =============================================================================
|
|
// inferSqlType — exhaustive coverage
|
|
// =============================================================================
|
|
|
|
describe("inferSqlType — primitives", () => {
|
|
test("integer Number → BIGINT", () => {
|
|
expect(inferSqlType(0)).toBe("BIGINT");
|
|
expect(inferSqlType(42)).toBe("BIGINT");
|
|
expect(inferSqlType(-7)).toBe("BIGINT");
|
|
expect(inferSqlType(Number.MAX_SAFE_INTEGER)).toBe("BIGINT");
|
|
});
|
|
|
|
test("non-integer Number → DOUBLE PRECISION", () => {
|
|
expect(inferSqlType(0.5)).toBe("DOUBLE PRECISION");
|
|
expect(inferSqlType(-3.14)).toBe("DOUBLE PRECISION");
|
|
expect(inferSqlType(Number.EPSILON)).toBe("DOUBLE PRECISION");
|
|
});
|
|
|
|
test("BigInt → BIGINT (not DOUBLE PRECISION)", () => {
|
|
// Pre-fix this branch was unreachable because bigint was bundled with
|
|
// number and `Number.isInteger(BigInt)` returns false → would have
|
|
// returned DOUBLE PRECISION (wrong). The split-out check is the fix.
|
|
expect(inferSqlType(BigInt(0))).toBe("BIGINT");
|
|
expect(inferSqlType(BigInt("9007199254740993"))).toBe("BIGINT");
|
|
expect(inferSqlType(BigInt(-1))).toBe("BIGINT");
|
|
});
|
|
|
|
test("string / null / undefined → TEXT", () => {
|
|
expect(inferSqlType("")).toBe("TEXT");
|
|
expect(inferSqlType("hello")).toBe("TEXT");
|
|
expect(inferSqlType(null)).toBe("TEXT");
|
|
expect(inferSqlType(undefined)).toBe("TEXT");
|
|
});
|
|
|
|
test("boolean → BOOLEAN", () => {
|
|
expect(inferSqlType(true)).toBe("BOOLEAN");
|
|
expect(inferSqlType(false)).toBe("BOOLEAN");
|
|
});
|
|
|
|
test("plain object → JSON", () => {
|
|
expect(inferSqlType({})).toBe("JSON");
|
|
expect(inferSqlType({ a: 1, b: [1, 2] })).toBe("JSON");
|
|
});
|
|
});
|
|
|
|
describe("inferSqlType — arrays", () => {
|
|
test("empty array → JSON", () => {
|
|
expect(inferSqlType([])).toBe("JSON");
|
|
});
|
|
|
|
test("homogeneous integer array → BIGINT[]", () => {
|
|
expect(inferSqlType([1, 2, 3])).toBe("BIGINT[]");
|
|
expect(inferSqlType([0])).toBe("BIGINT[]");
|
|
expect(inferSqlType([-1, 0, 1])).toBe("BIGINT[]");
|
|
});
|
|
|
|
test("homogeneous float array → DOUBLE PRECISION[]", () => {
|
|
expect(inferSqlType([1.5, 2.5])).toBe("DOUBLE PRECISION[]");
|
|
});
|
|
|
|
test("mixed int/float array widens to DOUBLE PRECISION[]", () => {
|
|
expect(inferSqlType([1, 2.5])).toBe("DOUBLE PRECISION[]");
|
|
expect(inferSqlType([1.5, 2])).toBe("DOUBLE PRECISION[]");
|
|
});
|
|
|
|
test("homogeneous string array → TEXT[]", () => {
|
|
expect(inferSqlType(["a", "b", "c"])).toBe("TEXT[]");
|
|
expect(inferSqlType([""])).toBe("TEXT[]");
|
|
});
|
|
|
|
test("homogeneous bool array → BOOLEAN[]", () => {
|
|
expect(inferSqlType([true, false, true])).toBe("BOOLEAN[]");
|
|
});
|
|
|
|
test("homogeneous bigint array → BIGINT[]", () => {
|
|
expect(inferSqlType([BigInt(1), BigInt(2)])).toBe("BIGINT[]");
|
|
});
|
|
|
|
test("non-homogeneous array → JSON", () => {
|
|
expect(inferSqlType([1, "x"])).toBe("JSON");
|
|
expect(inferSqlType(["a", true])).toBe("JSON");
|
|
expect(inferSqlType([1, null])).toBe("JSON");
|
|
expect(inferSqlType([true, 1])).toBe("JSON");
|
|
});
|
|
|
|
test("nested array → JSON (current limitation, no auto-tag for 2D)", () => {
|
|
expect(inferSqlType([[1], [2]])).toBe("JSON");
|
|
expect(inferSqlType([{ a: 1 }, { a: 2 }])).toBe("JSON");
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// parseTypeAnnotation — used by the SDK to suppress its own ::TYPE injection
|
|
// when the user already wrote a cast.
|
|
// =============================================================================
|
|
|
|
describe("parseTypeAnnotation — user-supplied cast detection", () => {
|
|
test("`${x}::int` → 'int'", () => {
|
|
expect(parseTypeAnnotation("SELECT ", "::int FROM t")).toBe("int");
|
|
});
|
|
test("whitespace tolerance after ::", () => {
|
|
expect(parseTypeAnnotation("SELECT ", " :: bigint FROM t")).toBe(
|
|
"bigint"
|
|
);
|
|
});
|
|
test("`CAST(${x} AS int)` → first whitespace-delimited word after AS", () => {
|
|
// The SDK splits on whitespace and doesn't strip closing parens, so
|
|
// `AS int)` returns "int)". The exact value doesn't matter downstream
|
|
// because the SDK only checks `explicitType !== undefined` to skip its
|
|
// own ::cast injection — but we lock the behaviour in.
|
|
expect(parseTypeAnnotation("SELECT CAST(", " AS int)")).toBe("int)");
|
|
});
|
|
test("`CAST ( ${x} AS BOOL )` (whitespace + caps)", () => {
|
|
// Whitespace before `)` causes split to drop it, so this returns "BOOL".
|
|
expect(parseTypeAnnotation("SELECT CAST ( ", " AS BOOL )")).toBe("BOOL");
|
|
});
|
|
test("no cast adjacent → undefined", () => {
|
|
expect(parseTypeAnnotation("SELECT ", " FROM t")).toBeUndefined();
|
|
expect(parseTypeAnnotation("SELECT ", "")).toBeUndefined();
|
|
expect(parseTypeAnnotation(undefined, undefined)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// datatable() template tag — content + args round-trips
|
|
// =============================================================================
|
|
|
|
describe("datatable() — template tag", () => {
|
|
test("primitives auto-tag with ::TYPE inline; decls have no type", () => {
|
|
const sql = dt();
|
|
const out = sql`SELECT ${42}, ${3.14}, ${true}, ${"x"}, ${null}`;
|
|
// datatable provider's formatArgDecl ignores the type, so we get bare
|
|
// decls + the casts in the SQL body.
|
|
expect(out.content).toContain("-- $1 arg1\n");
|
|
expect(out.content).toContain("$1::BIGINT");
|
|
expect(out.content).toContain("$2::DOUBLE PRECISION");
|
|
expect(out.content).toContain("$3::BOOLEAN");
|
|
expect(out.content).toContain("$4::TEXT");
|
|
expect(out.content).toContain("$5::TEXT");
|
|
expect(out.args).toMatchObject({
|
|
arg1: 42,
|
|
arg2: 3.14,
|
|
arg3: true,
|
|
arg4: "x",
|
|
arg5: null,
|
|
});
|
|
});
|
|
|
|
test("user `${x}::int` suppresses SDK's auto-cast (parser sees user's cast)", () => {
|
|
const sql = dt();
|
|
const out = sql`SELECT ${42}::int`;
|
|
expect(out.content).toContain("SELECT $1::int");
|
|
expect(out.content).not.toContain("$1::BIGINT");
|
|
});
|
|
|
|
test("CAST(${x} AS T) syntax → bare $N in SQL (regression #8988)", () => {
|
|
const sql = dt();
|
|
const out = sql`SELECT CAST(${true} AS bool)`;
|
|
expect(out.content).toContain("CAST($1 AS bool)");
|
|
expect(out.content).not.toContain("$1::BOOLEAN");
|
|
});
|
|
|
|
test("BigInt is stringified for JSON transport, tagged as ::BIGINT", () => {
|
|
const sql = dt();
|
|
const out = sql`SELECT ${BigInt("9007199254740993")}`;
|
|
expect(out.content).toContain("$1::BIGINT");
|
|
expect(out.args.arg1).toBe("9007199254740993");
|
|
// Round-trip through JSON without throwing — the original bug.
|
|
expect(() => JSON.stringify(out.args)).not.toThrow();
|
|
});
|
|
|
|
test("BigInt zero / negative / large", () => {
|
|
const sql = dt();
|
|
expect(sql`SELECT ${BigInt(0)}`.args.arg1).toBe("0");
|
|
expect(sql`SELECT ${BigInt(-1)}`.args.arg1).toBe("-1");
|
|
expect(sql`SELECT ${BigInt("99999999999999999999")}`.args.arg1).toBe(
|
|
"99999999999999999999"
|
|
);
|
|
});
|
|
|
|
test("Date is auto-tagged ::TIMESTAMPTZ and ISO-stringified", () => {
|
|
// Pre-fix: typeof Date === "object" → ::JSON, then PG cast chain
|
|
// worked accidentally for `${date}::timestamptz`. Now: explicit
|
|
// ::TIMESTAMPTZ + Date.toISOString() so plain `${date}` against a
|
|
// timestamptz column doesn't need a cast.
|
|
const sql = dt();
|
|
const d = new Date("2024-01-15T10:30:00.000Z");
|
|
const out = sql`SELECT ${d} AS t`;
|
|
expect(out.content).toContain("$1::TIMESTAMPTZ");
|
|
expect(out.args.arg1).toBe("2024-01-15T10:30:00.000Z");
|
|
expect(() => JSON.stringify(out.args)).not.toThrow();
|
|
});
|
|
|
|
test("non-finite Number is stringified for the executor", () => {
|
|
// JSON.stringify(NaN) and JSON.stringify(Infinity) both produce `null`,
|
|
// which silently became NULL in the database. The executor accepts
|
|
// "NaN" / "Infinity" / "-Infinity" via `Value::String → FLOAT8`
|
|
// (`f64::from_str`), so we send the special values as strings.
|
|
const sql = dt();
|
|
expect(sql`SELECT ${NaN}`.args.arg1).toBe("NaN");
|
|
expect(sql`SELECT ${Infinity}`.args.arg1).toBe("Infinity");
|
|
expect(sql`SELECT ${-Infinity}`.args.arg1).toBe("-Infinity");
|
|
// Tag stays DOUBLE PRECISION (these are floats).
|
|
expect(sql`SELECT ${NaN}`.content).toContain("$1::DOUBLE PRECISION");
|
|
});
|
|
|
|
test("homogeneous arrays auto-tag with TYPE[]", () => {
|
|
const sql = dt();
|
|
expect(sql`SELECT ${[1, 2, 3]}`.content).toContain("$1::BIGINT[]");
|
|
expect(sql`SELECT ${[1.5, 2.5]}`.content).toContain(
|
|
"$1::DOUBLE PRECISION[]"
|
|
);
|
|
expect(sql`SELECT ${["a", "b"]}`.content).toContain("$1::TEXT[]");
|
|
expect(sql`SELECT ${[true, false]}`.content).toContain("$1::BOOLEAN[]");
|
|
});
|
|
|
|
test("non-homogeneous and empty arrays fall back to JSON", () => {
|
|
const sql = dt();
|
|
expect(sql`SELECT ${[1, "x"]}`.content).toContain("$1::JSON");
|
|
expect(sql`SELECT ${[]}`.content).toContain("$1::JSON");
|
|
expect(sql`SELECT ${[[1], [2]]}`.content).toContain("$1::JSON");
|
|
});
|
|
|
|
test("mixed-numeric array widens to DOUBLE PRECISION[]", () => {
|
|
const sql = dt();
|
|
expect(sql`SELECT ${[1, 2.5]}`.content).toContain(
|
|
"$1::DOUBLE PRECISION[]"
|
|
);
|
|
});
|
|
|
|
test("multiple args get distinct decls + numbered placeholders", () => {
|
|
const sql = dt();
|
|
const out = sql`INSERT INTO t VALUES (${1}, ${"x"}, ${[true, false]})`;
|
|
expect(out.content).toContain("-- $1 arg1");
|
|
expect(out.content).toContain("-- $2 arg2");
|
|
expect(out.content).toContain("-- $3 arg3");
|
|
expect(out.content).toContain("$1::BIGINT");
|
|
expect(out.content).toContain("$2::TEXT");
|
|
expect(out.content).toContain("$3::BOOLEAN[]");
|
|
expect(out.args).toMatchObject({
|
|
arg1: 1,
|
|
arg2: "x",
|
|
arg3: [true, false],
|
|
});
|
|
});
|
|
|
|
test("RawSql is inlined verbatim, doesn't consume an arg index", () => {
|
|
const sql = dt();
|
|
const col = new RawSql("name");
|
|
const out = sql`SELECT ${col} FROM t WHERE id = ${42}`;
|
|
// Only one decl, only one arg in args dict.
|
|
expect(out.content.match(/^-- \$\d+/gm)?.length).toBe(1);
|
|
expect(out.content).toContain("SELECT name FROM t WHERE id = $1::BIGINT");
|
|
expect(Object.keys(out.args).filter((k) => k.startsWith("arg")).length).toBe(
|
|
1
|
|
);
|
|
expect(out.args).toMatchObject({ arg1: 42 });
|
|
});
|
|
|
|
test("schema name is propagated as SET search_path preamble", () => {
|
|
const sql = dt("main");
|
|
const out = sql`SELECT 1`;
|
|
expect(out.args.database).toBe("datatable://main");
|
|
});
|
|
|
|
test("database extra arg is always present", () => {
|
|
const sql = dt("custom_db");
|
|
const out = sql`SELECT ${1}`;
|
|
expect(out.args.database).toBe("datatable://custom_db");
|
|
});
|
|
|
|
test("role is emitted as the first line, ahead of arg declarations", () => {
|
|
// The executor stops parsing annotations at the first non-comment line, and
|
|
// a role landing after the preamble's SET would never be seen.
|
|
const out = dt("main", "analyst")`SELECT ${1}`;
|
|
expect(out.content.split("\n")[0]).toBe("-- role analyst");
|
|
// No role means no annotation at all, not an empty one.
|
|
expect(dt("main")`SELECT 1`.content).not.toContain("-- role");
|
|
});
|
|
|
|
test("query() also emits the role annotation first", () => {
|
|
const out = datatableQuery("main", "analyst")("SELECT $1", 42);
|
|
expect(out.content.split("\n")[0]).toBe("-- role analyst");
|
|
});
|
|
|
|
test("a role that is not a role name is refused, not annotated", () => {
|
|
// The annotation is a comment line: a newline in the value would end it and
|
|
// leave the rest as SQL running under whatever role the first line named.
|
|
expect(() => dt("main", "admin\nDELETE FROM customers")).toThrow(
|
|
/Invalid data table role/
|
|
);
|
|
expect(() => dt("main", "operator-1")).not.toThrow();
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// datatable().query() — positional placeholders (the previously-broken path)
|
|
// =============================================================================
|
|
|
|
describe("datatable().query() — positional placeholders", () => {
|
|
test("emits typed declarations + SQL verbatim, no appended placeholders", () => {
|
|
const q = datatableQuery();
|
|
const out = q("SELECT $1, $2", 42, "hello");
|
|
expect(out.content).toContain("-- $1 arg1 (BIGINT)");
|
|
expect(out.content).toContain("-- $2 arg2 (TEXT)");
|
|
// Crucially: SQL must end with the user's SQL, NOT have placeholders
|
|
// appended after it (the pre-fix bug).
|
|
expect(out.content.endsWith("SELECT $1, $2")).toBe(true);
|
|
expect(out.args).toMatchObject({ arg1: 42, arg2: "hello" });
|
|
});
|
|
|
|
test("BigInt args are stringified", () => {
|
|
const q = datatableQuery();
|
|
const out = q("SELECT $1", BigInt("100"));
|
|
expect(out.args.arg1).toBe("100");
|
|
expect(out.content).toContain("-- $1 arg1 (BIGINT)");
|
|
});
|
|
|
|
test("array args auto-tag homogeneously in the decl block", () => {
|
|
const q = datatableQuery();
|
|
const out = q("SELECT $1, $2", [1, 2], ["a", "b"]);
|
|
expect(out.content).toContain("-- $1 arg1 (BIGINT[])");
|
|
expect(out.content).toContain("-- $2 arg2 (TEXT[])");
|
|
});
|
|
|
|
test("zero params → no decl block, just SQL + extras", () => {
|
|
const q = datatableQuery();
|
|
const out = q("SELECT 1");
|
|
expect(out.content).not.toContain("-- $");
|
|
expect(out.content).toContain("SELECT 1");
|
|
// database extra still injected.
|
|
expect(out.args.database).toBe("datatable://main");
|
|
});
|
|
|
|
test("ten params number contiguously", () => {
|
|
const q = datatableQuery();
|
|
const params = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
|
const out = q("SELECT " + params.map((_, i) => `$${i + 1}`).join(","), ...params);
|
|
for (let i = 1; i <= 10; i++) {
|
|
expect(out.content).toContain(`-- $${i} arg${i} (BIGINT)`);
|
|
expect(out.args[`arg${i}`]).toBe(i);
|
|
}
|
|
});
|
|
|
|
test(".query()'s decl format matches what the executor parses (regression)", () => {
|
|
// The PG parser's RE_ARG_PGSQL is:
|
|
// ^-- \$(\d+) (\w+)(?: \(([A-Za-z0-9_\[\]]+)\))?(?: ?\= ?(.+))? *$
|
|
// We assert our decl line matches that grammar so the executor doesn't
|
|
// fall back to "inferred default text".
|
|
const q = datatableQuery();
|
|
const out = q("SELECT $1", 42);
|
|
const declRe = /^-- \$\d+ \w+ \([A-Za-z0-9_\[\]]+\) *$/m;
|
|
expect(out.content).toMatch(declRe);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// ducklake() template tag — DuckDB declares types in the comment (different
|
|
// shape from datatable). Same auto-tag rules apply for inferSqlType.
|
|
// =============================================================================
|
|
|
|
describe("ducklake() — DuckDB shape", () => {
|
|
test("declarations carry the type", () => {
|
|
const sql = dl("main");
|
|
const out = sql`SELECT ${42}, ${"hello"}, ${true}`;
|
|
expect(out.content).toContain("-- $arg1 (BIGINT)");
|
|
expect(out.content).toContain("-- $arg2 (TEXT)");
|
|
expect(out.content).toContain("-- $arg3 (BOOLEAN)");
|
|
// Preamble attaches the ducklake.
|
|
expect(out.content).toContain("ATTACH 'ducklake://main' AS dl;USE dl;");
|
|
// Args are referenced via $argN syntax in the SQL body.
|
|
expect(out.content).toContain("$arg1");
|
|
});
|
|
|
|
test("BigInt + homogeneous arrays propagate to ducklake too", () => {
|
|
const sql = dl("main");
|
|
const out = sql`SELECT ${BigInt(9)}, ${[1, 2, 3]}, ${["a", "b"]}`;
|
|
expect(out.content).toContain("(BIGINT)");
|
|
expect(out.content).toContain("(BIGINT[])");
|
|
expect(out.content).toContain("(TEXT[])");
|
|
expect(out.args.arg1).toBe("9");
|
|
expect(out.args.arg2).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
test("ducklake doesn't carry a database extra arg", () => {
|
|
const sql = dl();
|
|
const out = sql`SELECT 1`;
|
|
expect(out.args).not.toHaveProperty("database");
|
|
});
|
|
|
|
test("name:schema sets the active schema via USE dl.<schema>", () => {
|
|
const sql = dl("my_lake:analytics");
|
|
const out = sql`SELECT 1`;
|
|
expect(out.content).toContain(
|
|
`ATTACH 'ducklake://my_lake' AS dl;USE dl."analytics";`
|
|
);
|
|
});
|
|
|
|
test("no schema keeps the bare USE dl preamble", () => {
|
|
const sql = dl("my_lake");
|
|
const out = sql`SELECT 1`;
|
|
expect(out.content).toContain("ATTACH 'ducklake://my_lake' AS dl;USE dl;");
|
|
expect(out.content).not.toContain("USE dl.");
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Cross-cutting: the args dict must always be JSON-serialisable.
|
|
// =============================================================================
|
|
|
|
describe("args dict is JSON-serialisable for every supported value shape", () => {
|
|
test("BigInt, primitives, arrays, objects, raw — none throw", () => {
|
|
const sql = dt();
|
|
const out = sql`
|
|
SELECT ${BigInt(1)}, ${1}, ${1.5}, ${"x"}, ${true}, ${null},
|
|
${[1, 2]}, ${["a", "b"]}, ${[true, false]},
|
|
${{ k: 1 }}, ${[1, "x"]}
|
|
`;
|
|
const json = JSON.stringify(out.args);
|
|
expect(typeof json).toBe("string");
|
|
// BigInt got stringified, not thrown.
|
|
expect(json).toContain('"arg1":"1"');
|
|
});
|
|
});
|