@sqg/sqg 0.11.0 → 0.13.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/dist/sqg.mjs CHANGED
@@ -2,27 +2,32 @@
2
2
  import { exit } from "node:process";
3
3
  import { Command } from "commander";
4
4
  import consola, { LogLevels } from "consola";
5
+ import updateNotifier from "update-notifier";
6
+ import pc from "picocolors";
5
7
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
+ import * as clack from "@clack/prompts";
6
9
  import { randomUUID } from "node:crypto";
7
10
  import { homedir, tmpdir } from "node:os";
8
- import { basename, dirname, extname, join, resolve } from "node:path";
11
+ import { basename, dirname, extname, join, relative, resolve } from "node:path";
9
12
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
10
13
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
14
  import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
12
15
  import YAML from "yaml";
16
+ import { camelCase, pascalCase } from "es-toolkit/string";
13
17
  import Handlebars from "handlebars";
14
18
  import { z } from "zod";
15
19
  import { DuckDBEnumType, DuckDBInstance, DuckDBListType, DuckDBMapType, DuckDBStructType } from "@duckdb/node-api";
16
20
  import { LRParser } from "@lezer/lr";
17
- import { camelCase, isNotNil, pascalCase, sortBy } from "es-toolkit";
21
+ import { isNotNil, sortBy } from "es-toolkit";
22
+ import { PostgreSqlContainer } from "@testcontainers/postgresql";
18
23
  import { Client } from "pg";
19
24
  import types from "pg-types";
20
25
  import BetterSqlite3 from "better-sqlite3";
21
- import { camelCase as camelCase$1, pascalCase as pascalCase$1 } from "es-toolkit/string";
22
26
  import prettier from "prettier/standalone";
23
27
  import prettierPluginJava from "prettier-plugin-java";
24
28
  import typescriptPlugin from "prettier/parser-typescript";
25
29
  import estree from "prettier/plugins/estree";
30
+ import yoctoSpinner from "yocto-spinner";
26
31
 
27
32
  //#region src/constants.ts
28
33
  /**
@@ -360,12 +365,6 @@ function formatErrorForOutput(err) {
360
365
  * SQG Project Initialization - Creates new SQG projects with example files
361
366
  */
362
367
  /**
363
- * Get the default generator for a language preference
364
- */
365
- function getDefaultGenerator() {
366
- return "typescript/sqlite";
367
- }
368
- /**
369
368
  * Generate example SQL content based on engine
370
369
  */
