@barishnamazov/gsql 0.1.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.
@@ -0,0 +1,547 @@
1
+ /**
2
+ * GSQL Code Generator
3
+ *
4
+ * Transforms the AST into PostgreSQL-compatible SQL.
5
+ * Handles concept instantiation, mixin resolution, and template expansion.
6
+ */
7
+
8
+ import type {
9
+ GSQLProgram,
10
+ TopLevelDeclaration,
11
+ ExtensionDecl,
12
+ FunctionDecl,
13
+ SchemaDecl,
14
+ ConceptDecl,
15
+ EnumDecl,
16
+ Instantiation,
17
+ PerInstanceIndex,
18
+ SchemaBodyItem,
19
+ ColumnDef,
20
+ IndexDef,
21
+ CheckDef,
22
+ TriggerDef,
23
+ } from "./types.ts";
24
+
25
+ // ============================================================================
26
+ // Utility Functions
27
+ // ============================================================================
28
+
29
+ /**
30
+ * Convert PascalCase or camelCase to snake_case
31
+ *
32
+ * Examples:
33
+ * - "MyTarget" -> "my_target"
34
+ * - "Author" -> "author"
35
+ * - "UserID" -> "user_id"
36
+ * - "HTTPServer" -> "http_server"
37
+ * - "myVariableName" -> "my_variable_name"
38
+ *
39
+ * Handles edge cases:
40
+ * - Consecutive uppercase letters: splits before lowercase (HTTPServer -> http_server)
41
+ * - Mixed case: inserts underscores between transitions (myVarName -> my_var_name)
42
+ */
43
+ function toSnakeCase(str: string): string {
44
+ return str
45
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") // Handle consecutive caps (HTTPServer -> HTTP_Server)
46
+ .replace(/([a-z\d])([A-Z])/g, "$1_$2") // Handle camelCase transitions (myVar -> my_Var)
47
+ .toLowerCase();
48
+ }
49
+
50
+ // ============================================================================
51
+ // Generator Context
52
+ // ============================================================================
53
+
54
+ interface GeneratorContext {
55
+ /** Map of concept names to their definitions */
56
+ concepts: Map<string, ConceptDecl>;
57
+
58
+ /** Map of schema names to their definitions (for mixins) */
59
+ schemas: Map<string, SchemaDecl>;
60
+
61
+ /** Map of enum names to their definitions */
62
+ enums: Map<string, EnumDecl>;
63
+
64
+ /** Map of table names to their resolved schema names (for references) */
65
+ tableToSchema: Map<string, string>;
66
+
67
+ /** Template substitutions for current instantiation */
68
+ templateSubs: Map<string, string>;
69
+
70
+ /** Generated enum SQL */
71
+ enumSql: string[];
72
+
73
+ /** Generated table SQL */
74
+ tableSql: string[];
75
+
76
+ /** Generated index SQL */
77
+ indexSql: string[];
78
+
79
+ /** Generated trigger SQL */
80
+ triggerSql: string[];
81
+
82
+ /** Extension SQL */
83
+ extensionSql: string[];
84
+
85
+ /** Function SQL */
86
+ functionSql: string[];
87
+
88
+ /** Per-instance index SQL (added at the end) */
89
+ perInstanceIndexSql: string[];
90
+
91
+ /** Track which enums have been generated */
92
+ generatedEnums: Set<string>;
93
+
94
+ /** Track which tables have been generated */
95
+ generatedTables: Set<string>;
96
+ }
97
+
98
+ function createContext(): GeneratorContext {
99
+ return {
100
+ concepts: new Map(),
101
+ schemas: new Map(),
102
+ enums: new Map(),
103
+ tableToSchema: new Map(),
104
+ templateSubs: new Map(),
105
+ enumSql: [],
106
+ tableSql: [],
107
+ indexSql: [],
108
+ triggerSql: [],
109
+ extensionSql: [],
110
+ functionSql: [],
111
+ perInstanceIndexSql: [],
112
+ generatedEnums: new Set(),
113
+ generatedTables: new Set(),
114
+ };
115
+ }
116
+
117
+ // ============================================================================
118
+ // First Pass: Collect Definitions
119
+ // ============================================================================
120
+
121
+ function collectDefinitions(ast: GSQLProgram, ctx: GeneratorContext): void {
122
+ for (const decl of ast.declarations) {
123
+ switch (decl.type) {
124
+ case "ConceptDecl":
125
+ ctx.concepts.set(decl.name, decl);
126
+ break;
127
+ case "SchemaDecl":
128
+ ctx.schemas.set(decl.name, decl);
129
+ break;
130
+ case "EnumDecl":
131
+ ctx.enums.set(decl.name, decl);
132
+ break;
133
+ }
134
+ }
135
+ }
136
+
137
+ // ============================================================================
138
+ // Template Expansion
139
+ // ============================================================================
140
+
141
+ function expandTemplate(name: string, ctx: GeneratorContext): string {
142
+ const templateMatch = name.match(/^\{([^}]+)\}(.*)$/);
143
+ if (templateMatch) {
144
+ const [, param, suffix] = templateMatch;
145
+ let replacement: string | undefined;
146
+ for (const [key, value] of ctx.templateSubs) {
147
+ if (key === param) {
148
+ replacement = value;
149
+ break;
150
+ }
151
+ }
152
+ return (replacement ?? toSnakeCase(param ?? "")) + (suffix ?? "");
153
+ }
154
+ return name;
155
+ }
156
+
157
+ /**
158
+ * Escape special regex characters in a string
159
+ */
160
+ function escapeRegExp(str: string): string {
161
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
162
+ }
163
+
164
+ function expandCheckExpression(expr: string, ctx: GeneratorContext): string {
165
+ let result = expr;
166
+
167
+ for (const [param, value] of ctx.templateSubs) {
168
+ const escapedParam = escapeRegExp(param);
169
+ const pattern = new RegExp(`\\{${escapedParam}\\}`, "g");
170
+ result = result.replace(pattern, value);
171
+ }
172
+
173
+ result = result.replace(/(\w+)::(\w+)/g, "'$2'::$1");
174
+
175
+ return result;
176
+ }
177
+
178
+ // ============================================================================
179
+ // SQL Generation Helpers
180
+ // ============================================================================
181
+
182
+ function generateEnumSql(enumDecl: EnumDecl, ctx: GeneratorContext): void {
183
+ const name = enumDecl.name;
184
+
185
+ if (ctx.generatedEnums.has(name)) return;
186
+ ctx.generatedEnums.add(name);
187
+
188
+ const values = enumDecl.values.map((v) => `'${v}'`).join(", ");
189
+ ctx.enumSql.push(`DO $$ BEGIN
190
+ CREATE TYPE ${name} AS ENUM (${values});
191
+ EXCEPTION
192
+ WHEN duplicate_object THEN null;
193
+ END $$;`);
194
+ }
195
+
196
+ function generateColumnSql(col: ColumnDef, ctx: GeneratorContext): string {
197
+ const name = expandTemplate(col.name, ctx);
198
+ const dataType = col.dataType.toUpperCase();
199
+
200
+ const parts: string[] = [` ${name} ${dataType}`];
201
+ const postConstraints: string[] = [];
202
+
203
+ for (const constraint of col.constraints) {
204
+ switch (constraint.type) {
205
+ case "PrimaryKey":
206
+ parts.push("PRIMARY KEY");
207
+ break;
208
+ case "NotNull":
209
+ parts.push("NOT NULL");
210
+ break;
211
+ case "Unique":
212
+ parts.push("UNIQUE");
213
+ break;
214
+ case "Default":
215
+ parts.push(`DEFAULT ${constraint.value ?? "NULL"}`);
216
+ break;
217
+ case "Reference": {
218
+ let tableName = constraint.table ?? "";
219
+ if (ctx.tableToSchema.has(tableName)) {
220
+ tableName = ctx.tableToSchema.get(tableName) ?? tableName;
221
+ }
222
+ postConstraints.push(`REFERENCES ${tableName}(${constraint.column ?? "id"})`);
223
+ break;
224
+ }
225
+ case "Check":
226
+ parts.push(`CHECK (${expandCheckExpression(constraint.value ?? "", ctx)})`);
227
+ break;
228
+ case "OnDelete":
229
+ postConstraints.push(`ON DELETE ${constraint.action ?? "NO ACTION"}`);
230
+ break;
231
+ }
232
+ }
233
+
234
+ return [...parts, ...postConstraints].join(" ");
235
+ }
236
+
237
+ function generateIndexSql(tableName: string, index: IndexDef, ctx: GeneratorContext): string {
238
+ const columns = index.columns.map((c) => expandTemplate(c, ctx)).join(", ");
239
+ const columnNames = index.columns.map((c) => expandTemplate(c, ctx)).join("_");
240
+ const indexName = `idx_${tableName}_${columnNames}`;
241
+ const unique = index.unique === true ? "UNIQUE " : "";
242
+ const using = index.using ? `USING ${index.using} ` : "";
243
+
244
+ return `CREATE ${unique}INDEX ${indexName} ON ${tableName} ${using}(${columns});`;
245
+ }
246
+
247
+ function generateCheckSql(_tableName: string, check: CheckDef, ctx: GeneratorContext): string {
248
+ const expr = expandCheckExpression(check.expression, ctx);
249
+ return ` CHECK (${expr})`;
250
+ }
251
+
252
+ function generateTriggerSql(
253
+ tableName: string,
254
+ trigger: TriggerDef,
255
+ _ctx: GeneratorContext
256
+ ): string {
257
+ const timing = trigger.timing.toUpperCase();
258
+ const event = trigger.event.toUpperCase();
259
+ const forEach = trigger.forEach === "row" ? "FOR EACH ROW" : "FOR EACH STATEMENT";
260
+
261
+ return `CREATE TRIGGER ${trigger.name}_${tableName}
262
+ ${timing} ${event} ON ${tableName}
263
+ ${forEach} EXECUTE FUNCTION ${trigger.executeFunction}();`;
264
+ }
265
+
266
+ // ============================================================================
267
+ // Schema Resolution
268
+ // ============================================================================
269
+
270
+ interface ResolvedSchema {
271
+ tableName: string;
272
+ columns: ColumnDef[];
273
+ indexes: IndexDef[];
274
+ checks: CheckDef[];
275
+ triggers: TriggerDef[];
276
+ }
277
+
278
+ function resolveMixins(schema: SchemaDecl, ctx: GeneratorContext): SchemaBodyItem[] {
279
+ const items: SchemaBodyItem[] = [];
280
+
281
+ for (const mixinName of schema.mixins) {
282
+ const mixin = ctx.schemas.get(mixinName);
283
+ if (mixin) {
284
+ items.push(...resolveMixins(mixin, ctx));
285
+ }
286
+ }
287
+
288
+ items.push(...schema.members);
289
+
290
+ return items;
291
+ }
292
+
293
+ function resolveSchema(
294
+ schema: SchemaDecl,
295
+ tableName: string,
296
+ ctx: GeneratorContext
297
+ ): ResolvedSchema {
298
+ const allItems = resolveMixins(schema, ctx);
299
+
300
+ const columns: ColumnDef[] = [];
301
+ const indexes: IndexDef[] = [];
302
+ const checks: CheckDef[] = [];
303
+ const triggers: TriggerDef[] = [];
304
+
305
+ for (const item of allItems) {
306
+ switch (item.type) {
307
+ case "ColumnDef":
308
+ columns.push(item);
309
+ break;
310
+ case "IndexDef":
311
+ indexes.push(item);
312
+ break;
313
+ case "CheckDef":
314
+ checks.push(item);
315
+ break;
316
+ case "TriggerDef":
317
+ triggers.push(item);
318
+ break;
319
+ }
320
+ }
321
+
322
+ return { tableName, columns, indexes, checks, triggers };
323
+ }
324
+
325
+ // ============================================================================
326
+ // Table Generation
327
+ // ============================================================================
328
+
329
+ function generateTableSql(resolved: ResolvedSchema, ctx: GeneratorContext): void {
330
+ if (ctx.generatedTables.has(resolved.tableName)) return;
331
+ ctx.generatedTables.add(resolved.tableName);
332
+
333
+ const columnsSql = resolved.columns.map((c) => generateColumnSql(c, ctx));
334
+ const checksSql = resolved.checks.map((c) => generateCheckSql(resolved.tableName, c, ctx));
335
+
336
+ const allColumnLines = [...columnsSql, ...checksSql];
337
+ const tableBody = allColumnLines.join(",\n");
338
+
339
+ ctx.tableSql.push(`CREATE TABLE ${resolved.tableName} (\n${tableBody}\n);`);
340
+
341
+ for (const index of resolved.indexes) {
342
+ ctx.indexSql.push(generateIndexSql(resolved.tableName, index, ctx));
343
+ }
344
+
345
+ for (const trigger of resolved.triggers) {
346
+ ctx.triggerSql.push(generateTriggerSql(resolved.tableName, trigger, ctx));
347
+ }
348
+ }
349
+
350
+ // ============================================================================
351
+ // Declaration Processing
352
+ // ============================================================================
353
+
354
+ function processExtension(decl: ExtensionDecl, ctx: GeneratorContext): void {
355
+ ctx.extensionSql.push(`CREATE EXTENSION IF NOT EXISTS "${decl.name}";`);
356
+ }
357
+
358
+ function processFunction(decl: FunctionDecl, ctx: GeneratorContext): void {
359
+ ctx.functionSql.push(`CREATE OR REPLACE FUNCTION ${decl.name}()
360
+ RETURNS TRIGGER AS $$
361
+ BEGIN
362
+ ${decl.body}
363
+ END;
364
+ $$ LANGUAGE plpgsql;`);
365
+ }
366
+
367
+ function processEnum(decl: EnumDecl, ctx: GeneratorContext): void {
368
+ generateEnumSql(decl, ctx);
369
+ }
370
+
371
+ function processStandaloneSchema(decl: SchemaDecl, ctx: GeneratorContext): void {
372
+ // Standalone schemas are stored for mixin resolution
373
+ ctx.schemas.set(decl.name, decl);
374
+ }
375
+
376
+ function processInstantiation(decl: Instantiation, ctx: GeneratorContext): void {
377
+ const concept = ctx.concepts.get(decl.conceptName);
378
+
379
+ if (!concept) {
380
+ const schema = ctx.schemas.get(decl.conceptName);
381
+ if (schema) {
382
+ const target = decl.targets[0];
383
+ if (target) {
384
+ const tableName = target.tableName;
385
+ ctx.tableToSchema.set(schema.name, tableName);
386
+ const resolved = resolveSchema(schema, tableName, ctx);
387
+ generateTableSql(resolved, ctx);
388
+ }
389
+ }
390
+ return;
391
+ }
392
+
393
+ ctx.templateSubs.clear();
394
+ for (let i = 0; i < concept.typeParams.length; i++) {
395
+ const param = concept.typeParams[i];
396
+ const arg = decl.typeArgs[i];
397
+ if (param && arg) {
398
+ // If alias is provided, use it as-is (preserve case)
399
+ // If no alias, use snake_cased version of the parameter name
400
+ ctx.templateSubs.set(param, arg.alias ?? toSnakeCase(param));
401
+ // Also map the type parameter to the actual table name for foreign key resolution
402
+ ctx.tableToSchema.set(param, arg.tableName);
403
+ }
404
+ }
405
+
406
+ // Get the schemas in the concept in order
407
+ const conceptSchemas = concept.members.filter((m): m is SchemaDecl => m.type === "SchemaDecl");
408
+
409
+ // Map target names to schema positions
410
+ // First, create schema-to-table mapping
411
+ const schemaToTable = new Map<string, { name: string; alias?: string }>();
412
+
413
+ for (let i = 0; i < decl.targets.length && i < conceptSchemas.length; i++) {
414
+ const target = decl.targets[i];
415
+ const schema = conceptSchemas[i];
416
+ if (target && schema) {
417
+ schemaToTable.set(schema.name, { name: target.tableName, alias: target.alias });
418
+ ctx.tableToSchema.set(schema.name, target.tableName);
419
+
420
+ // Also add template substitution for self-references like {Assessments}_id
421
+ // If alias is provided, use it as-is. Otherwise, use snake_cased schema name
422
+ if (target.alias) {
423
+ ctx.templateSubs.set(schema.name, target.alias);
424
+ } else {
425
+ ctx.templateSubs.set(schema.name, toSnakeCase(schema.name));
426
+ }
427
+ }
428
+ }
429
+
430
+ // Generate enums from concept
431
+ const conceptEnums = concept.members.filter((m): m is EnumDecl => m.type === "EnumDecl");
432
+ for (const e of conceptEnums) {
433
+ generateEnumSql(e, ctx);
434
+ }
435
+
436
+ // Generate tables from concept schemas
437
+ for (const schema of conceptSchemas) {
438
+ const mapping = schemaToTable.get(schema.name);
439
+ if (mapping) {
440
+ const resolved = resolveSchema(schema, mapping.name, ctx);
441
+ generateTableSql(resolved, ctx);
442
+ }
443
+ }
444
+ }
445
+
446
+ function processPerInstanceIndex(decl: PerInstanceIndex, ctx: GeneratorContext): void {
447
+ const columns = decl.columns.join(", ");
448
+ const indexName = `idx_${decl.tableName}_${decl.columns.join("_")}`;
449
+ const unique = decl.unique === true ? "UNIQUE " : "";
450
+ const using = decl.using ? `USING ${decl.using} ` : "";
451
+
452
+ ctx.perInstanceIndexSql.push(
453
+ `CREATE ${unique}INDEX ${indexName} ON ${decl.tableName} ${using}(${columns});`
454
+ );
455
+ }
456
+
457
+ // ============================================================================
458
+ // Main Generate Function
459
+ // ============================================================================
460
+
461
+ export function generate(ast: GSQLProgram): string {
462
+ const ctx = createContext();
463
+
464
+ // First pass: collect all definitions
465
+ collectDefinitions(ast, ctx);
466
+
467
+ // Second pass: process declarations in order
468
+ for (const decl of ast.declarations) {
469
+ processDeclaration(decl, ctx);
470
+ }
471
+
472
+ // Assemble final SQL
473
+ const sections: string[] = [];
474
+
475
+ if (ctx.extensionSql.length > 0) {
476
+ sections.push(ctx.extensionSql.join("\n\n"));
477
+ }
478
+
479
+ if (ctx.functionSql.length > 0) {
480
+ sections.push(ctx.functionSql.join("\n\n"));
481
+ }
482
+
483
+ if (ctx.enumSql.length > 0) {
484
+ sections.push(ctx.enumSql.join("\n\n"));
485
+ }
486
+
487
+ // Tables with their indexes and triggers interleaved
488
+ const tableWithIndexes: string[] = [];
489
+ for (const table of ctx.tableSql) {
490
+ if (table) {
491
+ tableWithIndexes.push(table);
492
+ }
493
+
494
+ // Find matching indexes and triggers (by extracting table name)
495
+ const tableMatch = table.match(/CREATE TABLE (\w+)/);
496
+ if (tableMatch) {
497
+ const tableName = tableMatch[1] ?? "";
498
+ for (const idx of ctx.indexSql) {
499
+ if (idx.includes(` ON ${tableName} `)) {
500
+ tableWithIndexes.push(idx);
501
+ }
502
+ }
503
+ for (const trg of ctx.triggerSql) {
504
+ if (trg.includes(` ON ${tableName}`)) {
505
+ tableWithIndexes.push(trg);
506
+ }
507
+ }
508
+ }
509
+ }
510
+
511
+ if (tableWithIndexes.length > 0) {
512
+ sections.push(tableWithIndexes.join("\n\n"));
513
+ }
514
+
515
+ // Per-instance indexes at the end
516
+ if (ctx.perInstanceIndexSql.length > 0) {
517
+ sections.push(ctx.perInstanceIndexSql.join("\n\n"));
518
+ }
519
+
520
+ return "-- Generated SQL from Schema DSL\n\n" + sections.join("\n\n") + "\n";
521
+ }
522
+
523
+ function processDeclaration(decl: TopLevelDeclaration, ctx: GeneratorContext): void {
524
+ switch (decl.type) {
525
+ case "ExtensionDecl":
526
+ processExtension(decl, ctx);
527
+ break;
528
+ case "FunctionDecl":
529
+ processFunction(decl, ctx);
530
+ break;
531
+ case "EnumDecl":
532
+ processEnum(decl, ctx);
533
+ break;
534
+ case "SchemaDecl":
535
+ processStandaloneSchema(decl, ctx);
536
+ break;
537
+ case "ConceptDecl":
538
+ // Concepts are pre-collected, no processing needed here
539
+ break;
540
+ case "Instantiation":
541
+ processInstantiation(decl, ctx);
542
+ break;
543
+ case "PerInstanceIndex":
544
+ processPerInstanceIndex(decl, ctx);
545
+ break;
546
+ }
547
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * GSQL - Generic SQL Compiler
3
+ *
4
+ * A compiler for GSQL, bringing parametric polymorphism to SQL schemas.
5
+ * Compiles GSQL source to PostgreSQL-compatible SQL.
6
+ */
7
+
8
+ export { compile, compileToSQL } from "./compiler.ts";
9
+ export { parse } from "./parser.ts";
10
+ export { generate } from "./generator.ts";
11
+ export type { GSQLProgram, CompileResult, CompileError } from "./types.ts";