@sqg/sqg 0.21.0 → 0.21.1

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/dist/index.mjs CHANGED
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { LRParser } from "@lezer/lr";
5
5
  import { homedir } from "node:os";
6
6
  import { basename, dirname, extname, join, relative, resolve } from "node:path";
7
- import { camelCase, pascalCase, snakeCase } from "es-toolkit/string";
7
+ import { camelCase, escapeRegExp, pascalCase, snakeCase } from "es-toolkit/string";
8
8
  import Handlebars from "handlebars";
9
9
  import YAML from "yaml";
10
10
  import { z } from "zod";
@@ -1931,8 +1931,20 @@ async function preparePostgresSources(sources, queries, reporter) {
1931
1931
  };
1932
1932
  try {
1933
1933
  for (const source of sources) {
1934
+ if (source.url) {
1935
+ attachments.push({
1936
+ alias: source.name,
1937
+ connectionUri: source.url
1938
+ });
1939
+ continue;
1940
+ }
1934
1941
  reporter?.onContainerStarting?.();
1935
- const container = await new PostgreSqlContainer(source.image).withDatabase("sqg-db").withUsername("sqg").withPassword("secret").start();
1942
+ let container;
1943
+ try {
1944
+ container = await new PostgreSqlContainer(source.image).withDatabase("sqg-db").withUsername("sqg").withPassword("secret").start();
1945
+ } catch (e) {
1946
+ throw new DatabaseError(`Could not start a Postgres container for source '${source.name}': ${e.message}`, "postgres", "Postgres sources need Docker to introspect schema. Start Docker, or set 'url' on the source to point at an existing Postgres database (e.g. url: $DATABASE_URL).");
1947
+ }
1936
1948
  containers.push(container);
1937
1949
  const connectionUri = container.getConnectionUri();
1938
1950
  reporter?.onContainerStarted?.(connectionUri);
@@ -2178,15 +2190,16 @@ const ProjectSchema = z.object({
2178
2190
  template: z.string().optional().describe("Custom Handlebars template path"),
2179
2191
  output: z.string().min(1, "Output path is required").describe("Output file or directory path"),
2180
2192
  config: z.any().optional().describe("Generator-specific configuration")
2181
- })).min(1, "At least one generator is required").describe("Code generators to run")
2182
- })).min(1, "At least one SQL configuration is required").describe("SQL file configurations"),
2193
+ }).strict()).min(1, "At least one generator is required").describe("Code generators to run")
2194
+ }).strict()).min(1, "At least one SQL configuration is required").describe("SQL file configurations"),
2183
2195
  sources: z.array(z.object({
2184
2196
  type: z.enum(["file", "postgres"]).optional().describe("Source type: 'file' (default) or 'postgres'"),
2185
2197
  path: z.string().optional().describe("Path to source file for file sources (supports $HOME)"),
2186
2198
  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")
2189
- });
2199
+ image: z.string().optional().describe("Docker image for postgres sources (default: postgres:16-alpine)"),
2200
+ url: z.string().optional().describe("Postgres source: connect to this existing database for introspection instead of starting a container. Use a literal DSN or '$ENV_VAR' to read from the environment. When set, ':source=' BASELINE blocks are not applied (the live schema is used), which avoids schema drift.")
2201
+ }).strict().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")
2202
+ }).strict();
2190
2203
  var ExtraVariable = class {
2191
2204
  constructor(name, value) {
2192
2205
  this.name = name;
@@ -2202,11 +2215,25 @@ function createExtraVariables(sources, suppressLogging = false) {
2202
2215
  return new ExtraVariable(varName, `'${resolvedPath}'`);
2203
2216
  });
2204
2217
  }
2218
+ /**
2219
+ * Resolve a postgres source `url`. A value of the form `$VAR` or `${VAR}` is
2220
+ * read from the environment (so credentials need not live in sqg.yaml); any
2221
+ * other value is treated as a literal DSN.
2222
+ */
2223
+ function resolveSourceUrl(url, sourceName) {
2224
+ const match = url.match(/^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/);
2225
+ if (!match) return url;
2226
+ const envName = match[1];
2227
+ const value = process.env[envName];
2228
+ if (!value) throw new SqgError(`Postgres source '${sourceName}' references environment variable '${envName}' which is not set`, "CONFIG_VALIDATION_ERROR", `Set ${envName} to the database connection string, or use a literal DSN in the 'url' field`);
2229
+ return value;
2230
+ }
2205
2231
  /** Postgres sources resolved from the project config (with image defaulted). */
