@sqg/sqg 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -117,12 +117,15 @@ console.log(user?.name);
117
117
  | Annotation | Description |
118
118
  |------------|-------------|
119
119
  | `-- MIGRATE name` | Schema migration (CREATE TABLE, etc.) |
120
+ | `-- BASELINE name` | Schema created outside SQG (ETL, sibling service). Runs before migrations for type-checking; **not** emitted in `getMigrations()` |
121
+ | `-- BASELINE name :source=pg` | Schema of a `type: postgres` source. Applied natively to a managed testcontainer and attached into DuckDB for introspection (DuckDB generators; needs Docker) |
120
122
  | `-- QUERY name` | SELECT query returning rows |
121
123
  | `-- QUERY name :one` | Query returning single row or undefined |
122
124
  | `-- QUERY name :pluck` | Return single (first) column value |
123
125
  | `-- QUERY name :result=Foo` | Name the row type (Java). Add it to ONE query and every same-shape query shares it. Full-table `SELECT *` reuses the `TABLE` row type without annotation. |
124
126
  | `-- EXEC name` | INSERT/UPDATE/DELETE (no result rows) |
125
127
  | `-- TESTDATA name` | Test data, runs after migrations |
128
+ | `-- TABLE name :appender` | Generate a type-safe bulk insert appender (DuckDB, PostgreSQL) |
126
129
  | `@set var = value` | Define parameter with sample value |
127
130
  | `${var}` | Reference parameter in query |
128
131
 
package/dist/index.mjs CHANGED
@@ -229,6 +229,7 @@ SQL Annotation Syntax:
229
229
  -- QUERY <name> [:one] [:pluck] Select query (returns rows)
230
230
  -- EXEC <name> [:batch] Execute statement (INSERT/UPDATE/DELETE)
231
231
  -- BASELINE <name> Schema created outside SQG (runs before migrations, not tracked)
232
+ -- BASELINE <name> :source=<src> Schema of a 'type: postgres' source (applied to a managed testcontainer, attached into DuckDB)
232
233
  -- MIGRATE <name> Schema migration (runs in source order; name is any identifier, e.g. "1" or "add_email")
233
234
  -- TESTDATA <name> Test data setup (not generated)
234
235
  -- TABLE <name> :appender Table for bulk insert appender (DuckDB, PostgreSQL)
@@ -242,6 +243,7 @@ Modifiers:
242
243
  :all Return all rows (default)
243
244
  :batch Generate a JDBC batch method for an EXEC (Java only)
244
245
  :appender Generate bulk insert appender for TABLE annotation
246
+ :source=Name Mark a BASELINE block as the schema of a postgres source (Name)
245
247
  :result=Name Name (and share) the row type (Java only). Annotate ONE query
246
248
  with :result=Name — every other query in the same file with the
247
249
  same column shape automatically gets the same name; you do NOT
@@ -1496,6 +1498,19 @@ var PythonGenerator = class extends BaseGenerator {
1496
1498
  if (value.includes("\n") || value.includes("'") || value.includes("\"")) return `"""\\\n${value}"""`;
1497
1499
  return `"${value}"`;
1498
1500
  });
1501
+ Handlebars.registerHelper("sqlExpr", (queryHelper) => {
1502
+ const parts = queryHelper.sqlQueryParts;
1503
+ if (!parts.some((p) => typeof p !== "string" && p.name.startsWith("sources_"))) {
1504
+ const value = queryHelper.sqlQuery;
1505
+ if (value.includes("\n") || value.includes("'") || value.includes("\"")) return `"""\\\n${value}"""`;
1506
+ return `"${value}"`;
1507
+ }
1508
+ let out = "f\"\"\"";
1509
+ for (const part of parts) if (typeof part === "string") out += part.replace(/\\/g, "\\\\").replace(/\{/g, "{{").replace(/\}/g, "}}");
1510
+ else if (part.name.startsWith("sources_")) out += ` '{${part.name}}'`;
1511
+ else out += "?";
1512
+ return `${out}"""`;
1513
+ });
1499
1514
  Handlebars.registerHelper("pyType", (column) => {
1500
1515
  return this.getPyType(column);
1501
1516
  });
