diff --git a/backend/parsers/windmill-parser-ts-asset/src/lib.rs b/backend/parsers/windmill-parser-ts-asset/src/lib.rs index aea77de814..bc31a16c99 100644 --- a/backend/parsers/windmill-parser-ts-asset/src/lib.rs +++ b/backend/parsers/windmill-parser-ts-asset/src/lib.rs @@ -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 = 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"); } } diff --git a/backend/parsers/windmill-parser/src/asset_parser.rs b/backend/parsers/windmill-parser/src/asset_parser.rs index 44388690e4..4239c1854c 100644 --- a/backend/parsers/windmill-parser/src/asset_parser.rs +++ b/backend/parsers/windmill-parser/src/asset_parser.rs @@ -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, // 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)] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 76a706bcf7..85644e2781 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index e43122784c..0b8950fb8f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } -} +} \ No newline at end of file diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index 3a59b0d80c..f6c40e4099 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -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], () => { diff --git a/frontend/src/lib/infer.svelte.ts b/frontend/src/lib/infer.svelte.ts index cbdfa95505..79d0826dc1 100644 --- a/frontend/src/lib/infer.svelte.ts +++ b/frontend/src/lib/infer.svelte.ts @@ -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) diff --git a/frontend/src/lib/infer.ts b/frontend/src/lib/infer.ts index 2ebe901f51..1b3e45ced0 100644 --- a/frontend/src/lib/infer.ts +++ b/frontend/src/lib/infer.ts @@ -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 } diff --git a/typescript-client/dev.nu b/typescript-client/dev.nu index 9bd61a7bd3..e1e4f1e2ca 100755 --- a/typescript-client/dev.nu +++ b/typescript-client/dev.nu @@ -37,6 +37,7 @@ def main [ if ($do_all or $compile) { print "Compiling Typescript..." + npx tsdown --format esm --format cjs --no-dts tsc } diff --git a/typescript-client/sqlUtils.d.ts b/typescript-client/sqlUtils.d.ts index fa73465e2b..7ff4041792 100644 --- a/typescript-client/sqlUtils.d.ts +++ b/typescript-client/sqlUtils.d.ts @@ -71,8 +71,15 @@ export type SqlStatement = { ): Promise; }; +export declare class RawSql { + readonly __brand: "RawSql"; + readonly value: string; + constructor(value: string); +} + export interface SqlTemplateFunction { (strings: TemplateStringsArray, ...values: any[]): SqlStatement; + raw(value: string): RawSql; } export interface DatatableSqlTemplateFunction extends SqlTemplateFunction { query(sql: string, ...params: any[]): SqlStatement; diff --git a/typescript-client/sqlUtils.ts b/typescript-client/sqlUtils.ts index cf00c60117..696e2975b9 100644 --- a/typescript-client/sqlUtils.ts +++ b/typescript-client/sqlUtils.ts @@ -85,105 +85,137 @@ export type SqlStatement = { ): Promise; }; +/** + * 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 { (strings: TemplateStringsArray, ...values: any[]): SqlStatement; + /** Create a raw SQL fragment that will be inlined without parameterization */ + raw(value: string): RawSql; } export interface DatatableSqlTemplateFunction extends SqlTemplateFunction { query(sql: string, ...params: any[]): SqlStatement; } -/** - * 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; + 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({ 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; } 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; - }; - 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