371
370
  function getExampleSql(engine) {
@@ -561,14 +560,14 @@ DELETE FROM posts WHERE id = \${id};
561
560
  /**
562
561
  * Generate sqg.yaml configuration
563
562
  */
564
- function getConfigYaml(generator, output) {
563
+ function getConfigYaml(generator, output, projectName) {
565
564
  const isJava = parseGenerator(generator).language === "java";
566
565
  return `# SQG Configuration
567
566
  # Generated by: sqg init
568
567
  # Documentation: https://sqg.dev
569
568
 
570
569
  version: 1
571
- name: my-project
570
+ name: ${projectName}
572
571
 
573
572
  sql:
574
573
  - files:
@@ -581,11 +580,70 @@ sql:
581
580
  `;
582
581
  }
583
582
  /**
584
- * Initialize a new SQG project
583
+ * Run interactive wizard when no --generator flag is provided
585
584
  */
586
- async function initProject(options) {
587
- const generator = options.generator || getDefaultGenerator();
588
- const output = options.output || "./generated";
585
+ async function runInteractiveInit(options) {
586
+ clack.intro(pc.bold("Create a new SQG project"));
587
+ const projectName = await clack.text({
588
+ message: "Project name",
589
+ placeholder: "my-project",
590
+ defaultValue: "my-project",
591
+ validate: (value) => {
592
+ if (!value?.trim()) return "Project name is required";
593
+ }
594
+ });
595
+ if (clack.isCancel(projectName)) {
596
+ clack.cancel("Operation cancelled");
597
+ process.exit(0);
598
+ }
599
+ const generatorOptions = [];
600
+ for (const shortName of SHORT_GENERATOR_NAMES) {
601
+ const info = GENERATORS[`${shortName}/${Object.entries(GENERATORS).find(([k]) => k.startsWith(`${shortName}/`))?.[1]?.driver}`];
602
+ if (info) generatorOptions.push({
603
+ value: shortName,
604
+ label: shortName,
605
+ hint: info.description
606
+ });
607
+ }
608
+ const generator = await clack.select({
609
+ message: "Generator",
610
+ options: generatorOptions
611
+ });
612
+ if (clack.isCancel(generator)) {
613
+ clack.cancel("Operation cancelled");
614
+ process.exit(0);
615
+ }
616
+ const output = await clack.text({
617
+ message: "Output directory",
618
+ placeholder: "./generated",
619
+ defaultValue: options.output || "./generated"
620
+ });
621
+ if (clack.isCancel(output)) {
622
+ clack.cancel("Operation cancelled");
623
+ process.exit(0);
624
+ }
625
+ clack.log.step("Files to create:");
626
+ clack.log.message(` ${pc.dim("sqg.yaml")} Project configuration`);
627
+ clack.log.message(` ${pc.dim("queries.sql")} Example SQL queries`);
628
+ clack.log.message(` ${pc.dim(`${output}/`)} Output directory`);
629
+ const confirm = await clack.confirm({ message: "Create files?" });
630
+ if (clack.isCancel(confirm) || !confirm) {
631
+ clack.cancel("Operation cancelled");
632
+ process.exit(0);
633
+ }
634
+ await createProjectFiles({
635
+ generator,
636
+ output,
637
+ force: options.force,
638
+ projectName
639
+ });
640
+ clack.outro(`Done! Run: ${pc.bold("sqg sqg.yaml")}`);
641
+ }
642
+ /**
643
+ * Create project files (shared between interactive and non-interactive modes)
644
+ */
645
+ async function createProjectFiles(options) {
646
+ const { generator, output, force, projectName } = options;
589
647
  if (!isValidGenerator(generator)) {
590
648
  const similar = findSimilarGenerators(generator);
591
649
  throw new InvalidGeneratorError(generator, [...SHORT_GENERATOR_NAMES, ...GENERATOR_NAMES], similar.length > 0 ? similar[0] : void 0);
@@ -593,32 +651,40 @@ async function initProject(options) {
593
651
  const engine = parseGenerator(generator).engine;
594
652
  const configPath = "sqg.yaml";
595
653
  const sqlPath = "queries.sql";
596
- if (!options.force) {
654
+ if (!force) {
597
655
  if (existsSync(configPath)) throw new SqgError(`File already exists: ${configPath}`, "VALIDATION_ERROR", "Use --force to overwrite existing files");
598
656
  if (existsSync(sqlPath)) throw new SqgError(`File already exists: ${sqlPath}`, "VALIDATION_ERROR", "Use --force to overwrite existing files");
599
657
  }
600
- if (!existsSync(output)) {
601
- mkdirSync(output, { recursive: true });
602
- consola.success(`Created output directory: ${output}`);
603
- }
604
- writeFileSync(configPath, getConfigYaml(generator, output));
605
- consola.success(`Created ${configPath}`);
658
+ if (!existsSync(output)) mkdirSync(output, { recursive: true });
659
+ writeFileSync(configPath, getConfigYaml(generator, output, projectName));
606
660
  writeFileSync(sqlPath, getExampleSql(engine));
607
- consola.success(`Created ${sqlPath}`);
608
- consola.box(`
609
- SQG project initialized!
610
-
611
- Generator: ${generator}
612
- Engine: ${engine}
613
- Output: ${output}
614
-
615
- Next steps:
616
- 1. Edit queries.sql to add your SQL queries
617
- 2. Run: sqg sqg.yaml
618
- 3. Import the generated code from ${output}
619
-
620
- Documentation: https://sqg.dev
621
- `);
661
+ }
662
+ /**
663
+ * Initialize a new SQG project
664
+ */
665
+ async function initProject(options) {
666
+ if (!options.generator && process.stdin.isTTY) {
667
+ await runInteractiveInit(options);
668
+ return;
669
+ }
670
+ const generator = options.generator || "typescript/sqlite";
671
+ const output = options.output || "./generated";
672
+ await createProjectFiles({
673
+ generator,
674
+ output,
675
+ force: options.force,
676
+ projectName: "my-project"
677
+ });
678
+ const generatorInfo = parseGenerator(generator);
679
+ clack.intro(pc.bold("SQG project initialized!"));
680
+ clack.log.info(`Generator: ${generator}`);
681
+ clack.log.info(`Engine: ${generatorInfo.engine}`);
682
+ clack.log.info(`Output: ${output}`);
683
+ clack.log.step("Next steps:");
684
+ clack.log.message(" 1. Edit queries.sql to add your SQL queries");
685
+ clack.log.message(" 2. Run: sqg sqg.yaml");
686
+ clack.log.message(` 3. Import the generated code from ${output}`);
687
+ clack.outro("Documentation: https://sqg.dev");
622
688
  }
623
689
 
624
690
  //#endregion
@@ -670,8 +736,9 @@ var MapType = class {
670
736
  }
671
737
  };
672
738
  var EnumType = class {
673
- constructor(values) {
739
+ constructor(values, name) {
674
740
  this.values = values;
741
+ this.name = name;
675
742
  }
676
743
  toString() {
677
744
  return `ENUM(${this.values.map((v) => `'${v}'`).join(", ")})`;
@@ -744,7 +811,7 @@ var TableInfo = class {
744
811
  };
745
812
  function parseSQLQueries(filePath, extraVariables) {
746
813
  const content = readFileSync(filePath, "utf-8");
747
- consola.info(`Parsing SQL file: ${filePath}`);
814
+ consola.debug(`Parsing SQL file: ${filePath}`);
748
815
  consola.debug(`File start: ${content.slice(0, 200)}`);
749
816
  const queries = [];
750
817
  const tables = [];
@@ -933,9 +1000,9 @@ function parseSQLQueries(filePath, extraVariables) {
933
1000
  consola.debug(`Added query: ${name} (${queryType})`);
934
1001
  }
935
1002
  while (cursor.next());
936
- consola.info(`Total queries parsed: ${queries.length}, tables: ${tables.length}`);
937
- consola.info(`Query names: ${queries.map((q) => q.id).join(", ")}`);
938
- if (tables.length > 0) consola.info(`Table names: ${tables.map((t) => t.id).join(", ")}`);
1003
+ consola.debug(`Total queries parsed: ${queries.length}, tables: ${tables.length}`);
1004
+ consola.debug(`Query names: ${queries.map((q) => q.id).join(", ")}`);
1005
+ if (tables.length > 0) consola.debug(`Table names: ${tables.map((t) => t.id).join(", ")}`);
939
1006
  return {
940
1007
  queries,
941
1008
  tables
@@ -944,7 +1011,7 @@ function parseSQLQueries(filePath, extraVariables) {
944
1011
 
945
1012
  //#endregion
946
1013
  //#region src/db/types.ts
947
- async function initializeDatabase(queries, execQueries) {
1014
+ async function initializeDatabase(queries, execQueries, reporter) {
948
1015
  const migrationQueries = queries.filter((q) => q.isMigrate);
949
1016
  sortBy(migrationQueries, [(q) => Number(q.id.split("_")[1])]);
950
1017
  for (const query of migrationQueries) try {
@@ -961,7 +1028,7 @@ async function initializeDatabase(queries, execQueries) {
961
1028
  throw error;
962
1029
  }
963
1030
  if (migrationQueries.length + testdataQueries.length === 0) consola.warn("No migration or testdata queries found");
964
- consola.success("Database initialized successfully");
1031
+ reporter?.onDatabaseInitialized?.();
965
1032
  }
966
1033
 
967
1034
  //#endregion
@@ -988,7 +1055,7 @@ function convertType(type) {
988
1055
  const duckdb = new class {
989
1056
  db;
990
1057
  connection;
991
- async initializeDatabase(queries) {
1058
+ async initializeDatabase(queries, reporter) {
992
1059
  this.db = await DuckDBInstance.create(":memory:");
993
1060
  this.connection = await this.db.connect();
994
1061
  await initializeDatabase(queries, async (query) => {
@@ -997,18 +1064,17 @@ const duckdb = new class {
997
1064
  } catch (e) {
998
1065
  throw new SqlExecutionError(e.message, query.id, query.filename, query.rawQuery, e);
999
1066
  }
1000
- });
1067
+ }, reporter);
1001
1068
  }
1002
- async executeQueries(queries) {
1069
+ async executeQueries(queries, reporter) {
1003
1070
  const connection = this.connection;
1004
1071
  if (!connection) throw new DatabaseError("DuckDB connection not initialized", "duckdb", "This is an internal error. Check that migrations completed successfully.");
1005
1072
  try {
1006
1073
  const executableQueries = queries.filter((q) => !q.skipGenerateFunction);
1007
1074
  for (const query of executableQueries) {
1008
- consola.info(`Executing query: ${query.id}`);
1075
+ reporter?.onQueryStart?.(query.id);
1009
1076
  await this.executeQuery(connection, query);
1010
- if (query.isQuery) {}
1011
- consola.success(`Query ${query.id} executed successfully`);
1077
+ reporter?.onQueryComplete?.(query.id);
1012
1078
  }
1013
1079
  } catch (error) {
1014
1080
  consola.error("Error executing queries:", error.message);
@@ -1056,11 +1122,11 @@ const duckdb = new class {
1056
1122
  throw error;
1057
1123
  }
1058
1124
  }
1059
- async introspectTables(tables) {
1125
+ async introspectTables(tables, reporter) {
1060
1126
  const connection = this.connection;
1061
1127
  if (!connection) throw new DatabaseError("DuckDB connection not initialized", "duckdb", "This is an internal error. Check that migrations completed successfully.");
1062
1128
  for (const table of tables) {
1063
- consola.info(`Introspecting table schema: ${table.tableName}`);
1129
+ reporter?.onTableStart?.(table.tableName);
1064
1130
  try {
1065
1131
  const descRows = (await connection.runAndReadAll(`DESCRIBE ${table.tableName}`)).getRows();
1066
1132
  const nullabilityMap = /* @__PURE__ */ new Map();
@@ -1074,7 +1140,7 @@ const duckdb = new class {
1074
1140
  nullable: nullabilityMap.get(name) ?? true
1075
1141
  }));
1076
1142
  consola.debug(`Table ${table.tableName} columns:`, table.columns);
1077
- consola.success(`Introspected table: ${table.tableName} (${table.columns.length} columns)`);
1143
+ reporter?.onTableComplete?.(table.tableName, table.columns.length);
1078
1144
  } catch (error) {
1079
1145
  consola.error(`Failed to introspect table '${table.tableName}':`, error);
1080
1146
  throw error;
@@ -1088,103 +1154,178 @@ const duckdb = new class {
1088
1154
 
1089
1155
  //#endregion
1090
1156
  //#region src/db/postgres.ts
1091
- const databaseName = "sqg-db-temp";
1092
- let containerInstance = null;
1093
- async function startTestContainer() {
1094
- if (containerInstance) return containerInstance.getConnectionUri();
1095
- consola.info("Starting PostgreSQL container via testcontainers...");
1096
- const { PostgreSqlContainer } = await import("@testcontainers/postgresql");
1097
- containerInstance = await new PostgreSqlContainer("postgres:16-alpine").withDatabase("sqg-db").withUsername("sqg").withPassword("secret").start();
1098
- const connectionUri = containerInstance.getConnectionUri();
1099
- consola.success(`PostgreSQL container started at: ${connectionUri}`);
1100
- return connectionUri;
1101
- }
1102
- async function stopTestContainer() {
1103
- if (containerInstance) {
1104
- consola.info("Stopping PostgreSQL container...");
1105
- await containerInstance.stop();
1106
- containerInstance = null;
1107
- }
1108
- }
1109
- async function getConnectionString() {
1110
- if (process.env.SQG_POSTGRES_URL) return process.env.SQG_POSTGRES_URL;
1111
- return await startTestContainer();
1112
- }
1113
- function getTempConnectionString(baseUrl) {
1114
- return baseUrl.replace(/\/[^/]+$/, `/${databaseName}`);
1115
- }
1157
+ const tempDatabaseName = "sqg-db-temp";
1116
1158
  const typeIdToName = /* @__PURE__ */ new Map();
1117
1159
  for (const [name, id] of Object.entries(types.builtins)) typeIdToName.set(Number(id), name);
1118
- let dynamicTypeCache = /* @__PURE__ */ new Map();
1119
- async function loadTypeCache(db) {
1120
- const result = await db.query(`
1121
- SELECT t.oid, t.typname, t.typtype, t.typelem, et.typname AS elemtype
1122
- FROM pg_type t
1123
- LEFT JOIN pg_type et ON t.typelem = et.oid
1124
- WHERE t.typtype IN ('b', 'e', 'r', 'c') -- base, enum, range, composite
1125
- OR t.typelem != 0 -- array types
1126
- `);
1127
- dynamicTypeCache = /* @__PURE__ */ new Map();
1128
- for (const row of result.rows) {
1129
- const oid = row.oid;
1130
- let typeName = row.typname;
1131
- if (typeName.startsWith("_") && row.elemtype) typeName = `_${row.elemtype.toUpperCase()}`;
1132
- else typeName = typeName.toUpperCase();
1133
- dynamicTypeCache.set(oid, typeName);
1160
+ /**
1161
+ * External database mode: connects directly to the user's database.
1162
+ * Uses a single transaction for the entire session and rolls back on close,
1163
+ * so the database is never modified. Individual queries use savepoints.
1164
+ */
1165
+ var ExternalDbMode = class {
1166
+ constructor(connectionString) {
1167
+ this.connectionString = connectionString;
1134
1168
  }
1135
- }
1136
- function getTypeName(dataTypeID) {
1137
- const cached = dynamicTypeCache.get(dataTypeID);
1138
- if (cached) return cached;
1139
- return typeIdToName.get(dataTypeID) || `type_${dataTypeID}`;
1140
- }
1141
- const postgres = new class {
1169
+ async connect() {
1170
+ const db = new Client({ connectionString: this.connectionString });
1171
+ try {
1172
+ await db.connect();
1173
+ } catch (e) {
1174
+ throw new DatabaseError(`Failed to connect to PostgreSQL: ${e.message}`, "postgres", `Check that PostgreSQL is running and accessible at ${this.connectionString}.`);
1175
+ }
1176
+ await db.query("BEGIN");
1177
+ return db;
1178
+ }
1179
+ async wrapQuery(db, fn) {
1180
+ try {
1181
+ await db.query("SAVEPOINT sqg_query");
1182
+ return await fn();
1183
+ } finally {
1184
+ await db.query("ROLLBACK TO SAVEPOINT sqg_query");
1185
+ }
1186
+ }
1187
+ async close(db) {
1188
+ await db.query("ROLLBACK");
1189
+ await db.end();
1190
+ }
1191
+ };
1192
+ /**
1193
+ * Temp database mode: creates a temporary database for SQG to work in.
1194
+ * Connects to the provided server first (dbInitial) to CREATE the temp DB,
1195
+ * then connects to the temp DB for all operations.
1196
+ * On close, drops the temp DB and optionally stops the testcontainer.
1197
+ */
1198
+ var TempDbMode = class {
1142
1199
  dbInitial;
1143
- db;
1144
- usingTestContainer = false;
1145
- async initializeDatabase(queries) {
1146
- const connectionString = await getConnectionString();
1147
- const connectionStringTemp = getTempConnectionString(connectionString);
1148
- this.usingTestContainer = containerInstance !== null;
1149
- this.dbInitial = new Client({ connectionString });
1150
- this.db = new Client({ connectionString: connectionStringTemp });
1200
+ container = null;
1201
+ constructor(connectionString, container) {
1202
+ this.connectionString = connectionString;
1203
+ this.container = container;
1204
+ }
1205
+ async connect() {
1206
+ this.dbInitial = new Client({ connectionString: this.connectionString });
1151
1207
  try {
1152
1208
  await this.dbInitial.connect();
1153
1209
  } catch (e) {
1154
- throw new DatabaseError(`Failed to connect to PostgreSQL: ${e.message}`, "postgres", `Check that PostgreSQL is running and accessible at ${connectionString}. Set SQG_POSTGRES_URL environment variable to use a different connection string.`);
1210
+ throw new DatabaseError(`Failed to connect to PostgreSQL: ${e.message}`, "postgres", `Check that PostgreSQL is running and accessible at ${this.connectionString}. Set SQG_POSTGRES_URL environment variable to use a different connection string.`);
1155
1211
  }
1156
1212
  try {
1157
- await this.dbInitial.query(`DROP DATABASE IF EXISTS "${databaseName}";`);
1213
+ await this.dbInitial.query(`DROP DATABASE IF EXISTS "${tempDatabaseName}";`);
1158
1214
  } catch (error) {}
1159
1215
  try {
1160
- await this.dbInitial.query(`CREATE DATABASE "${databaseName}";`);
1216
+ await this.dbInitial.query(`CREATE DATABASE "${tempDatabaseName}";`);
1161
1217
  } catch (error) {
1162
1218
  throw new DatabaseError(`Failed to create temporary database: ${error.message}`, "postgres", "Check PostgreSQL user permissions to create databases");
1163
1219
  }
1220
+ const db = new Client({ connectionString: this.connectionString.replace(/\/[^/]+$/, `/${tempDatabaseName}`) });
1164
1221
  try {
1165
- await this.db.connect();
1222
+ await db.connect();
1166
1223
  } catch (e) {
1167
1224
  throw new DatabaseError(`Failed to connect to temporary database: ${e.message}`, "postgres");
1168
1225
  }
1226
+ return db;
1227
+ }
1228
+ async wrapQuery(db, fn) {
1229
+ try {
1230
+ await db.query("BEGIN");
1231
+ return await fn();
1232
+ } finally {
1233
+ await db.query("ROLLBACK");
1234
+ }
1235
+ }
1236
+ async close(db) {
1237
+ await db.end();
1238
+ await this.dbInitial.query(`DROP DATABASE IF EXISTS "${tempDatabaseName}"`);
1239
+ await this.dbInitial.end();
1240
+ if (this.container) {
1241
+ await this.container.stop();
1242
+ this.container = null;
1243
+ }
1244
+ }
1245
+ };
1246
+ const postgres = new class {
1247
+ db;
1248
+ mode;
1249
+ dynamicTypeCache = /* @__PURE__ */ new Map();
1250
+ enumTypeCache = /* @__PURE__ */ new Map();
1251
+ async startContainer(reporter) {
1252
+ reporter?.onContainerStarting?.();
1253
+ const container = await new PostgreSqlContainer("postgres:16-alpine").withDatabase("sqg-db").withUsername("sqg").withPassword("secret").start();
1254
+ const connectionUri = container.getConnectionUri();
1255
+ reporter?.onContainerStarted?.(connectionUri);
1256
+ return {
1257
+ connectionUri,
1258
+ container
1259
+ };
1260
+ }
1261
+ async loadTypeCache(db) {
1262
+ const result = await db.query(`
1263
+ SELECT t.oid, t.typname, t.typtype, t.typelem, et.typname AS elemtype,
1264
+ e.enumlabel
1265
+ FROM pg_type t
1266
+ LEFT JOIN pg_type et ON t.typelem = et.oid
1267
+ LEFT JOIN pg_enum e ON t.oid = e.enumtypid AND t.typtype = 'e'
1268
+ WHERE t.typtype IN ('b', 'e', 'r', 'c') -- base, enum, range, composite
1269
+ OR t.typelem != 0 -- array types
1270
+ ORDER BY t.oid, e.enumsortorder
1271
+ `);
1272
+ this.dynamicTypeCache = /* @__PURE__ */ new Map();
1273
+ this.enumTypeCache = /* @__PURE__ */ new Map();
1274
+ const enumsByOid = /* @__PURE__ */ new Map();
1275
+ for (const row of result.rows) {
1276
+ const oid = row.oid;
1277
+ let typeName = row.typname;
1278
+ if (typeName.startsWith("_") && row.elemtype) typeName = `_${row.elemtype.toUpperCase()}`;
1279
+ else typeName = typeName.toUpperCase();
1280
+ this.dynamicTypeCache.set(oid, typeName);
1281
+ if (row.enumlabel) {
1282
+ if (!enumsByOid.has(oid)) enumsByOid.set(oid, {
1283
+ name: row.typname,
1284
+ values: []
1285
+ });
1286
+ enumsByOid.get(oid).values.push(row.enumlabel);
1287
+ }
1288
+ }
1289
+ for (const [oid, { name, values }] of enumsByOid) this.enumTypeCache.set(oid, new EnumType(values, name));
1290
+ }
1291
+ getTypeName(dataTypeID) {
1292
+ const cached = this.dynamicTypeCache.get(dataTypeID);
1293
+ if (cached) return cached;
1294
+ return typeIdToName.get(dataTypeID) || `type_${dataTypeID}`;
1295
+ }
1296
+ getColumnType(dataTypeID) {
1297
+ const enumType = this.enumTypeCache.get(dataTypeID);
1298
+ if (enumType) return enumType;
1299
+ return this.getTypeName(dataTypeID);
1300
+ }
1301
+ async initializeDatabase(queries, reporter) {
1302
+ const externalUrl = process.env.SQG_POSTGRES_URL;
1303
+ this.dynamicTypeCache = /* @__PURE__ */ new Map();
1304
+ this.enumTypeCache = /* @__PURE__ */ new Map();
1305
+ if (externalUrl) this.mode = new ExternalDbMode(externalUrl);
1306
+ else {
1307
+ const { connectionUri, container } = await this.startContainer(reporter);
1308
+ this.mode = new TempDbMode(connectionUri, container);
1309
+ }
1310
+ this.db = await this.mode.connect();
1169
1311
  await initializeDatabase(queries, async (query) => {
1170
1312
  try {
1171
1313
  await this.db.query(query.rawQuery);
1172
1314
  } catch (e) {
1173
1315
  throw new SqlExecutionError(e.message, query.id, query.filename, query.rawQuery, e);
1174
1316
  }
1175
- });
1176
- await loadTypeCache(this.db);
1317
+ }, reporter);
1318
+ await this.loadTypeCache(this.db);
1177
1319
  }
1178
- async executeQueries(queries) {
1320
+ async executeQueries(queries, reporter) {
1179
1321
  const db = this.db;
1180
1322
  if (!db) throw new DatabaseError("PostgreSQL database not initialized", "postgres", "This is an internal error. Check that migrations completed successfully.");
1181
1323
  try {
1182
1324
  const executableQueries = queries.filter((q) => !q.skipGenerateFunction);
1183
1325
  for (const query of executableQueries) {
1184
- consola.debug(`Executing query: ${query.id}`);
1326
+ reporter?.onQueryStart?.(query.id);
1185
1327
  await this.executeQuery(db, query);
1186
- if (query.isQuery) {}
1187
- consola.success(`Query ${query.id} executed successfully`);
1328
+ reporter?.onQueryComplete?.(query.id);
1188
1329
  }
1189
1330
  } catch (error) {
1190
1331
  consola.error("Error executing queries:", error.message);
@@ -1194,7 +1335,7 @@ const postgres = new class {
1194
1335
  async executeQuery(db, query) {
1195
1336
  const statement = query.queryPositional;
1196
1337
  try {
1197
- consola.info("Query:", statement.sql);
1338
+ consola.debug("Query:", statement.sql);
1198
1339
  const parameterValues = statement.parameters.map((p) => {
1199
1340
  const value = p.value;
1200
1341
  if (value.startsWith("'") && value.endsWith("'") || value.startsWith("\"") && value.endsWith("\"")) return value.slice(1, -1);
@@ -1208,8 +1349,9 @@ const postgres = new class {
1208
1349
  if (paramTypeResult.rows.length === statement.parameters.length) {
1209
1350
  const paramTypes = /* @__PURE__ */ new Map();
1210
1351
  for (let i = 0; i < statement.parameters.length; i++) {
1211
- const typeName = getTypeName(Number(paramTypeResult.rows[i].oid));
1212
- paramTypes.set(statement.parameters[i].name, typeName);
1352
+ const oid = Number(paramTypeResult.rows[i].oid);
1353
+ const colType = this.getColumnType(oid);
1354
+ paramTypes.set(statement.parameters[i].name, colType);
1213
1355
  }
1214
1356
  query.parameterTypes = paramTypes;
1215
1357
  consola.debug("Parameter types:", Object.fromEntries(paramTypes));
@@ -1217,20 +1359,14 @@ const postgres = new class {
1217
1359
  } catch (e) {
1218
1360
  consola.debug(`Parameter type introspection failed for ${query.id}, using heuristic:`, e.message);
1219
1361
  }
1220
- let result;
1221
- try {
1222
- await db.query("BEGIN");
1223
- result = await db.query(statement.sql, parameterValues);
1224
- } finally {
1225
- await db.query("ROLLBACK");
1226
- }
1362
+ const result = await this.mode.wrapQuery(db, () => db.query(statement.sql, parameterValues));
1227
1363
  if (query.isQuery) {
1228
1364
  const columnNames = result.fields.map((field) => field.name);
1229
1365
  const columnTypes = result.fields.map((field) => {
1230
- return getTypeName(field.dataTypeID);
1366
+ return this.getColumnType(field.dataTypeID);
1231
1367
  });
1232
1368
  consola.debug("Columns:", columnNames);
1233
- consola.debug("Types:", columnTypes);
1369
+ consola.debug("Types:", columnTypes.map((t) => t.toString()));
1234
1370
  query.columns = columnNames.map((name, index) => ({
1235
1371
  name,
1236
1372
  type: columnTypes[index],
@@ -1247,11 +1383,11 @@ const postgres = new class {
1247
1383
  throw error;
1248
1384
  }
1249
1385
  }
1250
- async introspectTables(tables) {
1386
+ async introspectTables(tables, reporter) {
1251
1387
  const db = this.db;
1252
1388
  if (!db) throw new DatabaseError("PostgreSQL database not initialized", "postgres", "This is an internal error. Check that migrations completed successfully.");
1253
1389
  for (const table of tables) {
1254
- consola.info(`Introspecting table schema: ${table.tableName}`);
1390
+ reporter?.onTableStart?.(table.tableName);
1255
1391
  try {
1256
1392
  table.columns = (await db.query(`SELECT column_name, data_type, is_nullable
1257
1393
  FROM information_schema.columns
@@ -1261,7 +1397,7 @@ const postgres = new class {
1261
1397
  type: row.data_type.toUpperCase(),
1262
1398
  nullable: row.is_nullable === "YES"
1263
1399
  }));
1264
- consola.success(`Introspected table: ${table.tableName} (${table.columns.length} columns)`);
1400
+ reporter?.onTableComplete?.(table.tableName, table.columns.length);
1265
1401
  } catch (error) {
1266
1402
  consola.error(`Failed to introspect table '${table.tableName}':`, error);
1267
1403
  throw error;
@@ -1269,10 +1405,9 @@ const postgres = new class {
1269
1405
  }
1270
1406
  }
1271
1407
  async close() {
1272
- await this.db.end();
1273
- await this.dbInitial.query(`DROP DATABASE IF EXISTS "${databaseName}"`);
1274
- await this.dbInitial.end();
1275
- if (this.usingTestContainer) await stopTestContainer();
1408
+ await this.mode.close(this.db);
1409
+ this.dynamicTypeCache = /* @__PURE__ */ new Map();
1410
+ this.enumTypeCache = /* @__PURE__ */ new Map();
1276
1411
  }
1277
1412
  }();
1278
1413
 
@@ -1280,7 +1415,7 @@ const postgres = new class {
1280
1415
  //#region src/db/sqlite.ts
1281
1416
  const sqlite = new class {
1282
1417
  db;
1283
- async initializeDatabase(queries) {
1418
+ async initializeDatabase(queries, reporter) {
1284
1419
  const db = new BetterSqlite3(":memory:");
1285
1420
  await initializeDatabase(queries, (query) => {
1286
1421
  try {
@@ -1289,37 +1424,36 @@ const sqlite = new class {
1289
1424
  throw new SqlExecutionError(e.message, query.id, query.filename, query.rawQuery, e);
1290
1425
  }
1291
1426
  return Promise.resolve();
1292
- });
1427
+ }, reporter);
1293
1428
  this.db = db;
1294
1429
  }
1295
- executeQueries(queries) {
1430
+ executeQueries(queries, reporter) {
1296
1431
  const db = this.db;
1297
1432
  if (!db) throw new DatabaseError("SQLite database not initialized", "sqlite", "This is an internal error. Migrations may have failed silently.");
1298
1433
  try {
1299
1434
  const executableQueries = queries.filter((q) => !q.skipGenerateFunction);
1300
1435
  for (const query of executableQueries) {
1301
- consola.info(`Executing query: ${query.id}`);
1436
+ reporter?.onQueryStart?.(query.id);
1302
1437
  this.executeQuery(db, query);
1303
- if (query.isQuery) {}
1304
- consola.success(`Query ${query.id} executed successfully`);
1438
+ reporter?.onQueryComplete?.(query.id);
1305
1439
  }
1306
1440
  } catch (error) {
1307
1441
  consola.error("Error executing queries:", error.message);
1308
1442
  throw error;
1309
1443
  }
1310
1444
  }
1311
- introspectTables(tables) {
1445
+ introspectTables(tables, reporter) {
1312
1446
  const db = this.db;
1313
1447
  if (!db) throw new DatabaseError("SQLite database not initialized", "sqlite", "This is an internal error. Migrations may have failed silently.");
1314
1448
  for (const table of tables) {
1315
- consola.info(`Introspecting table schema: ${table.tableName}`);
1449
+ reporter?.onTableStart?.(table.tableName);
1316
1450
  const info = this.getTableInfo(db, table.tableName);
1317
1451
  table.columns = Array.from(info.values()).map((col) => ({
1318
1452
  name: col.name,
1319
1453
  type: col.type || "TEXT",
1320
1454
  nullable: col.notnull === 0 && col.pk === 0
1321
1455
  }));
1322
- consola.success(`Introspected table: ${table.tableName} (${table.columns.length} columns)`);
1456
+ reporter?.onTableComplete?.(table.tableName, table.columns.length);
1323
1457
  }
1324
1458
  }
1325
1459
  close() {
@@ -1534,6 +1668,10 @@ var JavaTypeMapper = class JavaTypeMapper extends TypeMapper {
1534
1668
  "false",
1535
1669
  "null"
1536
1670
  ]);
1671
+ getTypeName(column, path = "") {
1672
+ if (column.type instanceof EnumType && column.type.name) return path + pascalCase(column.type.name);
1673
+ return super.getTypeName(column, path);
1674
+ }
1537
1675
  mapPrimitiveType(type, _nullable) {
1538
1676
  const upperType = type.toString().toUpperCase();
1539
1677
  const mappedType = this.typeMap[upperType];
@@ -1585,6 +1723,7 @@ var JavaTypeMapper = class JavaTypeMapper extends TypeMapper {
1585
1723
  return name;
1586
1724
  }
1587
1725
  parseValue(column, value, path) {
1726
+ if (column.type instanceof EnumType && column.type.name) return `${pascalCase(column.type.name)}.fromValue((String)${value})`;
1588
1727
  if (column.type instanceof ListType) {
1589
1728
  const elementType = this.getTypeName({
1590
1729
  name: column.name,
@@ -1810,13 +1949,13 @@ var JavaGenerator = class extends BaseGenerator {
1810
1949
  return engine === "duckdb";
1811
1950
  }
1812
1951
  getFunctionName(id) {
1813
- return camelCase$1(id);
1952
+ return camelCase(id);
1814
1953
  }
1815
1954
  getFilename(sqlFileName) {
1816
- return `${pascalCase$1(sqlFileName)}.java`;
1955
+ return `${pascalCase(sqlFileName)}.java`;
1817
1956
  }
1818
1957
  getClassName(name) {
1819
- return pascalCase$1(name);
1958
+ return pascalCase(name);
1820
1959
  }
1821
1960
  partsToString(parts) {
1822
1961
  const stringParts = [];
@@ -1861,6 +2000,56 @@ var JavaGenerator = class extends BaseGenerator {
1861
2000
  Handlebars.registerHelper("appenderType", (column) => {
1862
2001
  return this.mapType(column);
1863
2002
  });
2003
+ Handlebars.registerHelper("declareEnums", (queryHelpers) => {
2004
+ const enumTypes = /* @__PURE__ */ new Map();
2005
+ for (const qh of queryHelpers) {
2006
+ for (const col of qh.query.columns) if (col.type instanceof EnumType && col.type.name) enumTypes.set(col.type.name, col.type);
2007
+ if (qh.query.parameterTypes) {
2008
+ for (const colType of qh.query.parameterTypes.values()) if (colType instanceof EnumType && colType.name) enumTypes.set(colType.name, colType);
2009
+ }
2010
+ }
2011
+ if (enumTypes.size === 0) return "";
2012
+ const parts = [];
2013
+ for (const [, enumType] of enumTypes) {
2014
+ const enumName = pascalCase(enumType.name);
2015
+ const sanitized = enumType.values.map((v) => {
2016
+ let ident = v.toUpperCase().replace(/[^A-Za-z0-9_]/g, "_");
2017
+ if (ident.length > 0 && /^[0-9]/.test(ident)) ident = `_${ident}`;
2018
+ if (ident.length === 0) ident = "_EMPTY";
2019
+ return ident;
2020
+ });
2021
+ const usedIdents = /* @__PURE__ */ new Set();
2022
+ const finalIdents = sanitized.map((base) => {
2023
+ if (!usedIdents.has(base)) {
2024
+ usedIdents.add(base);
2025
+ return base;
2026
+ }
2027
+ let counter = 2;
2028
+ let candidate = `${base}_${counter}`;
2029
+ while (usedIdents.has(candidate)) {
2030
+ counter++;
2031
+ candidate = `${base}_${counter}`;
2032
+ }
2033
+ usedIdents.add(candidate);
2034
+ return candidate;
2035
+ });
2036
+ const entries = enumType.values.map((v, i) => `${finalIdents[i]}("${v}")`);
2037
+ parts.push(`public enum ${enumName} {
2038
+ ${entries.join(", ")};
2039
+ private final String value;
2040
+ private static final java.util.Map<String, ${enumName}> BY_VALUE =
2041
+ java.util.Map.ofEntries(java.util.Arrays.stream(values()).map(v -> java.util.Map.entry(v.value, v)).toArray(java.util.Map.Entry[]::new));
2042
+ ${enumName}(String value) { this.value = value; }
2043
+ public String getValue() { return value; }
2044
+ public static ${enumName} fromValue(String value) {
2045
+ ${enumName} result = BY_VALUE.get(value);
2046
+ if (result == null) throw new IllegalArgumentException("Unknown value: " + value);
2047
+ return result;
2048
+ }
2049
+ }`);
2050
+ }
2051
+ return new Handlebars.SafeString(parts.join("\n\n "));
2052
+ });
1864
2053
  Handlebars.registerHelper("readColumns", (queryHelper) => {
1865
2054
  const query = queryHelper.query;
1866
2055
  if (queryHelper.isPluck) return this.readColumn({
@@ -2003,7 +2192,7 @@ var TsGenerator = class extends BaseGenerator {
2003
2192
  super(template, new TypeScriptTypeMapper());
2004
2193
  }
2005
2194
  getFunctionName(id) {
2006
- return camelCase$1(id);
2195
+ return camelCase(id);
2007
2196
  }
2008
2197
  isCompatibleWith(_engine) {
2009
2198
  return true;
@@ -2012,7 +2201,7 @@ var TsGenerator = class extends BaseGenerator {
2012
2201
  return `${sqlFileName}.ts`;
2013
2202
  }
2014
2203
  getClassName(name) {
2015
- return pascalCase$1(name);
2204
+ return pascalCase(name);
2016
2205
  }
2017
2206
  async beforeGenerate(_projectDir, _gen, _queries, _tables) {
2018
2207
  Handlebars.registerHelper("quote", (value) => this.quote(value));
@@ -2354,7 +2543,12 @@ var SqlQueryHelper = class {
2354
2543
  const rawType = this.query.parameterTypes?.get(param.name);
2355
2544
  let isArray = false;
2356
2545
  let arrayBaseType = null;
2357
- if (rawType instanceof ListType) {
2546
+ let isEnum = false;
2547
+ let enumClassName = null;
2548
+ if (rawType instanceof EnumType && rawType.name) {
2549
+ isEnum = true;
2550
+ enumClassName = pascalCase(rawType.name);
2551
+ } else if (rawType instanceof ListType) {
2358
2552
  isArray = true;
2359
2553
  arrayBaseType = rawType.baseType.toString();
2360
2554
  } else if (typeof rawType === "string" && rawType.startsWith("_")) {
@@ -2365,7 +2559,9 @@ var SqlQueryHelper = class {
2365
2559
  name: param.name,
2366
2560
  type: vars.get(param.name),
2367
2561
  isArray,
2368
- arrayBaseType
2562
+ arrayBaseType,
2563
+ isEnum,
2564
+ enumClassName
2369
2565
  };
2370
2566
  });
2371
2567
  }
@@ -2487,7 +2683,7 @@ function createExtraVariables(sources, suppressLogging = false) {
2487
2683
  const path = source.path;
2488
2684
  const resolvedPath = path.replace("$HOME", homedir());
2489
2685
  const varName = `sources_${(source.name ?? basename(path, extname(resolvedPath))).replace(/\s+/g, "_")}`;
2490
- if (!suppressLogging) consola.info("Extra variable:", varName, resolvedPath);
2686
+ if (!suppressLogging) consola.debug("Extra variable:", varName, resolvedPath);
2491
2687
  return new ExtraVariable(varName, `'${resolvedPath}'`);
2492
2688
  });
2493
2689
  }
@@ -2597,7 +2793,6 @@ async function writeGeneratedFile(projectDir, gen, generator, file, queries, tab
2597
2793
  }
2598
2794
  const outputPath = getOutputPath(projectDir, name, gen, generator);
2599
2795
  writeFileSync(outputPath, sourceFile);
2600
- consola.success(`Generated ${outputPath}`);
2601
2796
  await generator.afterGenerate(outputPath);
2602
2797
  return outputPath;
2603
2798
  }
@@ -2671,12 +2866,24 @@ async function validateProject(projectPath) {
2671
2866
  }
2672
2867
  return await validateProjectFromConfig(project, projectDir);
2673
2868
  }
2869
+ /** Count enum types across queries */
2870
+ function countEnums(queries) {
2871
+ const enumNames = /* @__PURE__ */ new Set();
2872
+ for (const query of queries) {
2873
+ if (!query.isQuery) continue;
2874
+ for (const col of query.columns) if (col.type instanceof EnumType && col.type.name) enumNames.add(col.type.name);
2875
+ }
2876
+ return enumNames.size;
2877
+ }
2674
2878
  /**
2675
2879
  * Process a project configuration and generate code from a Project object
2676
2880
  */
2677
- async function processProjectFromConfig(project, projectDir, writeToStdout = false) {
2881
+ async function processProjectFromConfig(project, projectDir, writeToStdout = false, ui) {
2678
2882
  const originalLevel = consola.level;
2679
2883
  if (writeToStdout) consola.level = LogLevels.silent;
2884
+ const totalStart = performance.now();
2885
+ const reporter = ui?.createReporter();
2886
+ const results = [];
2680
2887
  try {
2681
2888
  const extraVariables = createExtraVariables(project.sources ?? [], writeToStdout);
2682
2889
  const files = [];
@@ -2701,31 +2908,52 @@ async function processProjectFromConfig(project, projectDir, writeToStdout = fal
2701
2908
  throw SqgError.inFile(`Failed to parse SQL file: ${e.message}`, "SQL_PARSE_ERROR", sqlFile, { suggestion: "Check SQL syntax and annotation format" });
2702
2909
  }
2703
2910
  for (const [engine, gens] of gensByEngine) {
2911
+ const executableQueries = queries.filter((q) => !q.skipGenerateFunction);
2912
+ const reporterAny = reporter;
2913
+ if (reporterAny?.setQueryTotal) reporterAny.setQueryTotal(executableQueries.length);
2704
2914
  try {
2705
2915
  const dbEngine = getDatabaseEngine(engine);
2706
- await dbEngine.initializeDatabase(queries);
2707
- await dbEngine.executeQueries(queries);
2708
- if (tables.length > 0) await dbEngine.introspectTables(tables);
2916
+ ui?.startPhase(`Initializing ${engine} database...`);
2917
+ await dbEngine.initializeDatabase(queries, reporter);
2918
+ ui?.startPhase(`Introspecting ${executableQueries.length} queries...`);
2919
+ await dbEngine.executeQueries(queries, reporter);
2920
+ if (tables.length > 0) await dbEngine.introspectTables(tables, reporter);
2921
+ ui?.succeedPhase(`Introspected ${executableQueries.length} queries`);
2709
2922
  validateQueries(queries);
2710
2923
  await dbEngine.close();
2711
2924
  } catch (e) {
2925
+ ui?.failPhase(`Failed to process ${sqlFile}`);
2712
2926
  if (e instanceof SqgError) throw e;
2713
2927
  throw new SqgError(`Database error processing ${sqlFile}: ${e.message}`, "DATABASE_ERROR", `Check that the SQL is valid for engine '${engine}'`, {
2714
2928
  file: sqlFile,
2715
2929
  engine
2716
2930
  });
2717
2931
  }
2932
+ ui?.startPhase("Generating code...");
2933
+ const genStart = performance.now();
2718
2934
  for (const gen of gens) {
2719
2935
  const generator = getGenerator(gen.generator);
2720
2936
  const outputPath = await writeGeneratedFile(projectDir, {
2721
2937
  ...gen,
2722
2938
  projectName: project.name
2723
2939
  }, generator, sqlFile, queries, tables, engine, writeToStdout);
2724
- if (outputPath !== null) files.push(outputPath);
2940
+ if (outputPath !== null) {
2941
+ files.push(outputPath);
2942
+ results.push({
2943
+ outputPath,
2944
+ queryCount: executableQueries.length,
2945
+ enumCount: countEnums(queries),
2946
+ sqlFile,
2947
+ generator: gen.generator,
2948
+ elapsedMs: performance.now() - genStart
2949
+ });
2950
+ }
2725
2951
  }
2952
+ ui?.succeedPhase(`Generated ${gens.length} ${gens.length === 1 ? "file" : "files"} from ${sqlFile}`);
2726
2953
  }
2727
2954
  }
2728
2955
  }
2956
+ ui?.summary(results, performance.now() - totalStart);
2729
2957
  return files;
2730
2958
  } finally {
2731
2959
  if (writeToStdout) consola.level = originalLevel;
@@ -2734,16 +2962,16 @@ async function processProjectFromConfig(project, projectDir, writeToStdout = fal
2734
2962
  /**
2735
2963
  * Process a project configuration and generate code from a YAML file
2736
2964
  */
2737
- async function processProject(projectPath) {
2965
+ async function processProject(projectPath, ui) {
2738
2966
  const projectDir = resolve(dirname(projectPath));
2739
- return await processProjectFromConfig(parseProjectConfig(projectPath), projectDir, false);
2967
+ return await processProjectFromConfig(parseProjectConfig(projectPath), projectDir, false, ui);
2740
2968
  }
2741
2969
 
2742
2970
  //#endregion
2743
2971
  //#region src/mcp-server.ts
2744
2972
  const server = new Server({
2745
2973
  name: "sqg-mcp",
2746
- version: process.env.npm_package_version ?? "0.11.0"
2974
+ version: process.env.npm_package_version ?? "0.13.0"
2747
2975
  }, { capabilities: {
2748
2976
  tools: {},
2749
2977
  resources: {}
@@ -3070,11 +3298,154 @@ async function startMcpServer() {
3070
3298
  console.error("SQG MCP server running on stdio");
3071
3299
  }
3072
3300
 
3301
+ //#endregion
3302
+ //#region src/ui.ts
3303
+ /**
3304
+ * SQG UI Module - Centralized terminal output with spinners, timing, and beautiful errors
3305
+ */
3306
+ var UI = class {
3307
+ spinner = null;
3308
+ silent;
3309
+ verbose;
3310
+ phaseStart = 0;
3311
+ version;
3312
+ constructor(options) {
3313
+ this.silent = options.format === "json" || options.isStdout === true;
3314
+ this.verbose = options.verbose === true;
3315
+ this.version = options.version || "";
3316
+ }
3317
+ /** Print colored header */
3318
+ header() {
3319
+ if (this.silent) return;
3320
+ const logo = pc.bold(pc.blue("SQG"));
3321
+ const ver = this.version ? ` ${pc.dim(`v${this.version}`)}` : "";
3322
+ this.log(`\n ${logo}${ver}\n`);
3323
+ }
3324
+ /** Create a ProgressReporter for DB adapters */
3325
+ createReporter() {
3326
+ if (this.silent) return {};
3327
+ let queryCount = 0;
3328
+ let queryTotal = 0;
3329
+ return {
3330
+ onQueryStart: (id) => {
3331
+ queryCount++;
3332
+ if (this.verbose) this.log(` ${pc.dim(`Executing query: ${id}`)}`);
3333
+ else if (this.spinner) this.spinner.text = `Introspecting queries... (${queryCount}/${queryTotal || "?"})`;
3334
+ },
3335
+ onQueryComplete: (id) => {
3336
+ if (this.verbose) this.log(` ${pc.green("+")} ${pc.dim(id)}`);
3337
+ },
3338
+ onTableStart: (name) => {
3339
+ if (this.verbose) this.log(` ${pc.dim(`Introspecting table: ${name}`)}`);
3340
+ },
3341
+ onTableComplete: (name, columnCount) => {
3342
+ if (this.verbose) this.log(` ${pc.green("+")} ${pc.dim(`${name} (${columnCount} columns)`)}`);
3343
+ },
3344
+ onContainerStarting: () => {
3345
+ this.startPhase("Starting PostgreSQL container...");
3346
+ },
3347
+ onContainerStarted: (_uri) => {
3348
+ this.succeedPhase("PostgreSQL ready");
3349
+ },
3350
+ onDatabaseInitialized: () => {
3351
+ if (this.verbose) this.log(` ${pc.green("+")} ${pc.dim("Database initialized")}`);
3352
+ },
3353
+ setQueryTotal: (total) => {
3354
+ queryTotal = total;
3355
+ queryCount = 0;
3356
+ }
3357
+ };
3358
+ }
3359
+ /** Start a phase with a spinner */
3360
+ startPhase(label) {
3361
+ if (this.silent) return;
3362
+ this.stopSpinner();
3363
+ this.phaseStart = performance.now();
3364
+ this.spinner = yoctoSpinner({ text: label }).start();
3365
+ }
3366
+ /** Complete a phase successfully */
3367
+ succeedPhase(label) {
3368
+ if (this.silent) return;
3369
+ const elapsed = this.phaseStart ? performance.now() - this.phaseStart : 0;
3370
+ const time = elapsed > 100 ? pc.dim(` (${formatMs(elapsed)})`) : "";
3371
+ if (this.spinner) {
3372
+ this.spinner.success(`${label}${time}`);
3373
+ this.spinner = null;
3374
+ } else this.log(`${pc.green("+")} ${label}${time}`);
3375
+ }
3376
+ /** Fail a phase */
3377
+ failPhase(label) {
3378
+ if (this.silent) return;
3379
+ if (this.spinner) {
3380
+ this.spinner.error(label);
3381
+ this.spinner = null;
3382
+ } else this.log(`${pc.red("x")} ${label}`);
3383
+ }
3384
+ /** Display generation summary */
3385
+ summary(results, totalMs) {
3386
+ if (this.silent || results.length === 0) return;
3387
+ this.log("");
3388
+ for (const r of results) {
3389
+ const parts = [];
3390
+ if (r.queryCount > 0) parts.push(`${r.queryCount} ${r.queryCount === 1 ? "query" : "queries"}`);
3391
+ if (r.enumCount > 0) parts.push(`${r.enumCount} ${r.enumCount === 1 ? "enum" : "enums"}`);
3392
+ this.log(` ${pc.dim("->")} ${dimPath(r.outputPath)} ${pc.dim(`(${parts.join(", ")})`)}`);
3393
+ }
3394
+ this.log("");
3395
+ this.log(` ${pc.green("done")} ${pc.dim(`in ${formatMs(totalMs)}`)}`);
3396
+ }
3397
+ /** Display a formatted error */
3398
+ error(err) {
3399
+ if (this.silent) return;
3400
+ this.stopSpinner();
3401
+ const isSqg = "code" in err && "suggestion" in err;
3402
+ const message = err.message;
3403
+ const suggestion = isSqg ? err.suggestion : void 0;
3404
+ const code = isSqg ? err.code : void 0;
3405
+ this.log("");
3406
+ this.log(` ${pc.red(pc.bold("ERROR"))} ${message}`);
3407
+ if (suggestion) {
3408
+ this.log("");
3409
+ this.log(` ${pc.dim("Suggestion:")} ${suggestion}`);
3410
+ }
3411
+ if (code && this.verbose) this.log(` ${pc.dim("Code:")} ${code}`);
3412
+ this.log("");
3413
+ }
3414
+ stopSpinner() {
3415
+ if (this.spinner) {
3416
+ this.spinner.stop();
3417
+ this.spinner = null;
3418
+ }
3419
+ }
3420
+ log(msg) {
3421
+ process.stderr.write(`${msg}\n`);
3422
+ }
3423
+ };
3424
+ /** Format path with dim directory and bright filename, relative to cwd when it's a subpath */
3425
+ function dimPath(fullPath) {
3426
+ const rel = relative(process.cwd(), fullPath);
3427
+ const display = rel && !rel.startsWith("..") ? rel : fullPath;
3428
+ const dir = dirname(display);
3429
+ const file = basename(display);
3430
+ if (dir === ".") return file;
3431
+ return `${pc.dim(`${dir}/`)}${file}`;
3432
+ }
3433
+ /** Format milliseconds as human-readable */
3434
+ function formatMs(ms) {
3435
+ if (ms < 1e3) return `${Math.round(ms)}ms`;
3436
+ return `${(ms / 1e3).toFixed(1)}s`;
3437
+ }
3438
+
3073
3439
  //#endregion
3074
3440
  //#region src/sqg.ts
3075
- const version = process.env.npm_package_version ?? "0.11.0";
3441
+ const version = process.env.npm_package_version ?? "0.13.0";
3442
+ updateNotifier({ pkg: {
3443
+ name: "@sqg/sqg",
3444
+ version
3445
+ } }).notify({ message: "Update available {currentVersion} → {latestVersion}" });
3076
3446
  const description = process.env.npm_package_description ?? "SQG - SQL Query Generator - Type-safe code generation from SQL (https://sqg.dev)";
3077
- consola.level = LogLevels.info;
3447
+ consola.level = LogLevels.warn;
3448
+ const BRANDING = `\n ${pc.bold(pc.blue("SQG"))} ${pc.dim(`v${version}`)}\n`;
3078
3449
  const program = new Command().name("sqg").description(`${description}
3079
3450
 
3080
3451
  Generate type-safe database access code from annotated SQL files.
@@ -3083,12 +3454,20 @@ Supported Generators:
3083
3454
  ${formatGeneratorsHelp()}`).version(version, "-v, --version", "output the version number").option("--verbose", "Enable debug logging (shows SQL execution details)").option("--format <format>", "Output format: text (default) or json", "text").option("--validate", "Validate configuration without generating code").option("--generator <generator>", `Code generation generator (${SHORT_GENERATOR_NAMES.join(", ")})`).option("--file <file>", "SQL file path (can be repeated)", (val, prev = []) => {
3084
3455
  prev.push(val);
3085
3456
  return prev;
3086
- }).option("--output <path>", "Output file or directory path (optional, if omitted writes to stdout)").option("--name <name>", "Project name (optional, defaults to 'generated')").showHelpAfterError().showSuggestionAfterError();
3457
+ }).option("--output <path>", "Output file or directory path (optional, if omitted writes to stdout)").option("--name <name>", "Project name (optional, defaults to 'generated')").addHelpText("before", BRANDING).showHelpAfterError().showSuggestionAfterError();
3087
3458
  program.argument("[project]", "Path to the project YAML config (sqg.yaml) or omit to use CLI options").hook("preAction", (thisCommand) => {
3088
3459
  const opts = thisCommand.opts();
3089
3460
  if (opts.verbose) consola.level = LogLevels.debug;
3090
3461
  if (opts.format === "json") consola.level = LogLevels.silent;
3091
3462
  }).action(async (projectPath, options) => {
3463
+ const writeToStdout = !projectPath && !options.output;
3464
+ const ui = new UI({
3465
+ format: options.format,
3466
+ verbose: options.verbose,
3467
+ isStdout: writeToStdout,
3468
+ version
3469
+ });
3470
+ ui.header();
3092
3471
  try {
3093
3472
  if (!projectPath) {
3094
3473
  if (!options.generator) throw new SqgError("Missing required option: --generator", "CONFIG_VALIDATION_ERROR", `Specify a code generation generator: ${SHORT_GENERATOR_NAMES.join(", ")}`);
@@ -3100,7 +3479,6 @@ program.argument("[project]", "Path to the project YAML config (sqg.yaml) or omi
3100
3479
  name: options.name
3101
3480
  });
3102
3481
  const projectDir = process.cwd();
3103
- const writeToStdout = !options.output;
3104
3482
  if (options.validate) {
3105
3483
  const result = await validateProjectFromConfig(project, projectDir);
3106
3484
  if (options.format === "json") console.log(JSON.stringify(result, null, 2));
@@ -3118,7 +3496,7 @@ program.argument("[project]", "Path to the project YAML config (sqg.yaml) or omi
3118
3496
  }
3119
3497
  exit(result.valid ? 0 : 1);
3120
3498
  }
3121
- const files = await processProjectFromConfig(project, projectDir, writeToStdout);
3499
+ const files = await processProjectFromConfig(project, projectDir, writeToStdout, ui);
3122
3500
  if (options.format === "json" && !writeToStdout) console.log(JSON.stringify({
3123
3501
  status: "success",
3124
3502
  generatedFiles: files
@@ -3141,7 +3519,7 @@ program.argument("[project]", "Path to the project YAML config (sqg.yaml) or omi
3141
3519
  }
3142
3520
  exit(result.valid ? 0 : 1);
3143
3521
  }
3144
- const files = await processProject(projectPath);
3522
+ const files = await processProject(projectPath, ui);
3145
3523
  if (options.format === "json") console.log(JSON.stringify({
3146
3524
  status: "success",
3147
3525
  generatedFiles: files
@@ -3149,15 +3527,13 @@ program.argument("[project]", "Path to the project YAML config (sqg.yaml) or omi
3149
3527
  }
3150
3528
  } catch (err) {
3151
3529
  if (options.format === "json") console.log(JSON.stringify(formatErrorForOutput(err)));
3152
- else if (err instanceof SqgError) {
3153
- consola.error(err.message);
3154
- if (err.suggestion) consola.info(`Suggestion: ${err.suggestion}`);
3155
- if (err.context && options.verbose) consola.debug("Context:", err.context);
3156
- } else consola.error(err);
3530
+ else if (err instanceof SqgError) ui.error(err);
3531
+ else if (err instanceof Error) ui.error(err);
3532
+ else consola.error(err);
3157
3533
  exit(1);
3158
3534
  }
3159
3535
  });
3160
- program.command("init").description("Initialize a new SQG project with example configuration").option("-t, --generator <generator>", `Code generation generator (${SHORT_GENERATOR_NAMES.join(", ")})`, "typescript/sqlite").option("-o, --output <dir>", "Output directory for generated files", "./generated").option("-f, --force", "Overwrite existing files").action(async (options) => {
3536
+ program.command("init").description("Initialize a new SQG project with example configuration").option("-t, --generator <generator>", `Code generation generator (${SHORT_GENERATOR_NAMES.join(", ")}). Omit for interactive mode`).option("-o, --output <dir>", "Output directory for generated files", "./generated").option("-f, --force", "Overwrite existing files").action(async (options) => {
3161
3537
  const parentOpts = program.opts();
3162
3538
  try {
3163
3539
  await initProject(options);
@@ -3167,10 +3543,15 @@ program.command("init").description("Initialize a new SQG project with example c
3167
3543
  }));
3168
3544
  } catch (err) {
3169
3545
  if (parentOpts.format === "json") console.log(JSON.stringify(formatErrorForOutput(err)));
3170
- else if (err instanceof SqgError) {
3171
- consola.error(err.message);
3172
- if (err.suggestion) consola.info(`Suggestion: ${err.suggestion}`);
3173
- } else consola.error(err);
3546
+ else {
3547
+ const ui = new UI({
3548
+ format: parentOpts.format,
3549
+ verbose: parentOpts.verbose
3550
+ });
3551
+ if (err instanceof SqgError) ui.error(err);
3552
+ else if (err instanceof Error) ui.error(err);
3553
+ else consola.error(err);
3554
+ }
3174
3555
  exit(1);
3175
3556
  }
3176
3557
  });
@@ -201,6 +201,8 @@ public class {{className}} {
201
201
  }
202
202
  {{/if}}
203
203
 
204
+ {{{declareEnums queries}}}
205
+
204
206
  {{#each queries}}
205
207
  {{#unless skipGenerateFunction}}
206
208
  {{>columnTypesRecord}}
@@ -213,6 +215,7 @@ public class {{className}} {
213
215
  public Stream<{{rowType}}> {{functionName}}Stream({{#each variables}}{{{type}}} {{name}}{{#unless @last}}, {{/unless}}{{/each}}) throws SQLException {
214
216
  var stmt = connection.prepareStatement({{{partsToString sqlQueryParts}}});
215
217
  {{#each parameters}}{{#if isArray}}stmt.setArray({{plusOne @index}}, connection.createArrayOf("{{arrayBaseType}}", {{name}}.toArray()));
218
+ {{else if isEnum}}stmt.setObject({{plusOne @index}}, {{name}}.getValue());
216
219
  {{else}}stmt.setObject({{plusOne @index}}, {{name}});
217
220
  {{/if}}{{/each}}
218
221
  var rs = stmt.executeQuery();
@@ -329,6 +332,7 @@ int
329
332
 
330
333
  {{#*inline "execute"}}
331
334
  {{#each parameters}}{{#if isArray}}stmt.setArray({{plusOne @index}}, connection.createArrayOf("{{arrayBaseType}}", {{name}}.toArray()));
335
+ {{else if isEnum}}stmt.setObject({{plusOne @index}}, {{name}}.getValue());
332
336
  {{else}}stmt.setObject({{plusOne @index}}, {{name}});
333
337
  {{/if}}{{/each}}
334
338
  {{#if isQuery}}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sqg/sqg",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "SQG - SQL Query Generator - Type-safe code generation from SQL (https://sqg.dev)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,12 +39,15 @@
39
39
  "license": "Apache-2.0",
40
40
  "dependencies": {
41
41
  "@biomejs/biome": "2.3.3",
42
+ "@clack/prompts": "^1.1.0",
42
43
  "@duckdb/node-api": "1.4.3-r.2",
43
44
  "@duckdb/node-bindings-linux-x64": "1.4.3-r.2",
44
45
  "@lezer-unofficial/printer": "^1.0.1",
45
46
  "@lezer/common": "^1.5.0",
46
47
  "@lezer/generator": "^1.8.0",
47
48
  "@lezer/lr": "^1.4.6",
49
+ "@modelcontextprotocol/sdk": "^1.0.4",
50
+ "@testcontainers/postgresql": "^10.21.0",
48
51
  "better-sqlite3": "^12.5.0",
49
52
  "commander": "^14.0.2",
50
53
  "consola": "^3.4.2",
@@ -53,12 +56,13 @@
53
56
  "handlebars": "^4.7.8",
54
57
  "pg": "^8.16.3",
55
58
  "pg-types": "^4.1.0",
56
- "@testcontainers/postgresql": "^10.21.0",
59
+ "picocolors": "^1.1.1",
57
60
  "prettier": "^3.7.4",
58
61
  "prettier-plugin-java": "^2.7.7",
62
+ "update-notifier": "^7.3.1",
59
63
  "yaml": "^2.8.2",
60
- "zod": "^4.3.5",
61
- "@modelcontextprotocol/sdk": "^1.0.4"
64
+ "yocto-spinner": "^1.1.0",
65
+ "zod": "^4.3.5"
62
66
  },
63
67
  "devDependencies": {
64
68
  "@libsql/client": "^0.17.0",
@@ -66,6 +70,7 @@
66
70
  "@types/better-sqlite3": "^7.6.13",
67
71
  "@types/node": "^25.0.3",
68
72
  "@types/pg": "^8.16.0",
73
+ "@types/update-notifier": "^6.0.8",
69
74
  "@vitest/ui": "^4.0.16",
70
75
  "tsdown": "0.18.0",
71
76
  "tsx": "^4.21.0",