@@ -1620,6 +1635,7 @@ var TsGenerator = class extends BaseGenerator {
1620
1635
  async beforeGenerate(_projectDir, gen, _queries, _tables) {
1621
1636
  if (this.typeMapper instanceof TypeScriptTypeMapper) this.typeMapper.safeIntegers = !!gen.config?.safeIntegers;
1622
1637
  Handlebars.registerHelper("quote", (value) => this.quote(value));
1638
+ Handlebars.registerHelper("sqlExpr", (queryHelper) => this.sqlExpr(queryHelper));
1623
1639
  Handlebars.registerHelper("appendMethod", (column) => {
1624
1640
  if (column.type instanceof ListType) return "List";
1625
1641
  const typeStr = column.type?.toString().toUpperCase() || "";
@@ -1772,6 +1788,22 @@ var TsGenerator = class extends BaseGenerator {
1772
1788
  quote(value) {
1773
1789
  return value.includes("\n") || value.includes("'") ? `\`${value}\`` : `'${value}'`;
1774
1790
  }
1791
+ /**
1792
+ * Build the SQL expression for a query. Without file `sources`, this is the
1793
+ * plain quoted SQL string (unchanged). When a `${sources_x}` reference is
1794
+ * present it is interpolated into a template literal as a runtime argument
1795
+ * (the value is supplied by the caller, not bound), mirroring the Java
1796
+ * generator — `ATTACH '` + arg + `'` style.
1797
+ */
1798
+ sqlExpr(queryHelper) {
1799
+ const parts = queryHelper.sqlQueryParts;
1800
+ if (!parts.some((p) => typeof p !== "string" && p.name.startsWith("sources_"))) return this.quote(queryHelper.sqlQuery);
1801
+ let out = "`";
1802
+ for (const part of parts) if (typeof part === "string") out += part.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
1803
+ else if (part.name.startsWith("sources_")) out += ` '\${${part.name}}'`;
1804
+ else out += "?";
1805
+ return `${out}\``;
1806
+ }
1775
1807
  };
1776
1808
  //#endregion
1777
1809
  //#region src/generators/typescript-duckdb-generator.ts
@@ -1878,6 +1910,57 @@ function getGenerator(generator) {
1878
1910
  }
1879
1911
  }
1880
1912
  //#endregion
1913
+ //#region src/sources.ts
1914
+ /**
1915
+ * For each `type: postgres` source: start a throwaway Postgres testcontainer,
1916
+ * run the source's schema (the BASELINE blocks tagged `:source=<name>`) natively
1917
+ * against it so introspection sees true Postgres types, and return the connection
1918
+ * info to ATTACH into DuckDB.
1919
+ *
1920
+ * The synthesized ATTACH (done by the DuckDB adapter) and these schema blocks are
1921
+ * not emitted into generated code — at runtime the application attaches the real
1922
+ * production database under the same alias.
1923
+ */
1924
+ async function preparePostgresSources(sources, queries, reporter) {
1925
+ const containers = [];
1926
+ const attachments = [];
1927
+ const teardown = async () => {
1928
+ for (const container of containers) try {
1929
+ await container.stop();
1930
+ } catch {}
1931
+ };
1932
+ try {
1933
+ for (const source of sources) {
1934
+ reporter?.onContainerStarting?.();
1935
+ const container = await new PostgreSqlContainer(source.image).withDatabase("sqg-db").withUsername("sqg").withPassword("secret").start();
1936
+ containers.push(container);
1937
+ const connectionUri = container.getConnectionUri();
1938
+ reporter?.onContainerStarted?.(connectionUri);
1939
+ const schemaBlocks = queries.filter((q) => q.isBaseline && q.sourceTarget === source.name);
1940
+ const client = new Client({ connectionString: connectionUri });
1941
+ await client.connect();
1942
+ try {
1943
+ for (const block of schemaBlocks) await client.query(block.rawQuery);
1944
+ } catch (e) {
1945
+ throw new DatabaseError(`Failed to apply schema for postgres source '${source.name}': ${e.message}`, "postgres", `Check that the BASELINE blocks tagged ':source=${source.name}' are valid PostgreSQL.`);
1946
+ } finally {
1947
+ await client.end();
1948
+ }
1949
+ attachments.push({
1950
+ alias: source.name,
1951
+ connectionUri
1952
+ });
1953
+ }
1954
+ } catch (e) {
1955
+ await teardown();
1956
+ throw e;
1957
+ }
1958
+ return {
1959
+ attachments,
1960
+ teardown
1961
+ };
1962
+ }
1963
+ //#endregion
1881
1964
  //#region src/sqltool.ts
1882
1965
  const GENERATED_FILE_COMMENT = "This file is generated by SQG (https://sqg.dev). Do not edit manually.";
1883
1966
  const configSchema = z.object({ result: z.record(z.string(), z.string()).optional() });
@@ -2054,18 +2137,23 @@ var TableHelper = class {
2054
2137
  return this.generator.typeMapper;
2055
2138
  }
2056
2139
  };
2057
- function generateSourceFile(name, queries, tables, templatePath, generator, engine, projectName, config) {
2140
+ function generateSourceFile(name, queries, tables, templatePath, generator, engine, projectName, config, postgresSourceNames = []) {
2058
2141
  const templateSrc = readFileSync(templatePath, "utf-8");
2059
2142
  const template = Handlebars.compile(templateSrc);
2060
2143
  Handlebars.registerHelper("mapType", (column) => generator.mapType(column));
2061
2144
  Handlebars.registerHelper("plusOne", (value) => value + 1);
2062
2145
  const migrations = queries.filter((q) => q.isMigrate).map((q) => new SqlQueryHelper(q, generator, generator.getStatement(q)));
2063
2146
  const tableHelpers = generator.supportsAppenders(engine) ? tables.filter((t) => !t.skipGenerateFunction).map((t) => new TableHelper(t, generator)) : [];
2147
+ const attachers = engine === "duckdb" ? postgresSourceNames.map((sourceName) => ({
2148
+ alias: sourceName,
2149
+ functionName: generator.getFunctionName(`attach_${sourceName}`)
2150
+ })) : [];
2064
2151
  return template({
2065
2152
  generatedComment: GENERATED_FILE_COMMENT,
2066
2153
  migrations,
2067
2154
  queries: queries.map((q) => new SqlQueryHelper(q, generator, generator.getStatement(q))),
2068
2155
  tables: tableHelpers,
2156
+ attachers,
2069
2157
  className: generator.getClassName(name),
2070
2158
  projectName,
2071
2159
  config
@@ -2093,9 +2181,11 @@ const ProjectSchema = z.object({
2093
2181
  })).min(1, "At least one generator is required").describe("Code generators to run")
2094
2182
  })).min(1, "At least one SQL configuration is required").describe("SQL file configurations"),
2095
2183
  sources: z.array(z.object({
2096
- path: z.string().describe("Path to source file (supports $HOME)"),
2097
- name: z.string().optional().describe("Variable name override")
2098
- })).optional().describe("External source files to include as variables")
2184
+ type: z.enum(["file", "postgres"]).optional().describe("Source type: 'file' (default) or 'postgres'"),
2185
+ path: z.string().optional().describe("Path to source file for file sources (supports $HOME)"),
2186
+ name: z.string().optional().describe("Variable name (file sources) or DuckDB attach alias (postgres sources)"),
2187
+ image: z.string().optional().describe("Docker image for postgres sources (default: postgres:16-alpine)")
2188
+ }).refine((s) => s.type === "postgres" ? !!s.name : !!s.path, { message: "file sources require 'path'; postgres sources require 'name'" })).optional().describe("External sources: files inlined as variables, or postgres databases attached for introspection")
2099
2189
  });