2206
2232
  function getPostgresSources(sources) {
2207
2233
  return sources.filter((source) => source.type === "postgres").map((source) => ({
2208
2234
  name: source.name,
2209
- image: source.image ?? "postgres:16-alpine"
2235
+ image: source.image ?? "postgres:16-alpine",
2236
+ url: source.url ? resolveSourceUrl(source.url, source.name) : void 0
2210
2237
  }));
2211
2238
  }
2212
2239
  function buildProjectFromCliOptions(options) {
@@ -2535,6 +2562,11 @@ async function processProjectFromConfig(project, projectDir, writeToStdout = fal
2535
2562
  if (e instanceof SqgError) throw e;
2536
2563
  throw SqgError.inFile(`Failed to parse SQL file: ${e.message}`, "SQL_PARSE_ERROR", sqlFile, { suggestion: "Check SQL syntax and annotation format" });
2537
2564
  }
2565
+ const referencedPgSources = pgSources.filter((source) => {
2566
+ const catalogRef = new RegExp(`\\b${escapeRegExp(source.name)}\\.`);
2567
+ return queries.some((q) => q.sourceTarget === source.name || catalogRef.test(q.rawQuery));
2568
+ });
2569
+ const referencedPgSourceNames = referencedPgSources.map((s) => s.name);
2538
2570
  for (const [engine, gens] of gensByEngine) {
2539
2571
  const executableQueries = queries.filter((q) => !q.skipGenerateFunction);
2540
2572
  const reporterAny = reporter;
@@ -2543,9 +2575,9 @@ async function processProjectFromConfig(project, projectDir, writeToStdout = fal
2543
2575
  try {
2544
2576
  const dbEngine = getDatabaseEngine(engine);
2545
2577
  let attachments;
2546
- if (engine === "duckdb" && pgSources.length > 0) {
2578
+ if (engine === "duckdb" && referencedPgSources.length > 0) {
2547
2579
  ui?.startPhase("Starting postgres source containers...");
2548
- preparedSources = await preparePostgresSources(pgSources, queries, reporter);
2580
+ preparedSources = await preparePostgresSources(referencedPgSources, queries, reporter);
2549
2581
  attachments = preparedSources.attachments;
2550
2582
  }
2551
2583
  ui?.startPhase(`Initializing ${engine} database...`);
@@ -2576,7 +2608,7 @@ async function processProjectFromConfig(project, projectDir, writeToStdout = fal
2576
2608
  const outputPath = await writeGeneratedFile(projectDir, {
2577
2609
  ...gen,
2578
2610
  projectName: project.name
2579
- }, generator, sqlFile, queries, tables, engine, writeToStdout, pgSources.map((s) => s.name));
2611
+ }, generator, sqlFile, queries, tables, engine, writeToStdout, referencedPgSourceNames);
2580
2612
  if (outputPath !== null) {
2581
2613
  files.push(outputPath);
2582
2614
  results.push({
@@ -2813,8 +2845,12 @@ function parseSQLQueries(filePath, extraVariables) {
2813
2845
  });
2814
2846
  }
2815
2847
  trim() {
2816
- const lastPart = this.sqlParts.length > 0 ? this.sqlParts[this.sqlParts.length - 1] : null;
2817
- if (lastPart && typeof lastPart === "string") this.sqlParts[this.sqlParts.length - 1] = lastPart.trimEnd();
2848
+ const isBlankString = (p) => typeof p === "string" && p.trim() === "";
2849
+ while (this.sqlParts.length > 0 && isBlankString(this.sqlParts[0])) this.sqlParts.shift();
2850
+ if (this.sqlParts.length > 0 && typeof this.sqlParts[0] === "string") this.sqlParts[0] = this.sqlParts[0].trimStart();
2851
+ while (this.sqlParts.length > 0 && isBlankString(this.sqlParts[this.sqlParts.length - 1])) this.sqlParts.pop();
2852
+ const lastIdx = this.sqlParts.length - 1;
2853
+ if (lastIdx >= 0 && typeof this.sqlParts[lastIdx] === "string") this.sqlParts[lastIdx] = this.sqlParts[lastIdx].trimEnd();
2818
2854
  }
2819
2855
  parameters() {
2820
2856
  return this.sqlParts.filter((part) => typeof part !== "string" && !part.name.startsWith("sources_"));
package/dist/sqg.mjs CHANGED
@@ -13,7 +13,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
13
13
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
14
  import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
15
15
  import YAML from "yaml";
16
- import { camelCase, pascalCase, snakeCase } from "es-toolkit/string";
16
+ import { camelCase, escapeRegExp, pascalCase, snakeCase } from "es-toolkit/string";
17
17
  import Handlebars from "handlebars";
18
18
  import { z } from "zod";
19
19
  import { DuckDBEnumType, DuckDBInstance, DuckDBListType, DuckDBMapType, DuckDBStructType } from "@duckdb/node-api";
@@ -949,8 +949,12 @@ function parseSQLQueries(filePath, extraVariables) {
949
949
  });
950
950
  }
951
951
  trim() {
952
- const lastPart = this.sqlParts.length > 0 ? this.sqlParts[this.sqlParts.length - 1] : null;
953
- if (lastPart && typeof lastPart === "string") this.sqlParts[this.sqlParts.length - 1] = lastPart.trimEnd();
952
+ const isBlankString = (p) => typeof p === "string" && p.trim() === "";
953
+ while (this.sqlParts.length > 0 && isBlankString(this.sqlParts[0])) this.sqlParts.shift();
954
+ if (this.sqlParts.length > 0 && typeof this.sqlParts[0] === "string") this.sqlParts[0] = this.sqlParts[0].trimStart();
955
+ while (this.sqlParts.length > 0 && isBlankString(this.sqlParts[this.sqlParts.length - 1])) this.sqlParts.pop();
956
+ const lastIdx = this.sqlParts.length - 1;
957
+ if (lastIdx >= 0 && typeof this.sqlParts[lastIdx] === "string") this.sqlParts[lastIdx] = this.sqlParts[lastIdx].trimEnd();
954
958
  }
955
959
  parameters() {
956
960
  return this.sqlParts.filter((part) => typeof part !== "string" && !part.name.startsWith("sources_"));
@@ -3179,8 +3183,20 @@ async function preparePostgresSources(sources, queries, reporter) {
3179
3183
  };
3180
3184
  try {
3181
3185
  for (const source of sources) {
3186
+ if (source.url) {
3187
+ attachments.push({
3188
+ alias: source.name,
3189
+ connectionUri: source.url
3190
+ });
3191
+ continue;
3192
+ }
3182
3193
  reporter?.onContainerStarting?.();
3183
- const container = await new PostgreSqlContainer(source.image).withDatabase("sqg-db").withUsername("sqg").withPassword("secret").start();
3194
+ let container;
3195
+ try {
3196
+ container = await new PostgreSqlContainer(source.image).withDatabase("sqg-db").withUsername("sqg").withPassword("secret").start();
3197
+ } catch (e) {
3198
+ throw new DatabaseError(`Could not start a Postgres container for source '${source.name}': ${e.message}`, "postgres", "Postgres sources need Docker to introspect schema. Start Docker, or set 'url' on the source to point at an existing Postgres database (e.g. url: $DATABASE_URL).");
3199
+ }
3184
3200
  containers.push(container);
3185
3201
  const connectionUri = container.getConnectionUri();
3186
3202
  reporter?.onContainerStarted?.(connectionUri);
@@ -3426,15 +3442,16 @@ const ProjectSchema = z.object({
3426
3442
  template: z.string().optional().describe("Custom Handlebars template path"),
3427
3443
  output: z.string().min(1, "Output path is required").describe("Output file or directory path"),
3428
3444
  config: z.any().optional().describe("Generator-specific configuration")
3429
- })).min(1, "At least one generator is required").describe("Code generators to run")
3430
- })).min(1, "At least one SQL configuration is required").describe("SQL file configurations"),
3445
+ }).strict()).min(1, "At least one generator is required").describe("Code generators to run")
3446
+ }).strict()).min(1, "At least one SQL configuration is required").describe("SQL file configurations"),
3431
3447
  sources: z.array(z.object({
3432
3448
  type: z.enum(["file", "postgres"]).optional().describe("Source type: 'file' (default) or 'postgres'"),
3433
3449
  path: z.string().optional().describe("Path to source file for file sources (supports $HOME)"),
3434
3450
  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")
3437
- });
3451
+ image: z.string().optional().describe("Docker image for postgres sources (default: postgres:16-alpine)"),
3452
+ url: z.string().optional().describe("Postgres source: connect to this existing database for introspection instead of starting a container. Use a literal DSN or '$ENV_VAR' to read from the environment. When set, ':source=' BASELINE blocks are not applied (the live schema is used), which avoids schema drift.")
3453
+ }).strict().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")
3454
+ }).strict();
3438
3455
  var ExtraVariable = class {
3439
3456
  constructor(name, value) {
3440
3457
  this.name = name;
@@ -3450,11 +3467,25 @@ function createExtraVariables(sources, suppressLogging = false) {
3450
3467
  return new ExtraVariable(varName, `'${resolvedPath}'`);
3451
3468
  });
3452
3469
  }
