feat: sql.raw in Typescript client (#8706)

* feat: detect sql.raw() in TS parser and tag queries with has_raw_interpolation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: filter out sql.raw queries from type-checking and preparation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: implement sql.raw() for inline raw SQL fragments in template literals

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: split sqlProviderImpl into provider interface + shared builder

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix ts client compilation

* update asset parser

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-04-03 21:20:37 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent dcd615fdc3
commit ce290f68db
10 changed files with 365 additions and 115 deletions
@@ -106,6 +106,28 @@ fn extract_wmill_datatable_call(expr: &Expr) -> Option<(AssetKind, String, Optio
}
None
}
/// Check if an expression is `tag.raw(...)` where `tag` matches the given tag name.
/// Returns true for patterns like `sql.raw(x)`.
fn is_raw_call(expr: &Expr, tag_name: &str) -> bool {
if let Expr::Call(call_expr) = expr {
if let Some(Expr::Member(member)) = call_expr.callee.as_expr().map(AsRef::as_ref) {
let is_tag = matches!(
member.obj.as_ref(),
Expr::Ident(ident) if ident.sym.as_str() == tag_name
);
if is_tag {
if let MemberProp::Ident(prop) = &member.prop {
return prop.sym.as_str() == "raw";
}
}
}
}
false
}
const WM_SQL_RAW_PLACEHOLDER: &str = "__WM_SQL_RAW__";
impl Visit for AssetsFinder {
// visit_call_expr will not recurse if it detects an asset,
// so this will only be called when no further context was found
@@ -230,21 +252,31 @@ impl Visit for AssetsFinder {
return;
};
// Extract the SQL query from the template quasis (string parts)
// Substitute ${} with $1, $2, etc.
let sql: String = node
// Determine which interpolations are sql.raw() calls
let raw_flags: Vec<bool> = node
.tpl
.quasis
.exprs
.iter()
.map(|quasi| quasi.raw.as_str())
.enumerate()
.fold(String::new(), |acc, (i, s)| {
if i == 0 {
s.to_string()
.map(|expr| is_raw_call(expr.as_ref(), tag_name))
.collect();
let has_raw_interpolation = raw_flags.iter().any(|&r| r);
// Extract the SQL query from the template quasis (string parts)
// Substitute ${} with $N for normal args, __WM_SQL_RAW__ for raw args
let mut sql = String::new();
let mut arg_index = 0usize;
for (i, quasi) in node.tpl.quasis.iter().enumerate() {
if i > 0 {
let is_raw = raw_flags.get(i - 1).copied().unwrap_or(false);
if is_raw {
sql.push_str(WM_SQL_RAW_PLACEHOLDER);
} else {
format!("{}${}{}", acc, i, s)
arg_index += 1;
sql.push_str(&format!("${}", arg_index));
}
});
}
sql.push_str(quasi.raw.as_str());
}
// Capture SQL query details before transforming for SQL parser
let span = node.span();
@@ -256,6 +288,7 @@ impl Visit for AssetsFinder {
source_kind: *kind,
source_name: asset_name.clone(),
source_schema: schema.clone(),
has_raw_interpolation,
});
// We use the SQL parser to detect RW, specific tables, etc.
@@ -266,7 +299,11 @@ impl Visit for AssetsFinder {
&sql,
);
match sql_assets {
Ok(Some(sql_assets)) => self.assets.extend(sql_assets),
Ok(Some(sql_assets)) => self.assets.extend(
sql_assets
.into_iter()
.filter(|a| !a.path.contains(WM_SQL_RAW_PLACEHOLDER)),
),
_ => {}
}
}
@@ -629,6 +666,7 @@ mod tests {
assert_eq!(query_detail.source_kind, AssetKind::DataTable);
assert_eq!(query_detail.source_name, "dt");
assert_eq!(query_detail.source_schema, None);
assert_eq!(query_detail.has_raw_interpolation, false);
// Span should be non-zero
assert!(query_detail.span.0 > 0);
assert!(query_detail.span.1 > query_detail.span.0);
@@ -692,5 +730,86 @@ mod tests {
assert_eq!(query_detail.source_kind, AssetKind::Ducklake);
assert_eq!(query_detail.source_name, "my_lake");
assert_eq!(query_detail.source_schema, None);
assert_eq!(query_detail.has_raw_interpolation, false);
}
#[test]
fn test_ts_asset_parser_sql_raw_basic() {
let input = r#"
import * as wmill from "windmill-client"
export async function main(table: string) {
let sql = wmill.datatable('dt')
return await sql`SELECT * FROM ${sql.raw(table)}`.fetch()
}
"#;
let result = parse_assets(input).unwrap();
// sql.raw in table position => the __WM_SQL_RAW__ asset gets filtered out,
// but the datatable itself is still tracked as "used" (without specific table info)
assert_eq!(
result.assets,
vec![ParseAssetsResult {
kind: AssetKind::DataTable,
path: "dt".to_string(),
access_type: None,
columns: None,
}]
);
// Check SQL query details
assert_eq!(result.sql_queries.len(), 1);
let q = &result.sql_queries[0];
assert!(q.query_string.contains("__WM_SQL_RAW__"));
assert!(!q.query_string.contains("$1"));
assert_eq!(q.has_raw_interpolation, true);
assert_eq!(q.source_kind, AssetKind::DataTable);
assert_eq!(q.source_name, "dt");
}
#[test]
fn test_ts_asset_parser_sql_raw_mixed() {
let input = r#"
import * as wmill from "windmill-client"
export async function main(name: string, col: string, val: number) {
let sql = wmill.datatable('dt')
return await sql`SELECT * FROM users WHERE name = ${name} AND ${sql.raw(col)} = ${val}`.fetch()
}
"#;
let result = parse_assets(input).unwrap();
// "users" table should still be detected
assert_eq!(result.assets.len(), 1);
assert_eq!(result.assets[0].path, "dt/users");
assert_eq!(result.assets[0].access_type, Some(R));
// Check SQL query details — arg numbering skips the raw interpolation
assert_eq!(result.sql_queries.len(), 1);
let q = &result.sql_queries[0];
assert_eq!(
q.query_string,
"SELECT * FROM users WHERE name = $1 AND __WM_SQL_RAW__ = $2"
);
assert_eq!(q.has_raw_interpolation, true);
}
#[test]
fn test_ts_asset_parser_sql_raw_no_false_positive() {
// Ensure that a normal query (no sql.raw) still has has_raw_interpolation=false
let input = r#"
import * as wmill from "windmill-client"
export async function main(x: number) {
let sql = wmill.datatable('dt')
return await sql`SELECT * FROM friends WHERE age = ${x}`.fetch()
}
"#;
let result = parse_assets(input).unwrap();
assert_eq!(result.sql_queries.len(), 1);
assert_eq!(result.sql_queries[0].has_raw_interpolation, false);
assert_eq!(
result.sql_queries[0].query_string,
"SELECT * FROM friends WHERE age = $1"
);
assert_eq!(result.assets.len(), 1);
assert_eq!(result.assets[0].path, "dt/friends");
}
}
@@ -39,6 +39,8 @@ pub struct SqlQueryDetails {
pub source_name: String, // e.g., "main", "dt"
#[serde(skip_serializing_if = "Option::is_none")]
pub source_schema: Option<String>, // e.g., Some("public"), None
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub has_raw_interpolation: bool, // true if any ${sql.raw(...)} was used
}
#[derive(Serialize, Debug, Default)]
+54 -12
View File
@@ -75,8 +75,7 @@
"vscode-languageclient": "~9.0.1",
"vscode-uri": "~3.1.0",
"vscode-ws-jsonrpc": "~3.5.0",
"windmill-parser-wasm": "file:../backend/parsers/windmill-parser-wasm/pkg-ts",
"windmill-parser-wasm-asset": "1.653.0",
"windmill-parser-wasm-asset": "1.673.0",
"windmill-parser-wasm-csharp": "1.510.1",
"windmill-parser-wasm-go": "1.510.1",
"windmill-parser-wasm-java": "1.510.1",
@@ -165,7 +164,8 @@
},
"../backend/parsers/windmill-parser-wasm/pkg-ts": {
"name": "windmill-parser-wasm-ts",
"version": "1.589.3"
"version": "1.673.0",
"extraneous": true
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
@@ -844,6 +844,7 @@
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
"integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -855,6 +856,7 @@
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -865,6 +867,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1354,6 +1357,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1510,6 +1514,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1526,6 +1531,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1542,6 +1548,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1558,6 +1565,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1574,6 +1582,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1590,6 +1599,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1606,6 +1616,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1622,6 +1633,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1638,6 +1650,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1654,6 +1667,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1670,6 +1684,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1686,6 +1701,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1702,6 +1718,7 @@
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1718,6 +1735,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1734,6 +1752,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2039,6 +2058,7 @@
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -6847,7 +6867,7 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"devOptional": true,
"dev": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@@ -7346,6 +7366,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7366,6 +7387,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7386,6 +7408,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7406,6 +7429,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7426,6 +7450,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7446,6 +7471,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7466,6 +7492,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7486,6 +7513,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7506,6 +7534,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7526,6 +7555,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7546,6 +7576,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -12121,6 +12152,21 @@
}
}
},
"node_modules/svelte-check/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/svelte-eslint-parser": {
"version": "0.43.0",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz",
@@ -12851,7 +12897,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -13601,14 +13647,10 @@
"node": ">=8"
}
},
"node_modules/windmill-parser-wasm": {
"resolved": "../backend/parsers/windmill-parser-wasm/pkg-ts",
"link": true
},
"node_modules/windmill-parser-wasm-asset": {
"version": "1.653.0",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-asset/-/windmill-parser-wasm-asset-1.653.0.tgz",
"integrity": "sha512-Tc9smy79wZSEBxAbiad8D4NiydOVHl541amCRQ93yqqffsmFnyRr4tpBYV/dR5uQ4zdX7aTe5ROwz2jhsXODYQ=="
"version": "1.673.0",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-asset/-/windmill-parser-wasm-asset-1.673.0.tgz",
"integrity": "sha512-sJZ9YbKhxMT67wWilmT7CDbwX0eVr+rvc6Y4OdHzOXwTwu3MixUinzVyP/lD4WDQW6xzitqOfstHBUrOxpww3A=="
},
"node_modules/windmill-parser-wasm-csharp": {
"version": "1.510.1",
+2 -3
View File
@@ -148,8 +148,7 @@
"vscode-languageclient": "~9.0.1",
"vscode-uri": "~3.1.0",
"vscode-ws-jsonrpc": "~3.5.0",
"windmill-parser-wasm": "file:../backend/parsers/windmill-parser-wasm/pkg-ts",
"windmill-parser-wasm-asset": "1.653.0",
"windmill-parser-wasm-asset": "1.673.0",
"windmill-parser-wasm-csharp": "1.510.1",
"windmill-parser-wasm-go": "1.510.1",
"windmill-parser-wasm-java": "1.510.1",
@@ -593,4 +592,4 @@
"@rollup/rollup-linux-x64-gnu": "^4.35.0",
"fsevents": "^2.3.3"
}
}
}
+5 -1
View File
@@ -1936,7 +1936,11 @@
// The worker will inject type parameters into the code that TypeScript analyzes
// Worker async function call freezes if we pass a Proxy, $state.snapshot() is very important here
updateSqlQueriesInWorker(uri, $state.snapshot(preparedAssetsSqlQueries))
// Filter out queries with raw interpolations — they can't be type-checked
let queriesToSend = $state
.snapshot(preparedAssetsSqlQueries)
.filter((q) => !q.has_raw_interpolation)
updateSqlQueriesInWorker(uri, queriesToSend)
}, 250)
watch([() => preparedAssetsSqlQueries, () => lang, () => isTsWorkerInitialized.current], () => {
+2
View File
@@ -31,6 +31,8 @@ export function usePreparedAssetSqlQueries(
queries = queries.filter(
([_, q]) => q.source_kind === 'datatable' || q.source_kind === 'ducklake'
)
// Skip queries with raw interpolations — they can't be prepared
queries = queries.filter(([_, q]) => !q.has_raw_interpolation)
// We only support preparing single-statement queries for now.
queries = queries.filter(([_, q]) => getQueryStmtCountHeuristic(q.query_string) === 1)
+1
View File
@@ -187,6 +187,7 @@ export type InferAssetsSqlQueryDetails = {
source_kind: 'datatable' | 'ducklake' // AssetKind equivalent
source_name: string // e.g., "main", "dt"
source_schema?: string // e.g., "public", optional
has_raw_interpolation?: boolean // true if any ${sql.raw(...)} was used
prepared?: PreparedAssetsSqlQuery
}
+1
View File
@@ -37,6 +37,7 @@ def main [
if ($do_all or $compile) {
print "Compiling Typescript..."
npx tsdown --format esm --format cjs --no-dts
tsc
}
+7
View File
@@ -71,8 +71,15 @@ export type SqlStatement<T> = {
): Promise<void>;
};
export declare class RawSql {
readonly __brand: "RawSql";
readonly value: string;
constructor(value: string);
}
export interface SqlTemplateFunction {
<T = any>(strings: TemplateStringsArray, ...values: any[]): SqlStatement<T>;
raw(value: string): RawSql;
}
export interface DatatableSqlTemplateFunction extends SqlTemplateFunction {
query<T = any>(sql: string, ...params: any[]): SqlStatement<T>;
+160 -87
View File
@@ -85,105 +85,137 @@ export type SqlStatement<T> = {
): Promise<void>;
};
/**
* Wrapper for raw SQL fragments that should be inlined without parameterization.
* Created via `sql.raw(value)`.
*/
export class RawSql {
readonly __brand = "RawSql" as const;
constructor(public readonly value: string) { }
}
/**
* Template tag function for creating SQL statements with parameterized values
*/
export interface SqlTemplateFunction {
<T = any>(strings: TemplateStringsArray, ...values: any[]): SqlStatement<T>;
/** Create a raw SQL fragment that will be inlined without parameterization */
raw(value: string): RawSql;
}
export interface DatatableSqlTemplateFunction extends SqlTemplateFunction {
query<T = any>(sql: string, ...params: any[]): SqlStatement<T>;
}
/**
* Create a SQL template function for PostgreSQL/datatable queries
* @param name - Database/datatable name (default: "main")
* @returns SQL template function for building parameterized queries
* @example
* let sql = wmill.datatable()
* let name = 'Robin'
* let age = 21
* await sql`
* SELECT * FROM friends
* WHERE name = ${name} AND age = ${age}::int
* `.fetch()
*/
export function datatable(name: string = "main"): DatatableSqlTemplateFunction {
return sqlProviderImpl(
"datatable",
parseName(name)
) as DatatableSqlTemplateFunction;
// ---------------------------------------------------------------------------
// Provider interface — captures what differs between datatable and ducklake
// ---------------------------------------------------------------------------
interface SqlProvider {
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;
}
/**
* Create a SQL template function for DuckDB/ducklake queries
* @param name - DuckDB database name (default: "main")
* @returns SQL template function for building parameterized queries
* @example
* let sql = wmill.ducklake()
* let name = 'Robin'
* let age = 21
* await sql`
* SELECT * FROM friends
* WHERE name = ${name} AND age = ${age}
* `.fetch()
*/
export function ducklake(name: string = "main"): SqlTemplateFunction {
return sqlProviderImpl("ducklake", { name });
function datatableProvider(name: string, schema?: string): SqlProvider {
return {
providerName: "datatable",
language: "postgresql",
extraArgs: { database: `datatable://${name}` },
formatArgDecl: (argNum) => `-- $${argNum} arg${argNum}`,
formatArgUsage: (argNum, explicitType, inferredType) =>
explicitType !== undefined
? `$${argNum}`
: `$${argNum}::${inferredType}`,
preamble: () => (schema ? `SET search_path TO "${schema}";\n` : ""),
};
}
function sqlProviderImpl(
provider: "datatable" | "ducklake",
{ name, schema }: { name: string; schema?: string }
): SqlTemplateFunction {
let sqlFn: SqlTemplateFunction = (
strings: TemplateStringsArray,
...values: any[]
) => {
let formatArgDecl = {
datatable: (i: number) => `-- $${i + 1} arg${i + 1}`,
ducklake: (i: number) => {
function ducklakeProvider(name: string): SqlProvider {
return {
providerName: "ducklake",
language: "duckdb",
extraArgs: {},
formatArgDecl: (argNum, argType) => `-- $arg${argNum} (${argType})`,
formatArgUsage: (argNum) => `$arg${argNum}`,
preamble: () => `ATTACH 'ducklake://${name}' AS dl;USE dl;\n`,
};
}
// ---------------------------------------------------------------------------
// Shared template function builder
// ---------------------------------------------------------------------------
function buildSqlTemplateFunction(provider: SqlProvider): SqlTemplateFunction {
let sqlFn = ((strings: TemplateStringsArray, ...values: any[]) => {
// Separate raw vs parameterized values, assigning arg indices only to params
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,
};
});
// Arg declarations (SQL comments consumed by the executor)
let argDecls = valueInfos
.filter((info) => !info.raw)
.map((info) => {
let argType =
parseTypeAnnotation(strings[i], strings[i + 1]) ||
inferSqlType(values[i]);
return `-- $arg${i + 1} (${argType})`;
},
}[provider];
parseTypeAnnotation(
strings[info.originalIndex],
strings[info.originalIndex + 1]
) || inferSqlType(info.value);
return provider.formatArgDecl(info.argNum, argType);
});
let formatArgUsage = {
datatable: (i: number) => {
const parsedType = parseTypeAnnotation(strings[i], strings[i + 1]);
if (parsedType !== undefined) return `$${i + 1}`;
let argType = inferSqlType(values[i]);
return `$${i + 1}::${argType}`;
},
ducklake: (i: number) => `$arg${i + 1}`,
}[provider];
let content = values.map((_, i) => formatArgDecl(i)).join("\n") + "\n";
if (provider === "ducklake")
content += `ATTACH 'ducklake://${name}' AS dl;USE dl;\n`;
if (schema && provider === "datatable") {
content += `SET search_path TO "${schema}";\n`;
}
let content = argDecls.length ? argDecls.join("\n") + "\n" : "";
content += provider.preamble();
// SQL body — inline raw values, reference params via provider syntax
let contentBody = "";
for (let i = 0; i < strings.length; i++) {
contentBody += strings[i];
if (i !== strings.length - 1) contentBody += formatArgUsage(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(values.map((v, i) => [`arg${i + 1}`, v])),
...(provider === "datatable" ? { database: `datatable://${name}` } : {}),
...Object.fromEntries(
valueInfos
.filter((info) => !info.raw)
.map((info) => [`arg${info.argNum}`, info.value])
),
...provider.extraArgs,
};
const language = {
datatable: "postgresql" as const,
ducklake: "duckdb" as const,
}[provider];
async function fetch<ResultCollectionT extends ResultCollection>({
resultCollection,
@@ -193,7 +225,7 @@ function sqlProviderImpl(
try {
let result = await JobService.runScriptPreviewInline({
workspace: getWorkspace(),
requestBody: { args, content, language },
requestBody: { args, content, language: provider.language },
});
return result as SqlResult<any, ResultCollectionT>;
} catch (e: any) {
@@ -207,7 +239,7 @@ function sqlProviderImpl(
if (body.startsWith("Internal:")) body = body.slice(9).trim();
if (body.startsWith("Error:")) body = body.slice(6).trim();
if (body.startsWith("datatable")) body = body.slice(9).trim();
err = Error(`${provider} ${body}`);
err = Error(`${provider.providerName} ${body}`);
err.query = contentBody;
err.request = e.request;
}
@@ -228,21 +260,62 @@ function sqlProviderImpl(
}),
execute: (params) => fetch(params),
} satisfies SqlStatement<any>;
};
if (provider === "datatable") {
(sqlFn as DatatableSqlTemplateFunction).query = (
sqlString: string,
...params: any[]
) => {
// This is less than ideal, did that quickly for a client need.
// TODO: break down the SqlTemplateFunction impl and reuse here properly.
let arr = Object.assign([sqlString], { raw: [sqlString] });
return sqlFn(arr, ...params);
};
}
}) as SqlTemplateFunction;
sqlFn.raw = (value: string) => new RawSql(value);
return sqlFn;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Create a SQL template function for PostgreSQL/datatable queries
* @param name - Database/datatable name (default: "main")
* @returns SQL template function for building parameterized queries
* @example
* let sql = wmill.datatable()
* let name = 'Robin'
* let age = 21
* await sql`
* SELECT * FROM friends
* WHERE name = ${name} AND age = ${age}::int
* `.fetch()
*/
export function datatable(name: string = "main"): DatatableSqlTemplateFunction {
let { name: n, schema } = parseName(name);
let sqlFn = buildSqlTemplateFunction(
datatableProvider(n, schema)
) as DatatableSqlTemplateFunction;
sqlFn.query = (sqlString: string, ...params: any[]) => {
let arr = Object.assign([sqlString], { raw: [sqlString] });
return sqlFn(arr, ...params);
};
return sqlFn;
}
/**
* Create a SQL template function for DuckDB/ducklake queries
* @param name - DuckDB database name (default: "main")
* @returns SQL template function for building parameterized queries
* @example
* let sql = wmill.ducklake()
* let name = 'Robin'
* let age = 21
* await sql`
* SELECT * FROM friends
* WHERE name = ${name} AND age = ${age}
* `.fetch()
*/
export function ducklake(name: string = "main"): SqlTemplateFunction {
return buildSqlTemplateFunction(ducklakeProvider(name));
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
// DuckDB executor requires explicit argument types at declaration
// And postgres at argument usage.
// These types exist in both DuckDB and Postgres