2100
2190
  var ExtraVariable = class {
2101
2191
  constructor(name, value) {
@@ -2104,7 +2194,7 @@ var ExtraVariable = class {
2104
2194
  }
2105
2195
  };
2106
2196
  function createExtraVariables(sources, suppressLogging = false) {
2107
- return sources.map((source) => {
2197
+ return sources.filter((source) => source.type !== "postgres").map((source) => {
2108
2198
  const path = source.path;
2109
2199
  const resolvedPath = path.replace("$HOME", homedir());
2110
2200
  const varName = `sources_${(source.name ?? basename(path, extname(resolvedPath))).replace(/\s+/g, "_")}`;
@@ -2112,6 +2202,13 @@ function createExtraVariables(sources, suppressLogging = false) {
2112
2202
  return new ExtraVariable(varName, `'${resolvedPath}'`);
2113
2203
  });
2114
2204
  }
2205
+ /** Postgres sources resolved from the project config (with image defaulted). */
2206
+ function getPostgresSources(sources) {
2207
+ return sources.filter((source) => source.type === "postgres").map((source) => ({
2208
+ name: source.name,
2209
+ image: source.image ?? "postgres:16-alpine"
2210
+ }));
2211
+ }
2115
2212
  function buildProjectFromCliOptions(options) {
2116
2213
  if (!isValidGenerator(options.generator)) {
2117
2214
  const similar = findSimilarGenerators(options.generator);
@@ -2250,7 +2347,7 @@ function assignResultTypeNames(queries, tables, projectDir = process.cwd()) {
2250
2347
  }
2251
2348
  const fullMatch = tryFullTableMatch(group.queries[0], tablesByName);
2252
2349
  if (fullMatch) {
2253
- claim(fullMatch, shape, group.queries[0], `full-table match`);
2350
+ claim(fullMatch, shape, group.queries[0], "full-table match");
2254
2351
  for (const q of group.queries) q.resultTypeName = fullMatch;
2255
2352
  continue;
2256
2353
  }
@@ -2303,14 +2400,14 @@ function validateQueries(queries) {
2303
2400
  };
2304
2401
  }
2305
2402
  }
2306
- async function writeGeneratedFile(projectDir, gen, generator, file, queries, tables, engine, writeToStdout = false) {
2403
+ async function writeGeneratedFile(projectDir, gen, generator, file, queries, tables, engine, writeToStdout = false, postgresSourceNames = []) {
2307
2404
  await generator.beforeGenerate(projectDir, gen, queries, tables);
2308
2405
  const templatePath = join(dirname(new URL(import.meta.url).pathname), gen.template ?? generator.template);
2309
2406
  const name = gen.name ?? basename(file, extname(file));
2310
2407
  const sourceFile = generateSourceFile(name, queries, tables, templatePath, generator, engine, gen.projectName ?? name, {
2311
2408
  migrations: true,
2312
2409
  ...gen.config
2313
- });
2410
+ }, postgresSourceNames);
2314
2411
  if (writeToStdout) {
2315
2412
  process.stdout.write(sourceFile);
2316
2413
  if (!sourceFile.endsWith("\n")) process.stdout.write("\n");
@@ -2411,6 +2508,11 @@ async function processProjectFromConfig(project, projectDir, writeToStdout = fal
2411
2508
  const results = [];
2412
2509
  try {
2413
2510
  const extraVariables = createExtraVariables(project.sources ?? [], writeToStdout);
2511
+ const pgSources = getPostgresSources(project.sources ?? []);
2512
+ const pgSourceNames = new Set(pgSources.map((s) => s.name));
2513
+ if (pgSources.length > 0) {
2514
+ if (!project.sql.some((sql) => sql.gen.some((gen) => getGeneratorEngine(gen.generator) === "duckdb"))) throw new SqgError("Postgres sources require a DuckDB generator (they are attached into DuckDB for introspection)", "CONFIG_VALIDATION_ERROR", "Add a 'typescript/duckdb' or 'java/duckdb' generator, or remove the postgres sources");
2515
+ }
2414
2516
  const files = [];
2415
2517
  for (const sql of project.sql) {
2416
2518
  const gensByEngine = /* @__PURE__ */ new Map();
@@ -2428,6 +2530,7 @@ async function processProjectFromConfig(project, projectDir, writeToStdout = fal
2428
2530
  const parseResult = parseSQLQueries(fullPath, extraVariables);
2429
2531
  queries = parseResult.queries;
2430
2532
  tables = parseResult.tables;
2533
+ for (const q of queries) if (q.sourceTarget && !pgSourceNames.has(q.sourceTarget)) throw SqgError.inQuery(`BASELINE block targets unknown postgres source '${q.sourceTarget}'`, "CONFIG_VALIDATION_ERROR", q.id, sqlFile, { suggestion: `Declare a source with 'type: postgres, name: ${q.sourceTarget}' in sqg.yaml` });
2431
2534
  } catch (e) {
2432
2535
  if (e instanceof SqgError) throw e;
2433
2536
  throw SqgError.inFile(`Failed to parse SQL file: ${e.message}`, "SQL_PARSE_ERROR", sqlFile, { suggestion: "Check SQL syntax and annotation format" });
@@ -2436,10 +2539,17 @@ async function processProjectFromConfig(project, projectDir, writeToStdout = fal
2436
2539
  const executableQueries = queries.filter((q) => !q.skipGenerateFunction);
2437
2540
  const reporterAny = reporter;
2438
2541
  if (reporterAny?.setQueryTotal) reporterAny.setQueryTotal(executableQueries.length);
2542
+ let preparedSources;
2439
2543
  try {
2440
2544
  const dbEngine = getDatabaseEngine(engine);
2545
+ let attachments;
2546
+ if (engine === "duckdb" && pgSources.length > 0) {
2547
+ ui?.startPhase("Starting postgres source containers...");
2548
+ preparedSources = await preparePostgresSources(pgSources, queries, reporter);
2549
+ attachments = preparedSources.attachments;
2550
+ }
2441
2551
  ui?.startPhase(`Initializing ${engine} database...`);
2442
- await dbEngine.initializeDatabase(queries, reporter);
2552
+ await dbEngine.initializeDatabase(queries, reporter, { attachments });
2443
2553
  ui?.startPhase(`Introspecting ${executableQueries.length} queries...`);
2444
2554
  await dbEngine.executeQueries(queries, reporter);
2445
2555
  if (tables.length > 0) await dbEngine.introspectTables(tables, reporter);
@@ -2456,6 +2566,8 @@ async function processProjectFromConfig(project, projectDir, writeToStdout = fal
2456
2566
  file: sqlFile,
2457
2567
  engine
2458
2568
  });
2569
+ } finally {
2570
+ if (preparedSources) await preparedSources.teardown();
2459
2571
  }
2460
2572
  ui?.startPhase("Generating code...");
2461
2573
  const genStart = performance.now();
@@ -2464,7 +2576,7 @@ async function processProjectFromConfig(project, projectDir, writeToStdout = fal
2464
2576
  const outputPath = await writeGeneratedFile(projectDir, {
2465
2577
  ...gen,
2466
2578
  projectName: project.name
2467
- }, generator, sqlFile, queries, tables, engine, writeToStdout);
2579
+ }, generator, sqlFile, queries, tables, engine, writeToStdout, pgSources.map((s) => s.name));
2468
2580
  if (outputPath !== null) {
2469
2581
  files.push(outputPath);
2470
2582
  results.push({
@@ -2537,6 +2649,13 @@ var SQLQuery = class {
2537
2649
  parameterTypes;
2538
2650
  /** Explicit row-type name from `:result=Name` modifier, when present. */
2539
2651
  resultTypeOverride;
2652
+ /**
2653
+ * Name of the postgres `source` this block targets, from a `:source=name`
2654
+ * modifier on a BASELINE block. Such blocks define the schema of an external
2655
+ * postgres source and run natively against that source's container during
2656
+ * generation (not against the DuckDB introspection connection).
2657
+ */
2658
+ sourceTarget;
2540
2659
  /** Final row-type identifier assigned by assignResultTypeNames (after introspection). */
2541
2660
  resultTypeName;
2542
2661
  constructor(filename, id, rawQuery, queryAnonymous, queryNamed, queryPositional, type, isOne, isPluck, isBatch, variables, config, line) {
@@ -2653,6 +2772,8 @@ function parseSQLQueries(filePath, extraVariables) {
2653
2772
  const isPluck = modifiers.includes(":pluck");
2654
2773
  const isBatch = modifiers.includes(":batch");
2655
2774
  const resultTypeOverride = modifiers.find((m) => m.startsWith(":result="))?.slice(8);
2775
+ const sourceTarget = modifiers.find((m) => m.startsWith(":source="))?.slice(8);
2776
+ if (sourceTarget && queryType !== "BASELINE") throw SqgError.inQuery(`The ':source=' modifier is only valid on BASELINE blocks, not ${queryType}`, "SQL_PARSE_ERROR", name, filePath, { suggestion: "Move the schema for a postgres source into a '-- BASELINE <name> :source=<source>' block" });
2656
2777
  let configStr = getStr("Config", true);
2657
2778
  if (configStr?.endsWith("*/")) configStr = configStr.slice(0, -2);
2658
2779
  let config = null;
@@ -2808,6 +2929,7 @@ function parseSQLQueries(filePath, extraVariables) {
2808
2929
  });
2809
2930
  const query = new SQLQuery(filePath, name, sqlContentStr, sql.toSqlWithAnonymousPlaceholders(), sql.toSqlWithNamedPlaceholders(), sql.toSqlWithPositionalPlaceholders(), queryType, isOne, isPluck, isBatch, variables, config, annotationLine);
2810
2931
  query.resultTypeOverride = resultTypeOverride;
2932
+ query.sourceTarget = sourceTarget;
2811
2933
  checkDuplicate(queryType, name);
2812
2934
  queries.push(query);
2813
2935
  consola.debug(`Added query: ${name} (${queryType})`);
@@ -2824,7 +2946,7 @@ function parseSQLQueries(filePath, extraVariables) {
2824
2946
  //#endregion
2825
2947
  //#region src/db/types.ts
2826
2948
  async function initializeDatabase(queries, execQueries, reporter) {
2827
- const baselineQueries = queries.filter((q) => q.isBaseline);
2949
+ const baselineQueries = queries.filter((q) => q.isBaseline && !q.sourceTarget);
2828
2950
  for (const query of baselineQueries) try {
2829
2951
  await execQueries(query);
2830
2952
  } catch (error) {
@@ -2880,9 +3002,17 @@ function convertType(type) {
2880
3002
  const duckdb = new class {
2881
3003
  db;
2882
3004
  connection;
2883
- async initializeDatabase(queries, reporter) {
3005
+ async initializeDatabase(queries, reporter, options) {
2884
3006
  this.db = await DuckDBInstance.create(":memory:");
2885
3007
  this.connection = await this.db.connect();
3008
+ for (const attachment of options?.attachments ?? []) {
3009
+ const sql = `ATTACH '${attachment.connectionUri}' AS ${attachment.alias} (TYPE postgres)`;
3010
+ try {
3011
+ await this.connection.run(sql);
3012
+ } catch (e) {
3013
+ throw new DatabaseError(`Failed to attach source '${attachment.alias}': ${e.message}`, "duckdb", "Check that the postgres source container started and is reachable.");
3014
+ }
3015
+ }
2886
3016
  await initializeDatabase(queries, async (query) => {
2887
3017
  try {
2888
3018
  await this.connection.run(query.rawQuery);
package/dist/sqg.mjs CHANGED
@@ -221,6 +221,7 @@ SQL Annotation Syntax:
221
221
  -- QUERY <name> [:one] [:pluck] Select query (returns rows)
222
222
  -- EXEC <name> [:batch] Execute statement (INSERT/UPDATE/DELETE)
223
223
  -- BASELINE <name> Schema created outside SQG (runs before migrations, not tracked)
224
+ -- BASELINE <name> :source=<src> Schema of a 'type: postgres' source (applied to a managed testcontainer, attached into DuckDB)
224
225
  -- MIGRATE <name> Schema migration (runs in source order; name is any identifier, e.g. "1" or "add_email")
225
226
  -- TESTDATA <name> Test data setup (not generated)
226
227
  -- TABLE <name> :appender Table for bulk insert appender (DuckDB, PostgreSQL)
@@ -234,6 +235,7 @@ Modifiers:
234
235
  :all Return all rows (default)
235
236
  :batch Generate a JDBC batch method for an EXEC (Java only)
236
237
  :appender Generate bulk insert appender for TABLE annotation
238
+ :source=Name Mark a BASELINE block as the schema of a postgres source (Name)
237
239
  :result=Name Name (and share) the row type (Java only). Annotate ONE query
238
240
  with :result=Name — every other query in the same file with the
239
241
  same column shape automatically gets the same name; you do NOT
@@ -783,6 +785,13 @@ var SQLQuery = class {
783
785
  parameterTypes;
784
786
  /** Explicit row-type name from `:result=Name` modifier, when present. */
785
787
  resultTypeOverride;
788
+ /**
789
+ * Name of the postgres `source` this block targets, from a `:source=name`
790
+ * modifier on a BASELINE block. Such blocks define the schema of an external
791
+ * postgres source and run natively against that source's container during
792
+ * generation (not against the DuckDB introspection connection).
793
+ */
794
+ sourceTarget;
786
795
  /** Final row-type identifier assigned by assignResultTypeNames (after introspection). */
787
796
  resultTypeName;
788
797
  constructor(filename, id, rawQuery, queryAnonymous, queryNamed, queryPositional, type, isOne, isPluck, isBatch, variables, config, line) {
@@ -899,6 +908,8 @@ function parseSQLQueries(filePath, extraVariables) {
899
908
  const isPluck = modifiers.includes(":pluck");
900
909
  const isBatch = modifiers.includes(":batch");
901
910
  const resultTypeOverride = modifiers.find((m) => m.startsWith(":result="))?.slice(8);
911
+ const sourceTarget = modifiers.find((m) => m.startsWith(":source="))?.slice(8);
912
+ if (sourceTarget && queryType !== "BASELINE") throw SqgError.inQuery(`The ':source=' modifier is only valid on BASELINE blocks, not ${queryType}`, "SQL_PARSE_ERROR", name, filePath, { suggestion: "Move the schema for a postgres source into a '-- BASELINE <name> :source=<source>' block" });
902
913
  let configStr = getStr("Config", true);
903
914
  if (configStr?.endsWith("*/")) configStr = configStr.slice(0, -2);
904
915
  let config = null;
@@ -1054,6 +1065,7 @@ function parseSQLQueries(filePath, extraVariables) {
1054
1065
  });
1055
1066
  const query = new SQLQuery(filePath, name, sqlContentStr, sql.toSqlWithAnonymousPlaceholders(), sql.toSqlWithNamedPlaceholders(), sql.toSqlWithPositionalPlaceholders(), queryType, isOne, isPluck, isBatch, variables, config, annotationLine);
1056
1067
  query.resultTypeOverride = resultTypeOverride;
1068
+ query.sourceTarget = sourceTarget;
1057
1069
  checkDuplicate(queryType, name);
1058
1070
  queries.push(query);
1059
1071
  consola.debug(`Added query: ${name} (${queryType})`);
@@ -1070,7 +1082,7 @@ function parseSQLQueries(filePath, extraVariables) {
1070
1082
  //#endregion
1071
1083
  //#region src/db/types.ts
1072
1084
  async function initializeDatabase(queries, execQueries, reporter) {
1073
- const baselineQueries = queries.filter((q) => q.isBaseline);
1085
+ const baselineQueries = queries.filter((q) => q.isBaseline && !q.sourceTarget);
1074
1086
  for (const query of baselineQueries) try {
1075
1087
  await execQueries(query);
1076
1088
  } catch (error) {
@@ -1126,9 +1138,17 @@ function convertType(type) {
1126
1138
  const duckdb = new class {
1127
1139
  db;
1128
1140
  connection;
1129
- async initializeDatabase(queries, reporter) {
1141
+ async initializeDatabase(queries, reporter, options) {
1130
1142
  this.db = await DuckDBInstance.create(":memory:");
1131
1143
  this.connection = await this.db.connect();
1144
+ for (const attachment of options?.attachments ?? []) {
1145
+ const sql = `ATTACH '${attachment.connectionUri}' AS ${attachment.alias} (TYPE postgres)`;
1146
+ try {
1147
+ await this.connection.run(sql);
1148
+ } catch (e) {
1149
+ throw new DatabaseError(`Failed to attach source '${attachment.alias}': ${e.message}`, "duckdb", "Check that the postgres source container started and is reachable.");
1150
+ }
1151
+ }
1132
1152
  await initializeDatabase(queries, async (query) => {
1133
1153
  try {
1134
1154
  await this.connection.run(query.rawQuery);
@@ -2726,6 +2746,19 @@ var PythonGenerator = class extends BaseGenerator {
2726
2746
  if (value.includes("\n") || value.includes("'") || value.includes("\"")) return `"""\\\n${value}"""`;
2727
2747
  return `"${value}"`;
2728
2748
  });
2749
+ Handlebars.registerHelper("sqlExpr", (queryHelper) => {
2750
+ const parts = queryHelper.sqlQueryParts;
2751
+ if (!parts.some((p) => typeof p !== "string" && p.name.startsWith("sources_"))) {
2752
+ const value = queryHelper.sqlQuery;
2753
+ if (value.includes("\n") || value.includes("'") || value.includes("\"")) return `"""\\\n${value}"""`;
2754
+ return `"${value}"`;
2755
+ }
2756
+ let out = "f\"\"\"";
2757
+ for (const part of parts) if (typeof part === "string") out += part.replace(/\\/g, "\\\\").replace(/\{/g, "{{").replace(/\}/g, "}}");
2758
+ else if (part.name.startsWith("sources_")) out += ` '{${part.name}}'`;
2759
+ else out += "?";
2760
+ return `${out}"""`;
2761
+ });
2729
2762
  Handlebars.registerHelper("pyType", (column) => {
2730
2763
  return this.getPyType(column);
2731
2764
  });
@@ -2850,6 +2883,7 @@ var TsGenerator = class extends BaseGenerator {
2850
2883
  async beforeGenerate(_projectDir, gen, _queries, _tables) {
2851
2884
  if (this.typeMapper instanceof TypeScriptTypeMapper) this.typeMapper.safeIntegers = !!gen.config?.safeIntegers;
2852
2885
  Handlebars.registerHelper("quote", (value) => this.quote(value));
2886
+ Handlebars.registerHelper("sqlExpr", (queryHelper) => this.sqlExpr(queryHelper));
2853
2887
  Handlebars.registerHelper("appendMethod", (column) => {
2854
2888
  if (column.type instanceof ListType) return "List";
2855
2889
  const typeStr = column.type?.toString().toUpperCase() || "";
@@ -3002,6 +3036,22 @@ var TsGenerator = class extends BaseGenerator {
3002
3036
  quote(value) {
3003
3037
  return value.includes("\n") || value.includes("'") ? `\`${value}\`` : `'${value}'`;
3004
3038
  }
3039
+ /**
3040
+ * Build the SQL expression for a query. Without file `sources`, this is the
3041
+ * plain quoted SQL string (unchanged). When a `${sources_x}` reference is
3042
+ * present it is interpolated into a template literal as a runtime argument
3043
+ * (the value is supplied by the caller, not bound), mirroring the Java
3044
+ * generator — `ATTACH '` + arg + `'` style.
3045
+ */
3046
+ sqlExpr(queryHelper) {
3047
+ const parts = queryHelper.sqlQueryParts;
3048
+ if (!parts.some((p) => typeof p !== "string" && p.name.startsWith("sources_"))) return this.quote(queryHelper.sqlQuery);
3049
+ let out = "`";
3050
+ for (const part of parts) if (typeof part === "string") out += part.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
3051
+ else if (part.name.startsWith("sources_")) out += ` '\${${part.name}}'`;
3052
+ else out += "?";
3053
+ return `${out}\``;
3054
+ }
3005
3055
  };
3006
3056
  //#endregion
3007
3057
  //#region src/generators/typescript-duckdb-generator.ts
@@ -3108,6 +3158,57 @@ function getGenerator(generator) {
3108
3158
  }
3109
3159
  }
3110
3160
  //#endregion
3161
+ //#region src/sources.ts
3162
+ /**
3163
+ * For each `type: postgres` source: start a throwaway Postgres testcontainer,
3164
+ * run the source's schema (the BASELINE blocks tagged `:source=<name>`) natively
3165
+ * against it so introspection sees true Postgres types, and return the connection
3166
+ * info to ATTACH into DuckDB.
3167
+ *
3168
+ * The synthesized ATTACH (done by the DuckDB adapter) and these schema blocks are
3169
+ * not emitted into generated code — at runtime the application attaches the real
3170
+ * production database under the same alias.
3171
+ */
3172
+ async function preparePostgresSources(sources, queries, reporter) {
3173
+ const containers = [];
3174
+ const attachments = [];
3175
+ const teardown = async () => {
3176
+ for (const container of containers) try {
3177
+ await container.stop();
3178
+ } catch {}
3179
+ };
3180
+ try {
3181
+ for (const source of sources) {
3182
+ reporter?.onContainerStarting?.();
3183
+ const container = await new PostgreSqlContainer(source.image).withDatabase("sqg-db").withUsername("sqg").withPassword("secret").start();
3184
+ containers.push(container);
3185
+ const connectionUri = container.getConnectionUri();
3186
+ reporter?.onContainerStarted?.(connectionUri);
3187
+ const schemaBlocks = queries.filter((q) => q.isBaseline && q.sourceTarget === source.name);
3188
+ const client = new Client({ connectionString: connectionUri });
3189
+ await client.connect();
3190
+ try {
3191
+ for (const block of schemaBlocks) await client.query(block.rawQuery);
3192
+ } catch (e) {
3193
+ throw new DatabaseError(`Failed to apply schema for postgres source '${source.name}': ${e.message}`, "postgres", `Check that the BASELINE blocks tagged ':source=${source.name}' are valid PostgreSQL.`);
3194
+ } finally {
3195
+ await client.end();
3196
+ }
3197
+ attachments.push({
3198
+ alias: source.name,
3199
+ connectionUri
3200
+ });
3201
+ }
3202
+ } catch (e) {
3203
+ await teardown();
3204
+ throw e;
3205
+ }
3206
+ return {
3207
+ attachments,
3208
+ teardown
3209
+ };
3210
+ }
3211
+ //#endregion
3111
3212
  //#region src/sqltool.ts
3112
3213
  const GENERATED_FILE_COMMENT = "This file is generated by SQG (https://sqg.dev). Do not edit manually.";
3113
3214
  const configSchema = z.object({ result: z.record(z.string(), z.string()).optional() });
@@ -3284,18 +3385,23 @@ var TableHelper = class {
3284
3385
  return this.generator.typeMapper;
3285
3386
  }
3286
3387
  };
3287
- function generateSourceFile(name, queries, tables, templatePath, generator, engine, projectName, config) {
3388
+ function generateSourceFile(name, queries, tables, templatePath, generator, engine, projectName, config, postgresSourceNames = []) {
3288
3389
  const templateSrc = readFileSync(templatePath, "utf-8");
3289
3390
  const template = Handlebars.compile(templateSrc);
3290
3391
  Handlebars.registerHelper("mapType", (column) => generator.mapType(column));
3291
3392
  Handlebars.registerHelper("plusOne", (value) => value + 1);
3292
3393
  const migrations = queries.filter((q) => q.isMigrate).map((q) => new SqlQueryHelper(q, generator, generator.getStatement(q)));
3293
3394
  const tableHelpers = generator.supportsAppenders(engine) ? tables.filter((t) => !t.skipGenerateFunction).map((t) => new TableHelper(t, generator)) : [];
3395
+ const attachers = engine === "duckdb" ? postgresSourceNames.map((sourceName) => ({
3396
+ alias: sourceName,
3397
+ functionName: generator.getFunctionName(`attach_${sourceName}`)
3398
+ })) : [];
3294
3399
  return template({
3295
3400
  generatedComment: GENERATED_FILE_COMMENT,
3296
3401
  migrations,
3297
3402
  queries: queries.map((q) => new SqlQueryHelper(q, generator, generator.getStatement(q))),
3298
3403
  tables: tableHelpers,
3404
+ attachers,
3299
3405
  className: generator.getClassName(name),
3300
3406
  projectName,
3301
3407
  config
@@ -3323,9 +3429,11 @@ const ProjectSchema = z.object({
3323
3429
  })).min(1, "At least one generator is required").describe("Code generators to run")
3324
3430
  })).min(1, "At least one SQL configuration is required").describe("SQL file configurations"),
3325
3431
  sources: z.array(z.object({
3326
- path: z.string().describe("Path to source file (supports $HOME)"),
3327
- name: z.string().optional().describe("Variable name override")
3328
- })).optional().describe("External source files to include as variables")
3432
+ type: z.enum(["file", "postgres"]).optional().describe("Source type: 'file' (default) or 'postgres'"),
3433
+ path: z.string().optional().describe("Path to source file for file sources (supports $HOME)"),
3434
+ name: z.string().optional().describe("Variable name (file sources) or DuckDB attach alias (postgres sources)"),
3435
+ image: z.string().optional().describe("Docker image for postgres sources (default: postgres:16-alpine)")
3436
+ }).refine((s) => s.type === "postgres" ? !!s.name : !!s.path, { message: "file sources require 'path'; postgres sources require 'name'" })).optional().describe("External sources: files inlined as variables, or postgres databases attached for introspection")
3329
3437
  });
3330
3438
  var ExtraVariable = class {
3331
3439
  constructor(name, value) {
@@ -3334,7 +3442,7 @@ var ExtraVariable = class {
3334
3442
  }
3335
3443
  };
3336
3444
  function createExtraVariables(sources, suppressLogging = false) {
3337
- return sources.map((source) => {
3445
+ return sources.filter((source) => source.type !== "postgres").map((source) => {
3338
3446
  const path = source.path;
3339
3447
  const resolvedPath = path.replace("$HOME", homedir());
3340
3448
  const varName = `sources_${(source.name ?? basename(path, extname(resolvedPath))).replace(/\s+/g, "_")}`;
@@ -3342,6 +3450,13 @@ function createExtraVariables(sources, suppressLogging = false) {
3342
3450
  return new ExtraVariable(varName, `'${resolvedPath}'`);
3343
3451
  });
3344
3452
  }
3453
+ /** Postgres sources resolved from the project config (with image defaulted). */
3454
+ function getPostgresSources(sources) {
3455
+ return sources.filter((source) => source.type === "postgres").map((source) => ({
3456
+ name: source.name,
3457
+ image: source.image ?? "postgres:16-alpine"
3458
+ }));
3459
+ }
3345
3460
  function buildProjectFromCliOptions(options) {
3346
3461
  if (!isValidGenerator(options.generator)) {
3347
3462
  const similar = findSimilarGenerators(options.generator);
@@ -3480,7 +3595,7 @@ function assignResultTypeNames(queries, tables, projectDir = process.cwd()) {
3480
3595
  }
3481
3596
  const fullMatch = tryFullTableMatch(group.queries[0], tablesByName);
3482
3597
  if (fullMatch) {
3483
- claim(fullMatch, shape, group.queries[0], `full-table match`);
3598
+ claim(fullMatch, shape, group.queries[0], "full-table match");
3484
3599
  for (const q of group.queries) q.resultTypeName = fullMatch;
3485
3600
  continue;
3486
3601
  }
@@ -3533,14 +3648,14 @@ function validateQueries(queries) {
3533
3648
  };
3534
3649
  }
3535
3650
  }
3536
- async function writeGeneratedFile(projectDir, gen, generator, file, queries, tables, engine, writeToStdout = false) {
3651
+ async function writeGeneratedFile(projectDir, gen, generator, file, queries, tables, engine, writeToStdout = false, postgresSourceNames = []) {
3537
3652
  await generator.beforeGenerate(projectDir, gen, queries, tables);
3538
3653
  const templatePath = join(dirname(new URL(import.meta.url).pathname), gen.template ?? generator.template);
3539
3654
  const name = gen.name ?? basename(file, extname(file));
3540
3655
  const sourceFile = generateSourceFile(name, queries, tables, templatePath, generator, engine, gen.projectName ?? name, {
3541
3656
  migrations: true,
3542
3657
  ...gen.config
3543
- });
3658
+ }, postgresSourceNames);
3544
3659
  if (writeToStdout) {
3545
3660
  process.stdout.write(sourceFile);
3546
3661
  if (!sourceFile.endsWith("\n")) process.stdout.write("\n");
@@ -3641,6 +3756,11 @@ async function processProjectFromConfig(project, projectDir, writeToStdout = fal
3641
3756
  const results = [];
3642
3757
  try {
3643
3758
  const extraVariables = createExtraVariables(project.sources ?? [], writeToStdout);
3759
+ const pgSources = getPostgresSources(project.sources ?? []);
3760
+ const pgSourceNames = new Set(pgSources.map((s) => s.name));
3761
+ if (pgSources.length > 0) {
3762
+ if (!project.sql.some((sql) => sql.gen.some((gen) => getGeneratorEngine(gen.generator) === "duckdb"))) throw new SqgError("Postgres sources require a DuckDB generator (they are attached into DuckDB for introspection)", "CONFIG_VALIDATION_ERROR", "Add a 'typescript/duckdb' or 'java/duckdb' generator, or remove the postgres sources");
3763
+ }
3644
3764
  const files = [];
3645
3765
  for (const sql of project.sql) {
3646
3766
  const gensByEngine = /* @__PURE__ */ new Map();
@@ -3658,6 +3778,7 @@ async function processProjectFromConfig(project, projectDir, writeToStdout = fal
3658
3778
  const parseResult = parseSQLQueries(fullPath, extraVariables);
3659
3779
  queries = parseResult.queries;
3660
3780
  tables = parseResult.tables;
3781
+ for (const q of queries) if (q.sourceTarget && !pgSourceNames.has(q.sourceTarget)) throw SqgError.inQuery(`BASELINE block targets unknown postgres source '${q.sourceTarget}'`, "CONFIG_VALIDATION_ERROR", q.id, sqlFile, { suggestion: `Declare a source with 'type: postgres, name: ${q.sourceTarget}' in sqg.yaml` });
3661
3782
  } catch (e) {
3662
3783
  if (e instanceof SqgError) throw e;
3663
3784
  throw SqgError.inFile(`Failed to parse SQL file: ${e.message}`, "SQL_PARSE_ERROR", sqlFile, { suggestion: "Check SQL syntax and annotation format" });
@@ -3666,10 +3787,17 @@ async function processProjectFromConfig(project, projectDir, writeToStdout = fal
3666
3787
  const executableQueries = queries.filter((q) => !q.skipGenerateFunction);
3667
3788
  const reporterAny = reporter;
3668
3789
  if (reporterAny?.setQueryTotal) reporterAny.setQueryTotal(executableQueries.length);
3790
+ let preparedSources;
3669
3791
  try {
3670
3792
  const dbEngine = getDatabaseEngine(engine);
3793
+ let attachments;
3794
+ if (engine === "duckdb" && pgSources.length > 0) {
3795
+ ui?.startPhase("Starting postgres source containers...");
3796
+ preparedSources = await preparePostgresSources(pgSources, queries, reporter);
3797
+ attachments = preparedSources.attachments;
3798
+ }
3671
3799
  ui?.startPhase(`Initializing ${engine} database...`);
3672
- await dbEngine.initializeDatabase(queries, reporter);
3800
+ await dbEngine.initializeDatabase(queries, reporter, { attachments });
3673
3801
  ui?.startPhase(`Introspecting ${executableQueries.length} queries...`);
3674
3802
  await dbEngine.executeQueries(queries, reporter);
3675
3803
  if (tables.length > 0) await dbEngine.introspectTables(tables, reporter);
@@ -3686,6 +3814,8 @@ async function processProjectFromConfig(project, projectDir, writeToStdout = fal
3686
3814
  file: sqlFile,
3687
3815
  engine
3688
3816
  });
3817
+ } finally {
3818
+ if (preparedSources) await preparedSources.teardown();
3689
3819
  }
3690
3820
  ui?.startPhase("Generating code...");
3691
3821
  const genStart = performance.now();
@@ -3694,7 +3824,7 @@ async function processProjectFromConfig(project, projectDir, writeToStdout = fal
3694
3824
  const outputPath = await writeGeneratedFile(projectDir, {
3695
3825
  ...gen,
3696
3826
  projectName: project.name
3697
- }, generator, sqlFile, queries, tables, engine, writeToStdout);
3827
+ }, generator, sqlFile, queries, tables, engine, writeToStdout, pgSources.map((s) => s.name));
3698
3828
  if (outputPath !== null) {
3699
3829
  files.push(outputPath);
3700
3830
  results.push({
@@ -3728,7 +3858,7 @@ async function processProject(projectPath, ui) {
3728
3858
  //#region src/mcp-server.ts
3729
3859
  const server = new Server({
3730
3860
  name: "sqg-mcp",
3731
- version: process.env.npm_package_version ?? "0.20.0"
3861
+ version: process.env.npm_package_version ?? "0.21.0"
3732
3862
  }, { capabilities: {
3733
3863
  tools: {},
3734
3864
  resources: {}
@@ -4199,7 +4329,7 @@ function formatMs(ms) {
4199
4329
  }
4200
4330
  //#endregion
4201
4331
  //#region src/sqg.ts
4202
- const version = process.env.npm_package_version ?? "0.20.0";
4332
+ const version = process.env.npm_package_version ?? "0.21.0";
4203
4333
  updateNotifier({ pkg: {
4204
4334
  name: "@sqg/sqg",
4205
4335
  version
@@ -69,7 +69,7 @@ export class {{className}} {
69
69
  {{#unless skipGenerateFunction}}
70
70
  {{functionName}}({{#each variables}}{{name}}: {{type}}{{#unless @last}}, {{/unless}}{{/each}}): {{> returnType }} {
71
71
  const stmt = this.prepare<[{{> paramTypes}}], {{> resultType}}>('{{id}}',
72
- {{{quote sqlQuery}}}{{#if isPluck}}, true{{/if}});
72
+ {{{sqlExpr this}}}{{#if isPluck}}, true{{/if}});
73
73
  {{> execute}}
74
74
  }
75
75
  {{/unless}}
@@ -223,6 +223,15 @@ public class {{className}} {
223
223
 
224
224
  {{{declareEnums queries}}}
225
225
 
226
+ {{#each attachers}}
227
+ /** Attach the "{{alias}}" postgres source under the same alias used during generation. */
228
+ public void {{functionName}}(String connectionString) throws SQLException {
229
+ try (var stmt = connection.createStatement()) {
230
+ stmt.execute("ATTACH '" + connectionString + "' AS {{alias}} (TYPE postgres)");
231
+ }
232
+ }
233
+
234
+ {{/each}}
226
235
  {{#each queries}}
227
236
  {{#unless skipGenerateFunction}}
228
237
  {{>columnTypesRecord}}
@@ -135,6 +135,12 @@ class {{className}}:
135
135
  {{/if}}
136
136
  {{/if}}
137
137
 
138
+ {{#each attachers}}
139
+ def {{functionName}}(self, connection_string: str) -> None:
140
+ """Attach the "{{alias}}" postgres source under the same alias used during generation."""
141
+ self._conn.execute(f"ATTACH '{connection_string}' AS {{alias}} (TYPE postgres)")
142
+
143
+ {{/each}}
138
144
  {{#each queries}}
139
145
  {{#unless skipGenerateFunction}}
140
146
  {{#if isQuery}}
@@ -142,7 +148,7 @@ class {{className}}:
142
148
  {{#if isOne}}
143
149
  def {{functionName}}(self{{#each variables}}, {{name}}: {{{type}}}{{/each}}) -> {{{pyTypeOrNone (lookup columns 0)}}}:
144
150
  row = self._conn.execute(
145
- {{{quote sqlQuery}}}, {{> params}}
151
+ {{{sqlExpr this}}}, {{> params}}
146
152
  ).fetchone()
147
153
  if row is None:
148
154
  return None
@@ -150,19 +156,19 @@ class {{className}}:
150
156
 
151
157
  def {{functionName}}_raw(self{{#each variables}}, {{name}}: {{{type}}}{{/each}}) -> tuple[Any, ...] | None:
152
158
  return self._conn.execute(
153
- {{{quote sqlQuery}}}, {{> params}}
159
+ {{{sqlExpr this}}}, {{> params}}
154
160
  ).fetchone()
155
161
 
156
162
  {{else}}
157
163
  def {{functionName}}(self{{#each variables}}, {{name}}: {{{type}}}{{/each}}) -> list[{{{pyType (lookup columns 0)}}}]:
158
164
  rows = self._conn.execute(
159
- {{{quote sqlQuery}}}, {{> params}}
165
+ {{{sqlExpr this}}}, {{> params}}
160
166
  ).fetchall()
161
167
  return [r[0] for r in rows]
162
168
 
163
169
  def {{functionName}}_raw(self{{#each variables}}, {{name}}: {{{type}}}{{/each}}) -> list[tuple[Any, ...]]:
164
170
  return self._conn.execute(
165
- {{{quote sqlQuery}}}, {{> params}}
171
+ {{{sqlExpr this}}}, {{> params}}
166
172
  ).fetchall()
167
173
 
168
174
  {{/if}}
@@ -170,7 +176,7 @@ class {{className}}:
170
176
  {{#if isOne}}
171
177
  def {{functionName}}(self{{#each variables}}, {{name}}: {{{type}}}{{/each}}) -> {{{rowType}}} | None:
172
178
  row = self._conn.execute(
173
- {{{quote sqlQuery}}}, {{> params}}
179
+ {{{sqlExpr this}}}, {{> params}}
174
180
  ).fetchone()
175
181
  if row is None:
176
182
  return None
@@ -178,19 +184,19 @@ class {{className}}:
178
184
 
179
185
  def {{functionName}}_raw(self{{#each variables}}, {{name}}: {{{type}}}{{/each}}) -> tuple[Any, ...] | None:
180
186
  return self._conn.execute(
181
- {{{quote sqlQuery}}}, {{> params}}
187
+ {{{sqlExpr this}}}, {{> params}}
182
188
  ).fetchone()
183
189
 
184
190
  {{else}}
185
191
  def {{functionName}}(self{{#each variables}}, {{name}}: {{{type}}}{{/each}}) -> list[{{{rowType}}}]:
186
192
  rows = self._conn.execute(
187
- {{{quote sqlQuery}}}, {{> params}}
193
+ {{{sqlExpr this}}}, {{> params}}
188
194
  ).fetchall()
189
195
  return [{{{constructRow this}}} for row in rows]
190
196
 
191
197
  def {{functionName}}_raw(self{{#each variables}}, {{name}}: {{{type}}}{{/each}}) -> list[tuple[Any, ...]]:
192
198
  return self._conn.execute(
193
- {{{quote sqlQuery}}}, {{> params}}
199
+ {{{sqlExpr this}}}, {{> params}}
194
200
  ).fetchall()
195
201
 
196
202
  {{/if}}
@@ -198,7 +204,7 @@ class {{className}}:
198
204
  {{else}}
199
205
  def {{functionName}}(self{{#each variables}}, {{name}}: {{{type}}}{{/each}}) -> None:
200
206
  self._conn.execute(
201
- {{{quote sqlQuery}}}, {{> params}}
207
+ {{{sqlExpr this}}}, {{> params}}
202
208
  )
203
209
 
204
210
  {{/if}}
@@ -58,10 +58,17 @@ export class {{className}} {
58
58
  );
59
59
  }
60
60
 
61
+ {{#each attachers}}
62
+ /** Attach the "{{alias}}" postgres source under the same alias used during generation. */
63
+ async {{functionName}}(connectionString: string): Promise<void> {
64
+ await this.conn.run(`ATTACH '${connectionString}' AS {{alias}} (TYPE postgres)`);
65
+ }
66
+
67
+ {{/each}}
61
68
  {{#each queries}}
62
69
  {{#unless skipGenerateFunction}}
63
70
  async {{functionName}}({{#each variables}}{{name}}: {{type}}{{#unless @last}}, {{/unless}}{{/each}}): Promise<{{> returnType }}> {
64
- const sql = {{{quote sqlQuery}}};
71
+ const sql = {{{sqlExpr this}}};
65
72
  {{#if (hasListParams this)}}
66
73
  const stmt = await this.conn.prepare(sql);
67
74
  {{{bindStatements this}}}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sqg/sqg",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "SQG - SQL Query Generator - Type-safe code generation from SQL (https://sqg.dev)",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",