3470
+ /**
3471
+ * Resolve a postgres source `url`. A value of the form `$VAR` or `${VAR}` is
3472
+ * read from the environment (so credentials need not live in sqg.yaml); any
3473
+ * other value is treated as a literal DSN.
3474
+ */
3475
+ function resolveSourceUrl(url, sourceName) {
3476
+ const match = url.match(/^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/);
3477
+ if (!match) return url;
3478
+ const envName = match[1];
3479
+ const value = process.env[envName];
3480
+ if (!value) throw new SqgError(`Postgres source '${sourceName}' references environment variable '${envName}' which is not set`, "CONFIG_VALIDATION_ERROR", `Set ${envName} to the database connection string, or use a literal DSN in the 'url' field`);
3481
+ return value;
3482
+ }
3453
3483
  /** Postgres sources resolved from the project config (with image defaulted). */
3454
3484
  function getPostgresSources(sources) {
3455
3485
  return sources.filter((source) => source.type === "postgres").map((source) => ({
3456
3486
  name: source.name,
3457
- image: source.image ?? "postgres:16-alpine"
3487
+ image: source.image ?? "postgres:16-alpine",
3488
+ url: source.url ? resolveSourceUrl(source.url, source.name) : void 0
3458
3489
  }));
3459
3490
  }
3460
3491
  function buildProjectFromCliOptions(options) {
@@ -3783,6 +3814,11 @@ async function processProjectFromConfig(project, projectDir, writeToStdout = fal
3783
3814
  if (e instanceof SqgError) throw e;
3784
3815
  throw SqgError.inFile(`Failed to parse SQL file: ${e.message}`, "SQL_PARSE_ERROR", sqlFile, { suggestion: "Check SQL syntax and annotation format" });
3785
3816
  }
3817
+ const referencedPgSources = pgSources.filter((source) => {
3818
+ const catalogRef = new RegExp(`\\b${escapeRegExp(source.name)}\\.`);
3819
+ return queries.some((q) => q.sourceTarget === source.name || catalogRef.test(q.rawQuery));
3820
+ });
3821
+ const referencedPgSourceNames = referencedPgSources.map((s) => s.name);
3786
3822
  for (const [engine, gens] of gensByEngine) {
3787
3823
  const executableQueries = queries.filter((q) => !q.skipGenerateFunction);
3788
3824
  const reporterAny = reporter;
@@ -3791,9 +3827,9 @@ async function processProjectFromConfig(project, projectDir, writeToStdout = fal
3791
3827
  try {
3792
3828
  const dbEngine = getDatabaseEngine(engine);
3793
3829
  let attachments;
3794
- if (engine === "duckdb" && pgSources.length > 0) {
3830
+ if (engine === "duckdb" && referencedPgSources.length > 0) {
3795
3831
  ui?.startPhase("Starting postgres source containers...");
3796
- preparedSources = await preparePostgresSources(pgSources, queries, reporter);
3832
+ preparedSources = await preparePostgresSources(referencedPgSources, queries, reporter);
3797
3833
  attachments = preparedSources.attachments;
3798
3834
  }
3799
3835
  ui?.startPhase(`Initializing ${engine} database...`);
@@ -3824,7 +3860,7 @@ async function processProjectFromConfig(project, projectDir, writeToStdout = fal
3824
3860
  const outputPath = await writeGeneratedFile(projectDir, {
3825
3861
  ...gen,
3826
3862
  projectName: project.name
3827
- }, generator, sqlFile, queries, tables, engine, writeToStdout, pgSources.map((s) => s.name));
3863
+ }, generator, sqlFile, queries, tables, engine, writeToStdout, referencedPgSourceNames);
3828
3864
  if (outputPath !== null) {
3829
3865
  files.push(outputPath);
3830
3866
  results.push({
@@ -3858,7 +3894,7 @@ async function processProject(projectPath, ui) {
3858
3894
  //#region src/mcp-server.ts
3859
3895
  const server = new Server({
3860
3896
  name: "sqg-mcp",
3861
- version: process.env.npm_package_version ?? "0.21.0"
3897
+ version: process.env.npm_package_version ?? "0.21.1"
3862
3898
  }, { capabilities: {
3863
3899
  tools: {},
3864
3900
  resources: {}
@@ -4329,7 +4365,7 @@ function formatMs(ms) {
4329
4365
  }
4330
4366
  //#endregion
4331
4367
  //#region src/sqg.ts
4332
- const version = process.env.npm_package_version ?? "0.21.0";
4368
+ const version = process.env.npm_package_version ?? "0.21.1";
4333
4369
  updateNotifier({ pkg: {
4334
4370
  name: "@sqg/sqg",
4335
4371
  version
@@ -224,9 +224,11 @@ public class {{className}} {
224
224
  {{{declareEnums queries}}}
225
225
 
226
226
  {{#each attachers}}
227
- /** Attach the "{{alias}}" postgres source under the same alias used during generation. */
227
+ /** Attach the "{{alias}}" postgres source under the same alias used during generation. Loads the postgres extension (idempotent) so callers need not rely on DuckDB autoloading. */
228
228
  public void {{functionName}}(String connectionString) throws SQLException {
229
229
  try (var stmt = connection.createStatement()) {
230
+ stmt.execute("INSTALL postgres");
231
+ stmt.execute("LOAD postgres");
230
232
  stmt.execute("ATTACH '" + connectionString + "' AS {{alias}} (TYPE postgres)");
231
233
  }
232
234
  }
@@ -137,7 +137,13 @@ class {{className}}:
137
137
 
138
138
  {{#each attachers}}
139
139
  def {{functionName}}(self, connection_string: str) -> None:
140
- """Attach the "{{alias}}" postgres source under the same alias used during generation."""
140
+ """Attach the "{{alias}}" postgres source under the same alias used during generation.
141
+
142
+ Loads the postgres extension (idempotent) so callers need not rely on
143
+ DuckDB autoloading.
144
+ """
145
+ self._conn.execute("INSTALL postgres")
146
+ self._conn.execute("LOAD postgres")
141
147
  self._conn.execute(f"ATTACH '{connection_string}' AS {{alias}} (TYPE postgres)")
142
148
 
143
149
  {{/each}}
@@ -59,8 +59,10 @@ export class {{className}} {
59
59
  }
60
60
 
61
61
  {{#each attachers}}
62
- /** Attach the "{{alias}}" postgres source under the same alias used during generation. */
62
+ /** Attach the "{{alias}}" postgres source under the same alias used during generation. Loads the postgres extension (idempotent) so callers need not rely on DuckDB autoloading. */
63
63
  async {{functionName}}(connectionString: string): Promise<void> {
64
+ await this.conn.run("INSTALL postgres");
65
+ await this.conn.run("LOAD postgres");
64
66
  await this.conn.run(`ATTACH '${connectionString}' AS {{alias}} (TYPE postgres)`);
65
67
  }
66
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sqg/sqg",
3
- "version": "0.21.0",
3
+ "version": "0.21.1",
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",