@barishnamazov/gsql 0.1.0 → 0.2.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/README.md CHANGED
@@ -2,15 +2,103 @@
2
2
 
3
3
  **Parametric polymorphism for SQL schemas**
4
4
 
5
- GSQL is a domain-specific language that brings the power of generics/templates to database schemas. Define common patterns once, instantiate them anywhere.
5
+ GSQL is a domain-specific language that brings the power of generics/templates to database schemas.
6
+ Define common patterns once, instantiate them freely.
6
7
 
7
- ## Features
8
+ > Read the background story:
9
+ > [Parametric Polymorphism for SQL](https://barish.me/blog/parametric-polymorphism-for-sql/)
8
10
 
11
+ ## The Problem
12
+
13
+ When building relational databases, you often need to duplicate table structures with minor
14
+ variations. For example, in a learning management system, you might need announcements for courses,
15
+ lessons, and exams—the same pattern repeated three times.
16
+
17
+ Current solutions force you to choose between:
18
+
19
+ - **Separate tables** - Violates DRY principles, leads to maintenance nightmares
20
+ - **Polymorphic associations** - Sacrifices foreign key integrity and type safety
21
+
22
+ GSQL solves this by letting you define reusable schema templates (concepts) that compile to
23
+ PostgreSQL with proper foreign key constraints.
24
+
25
+ ## Quick Example
26
+
27
+ Here is the "LMS Dilemma" (Courses, Lessons, Exams) solved with GSQL. We define an `Announcing`
28
+ pattern once and apply it to three different tables, generating strictly typed foreign keys for
29
+ each.
30
+
31
+ ```
32
+ // Define reusable patterns (Mixins)
33
+ schema Timestamps {
34
+ created_at timestamptz nonull default(NOW());
35
+ updated_at timestamptz nonull default(NOW());
36
+ }
37
+
38
+ // Define a Generic Concept
39
+ // Accepts a 'Target' type parameter to create a relationship
40
+ concept Announcing<Target> {
41
+ schema Announcements mixin Timestamps {
42
+ id serial pkey;
43
+
44
+ // Template variables: {Target}_id becomes course_id, lesson_id, etc.
45
+ {Target}_id integer nonull ref(Target.id) ondelete(cascade);
46
+
47
+ title text nonull;
48
+ body text nonull;
49
+
50
+ index({Target}_id);
51
+ }
52
+ }
53
+
54
+ // Define Concrete Schemas (in actual app these would also be concepts with generics)
55
+ schema Courses mixin Timestamps { id serial pkey; name text; }
56
+ schema Lessons mixin Timestamps { id serial pkey; topic text; }
57
+ schema Exams mixin Timestamps { id serial pkey; score int; }
58
+
59
+ // Actually create tables by instantiating the Schemas/Concepts
60
+ courses = Courses;
61
+ lessons = Lessons;
62
+ exams = Exams;
63
+
64
+ // Create specific announcement tables for each entity
65
+ course_announcements = Announcing<courses[course]>;
66
+ lesson_announcements = Announcing<lessons[lesson]>;
67
+ exam_announcements = Announcing<exams[exam]>;
68
+
69
+ // Add per-instance indexes if needed
70
+ index(course_announcements, created_at);
71
+ ```
72
+
73
+ This generates three announcement tables with proper foreign keys:
74
+
75
+ ```sql
76
+ CREATE TABLE course_announcements (
77
+ id serial PRIMARY KEY,
78
+ course_id integer NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
79
+ title text NOT NULL,
80
+ body text NOT NULL,
81
+ created_at timestamptz NOT NULL DEFAULT NOW(),
82
+ updated_at timestamptz NOT NULL DEFAULT NOW()
83
+ );
84
+ CREATE INDEX ON course_announcements (course_id);
85
+ --- ...
86
+ ```
87
+
88
+ ## Key Features
89
+
90
+ - **Schemas**: A table definition with columns, constraints, indexes, triggers
9
91
  - **Concepts**: Generic schema templates with type parameters
10
92
  - **Mixins**: Compose reusable schema fragments
11
93
  - **Template variables**: Automatic field name expansion
94
+ - **Sibling references**: Multiple schemas within one concept can reference each other
12
95
  - **Per-instance indexes**: Add indexes after instantiation
13
96
  - **Type-safe foreign keys**: Proper FK constraints for polymorphic patterns
97
+ - **PostgreSQL output**: Compiles to PostgreSQL, integrates with migration tools like Atlas
98
+
99
+ ## Try It Out
100
+
101
+ Try GSQL in your browser with the [online playground](https://gsql.barish.me).
14
102
 
15
103
  ## Installation
16
104
 
@@ -52,45 +140,6 @@ if (result.success) {
52
140
  const sql = compileToSQL(source);
53
141
  ```
54
142
 
55
- ## Quick Example
56
-
57
- ```gsql
58
- // Reusable timestamp pattern
59
- schema Timestamps {
60
- created_at timestamptz nonull default(NOW());
61
- updated_at timestamptz nonull default(NOW());
62
- }
63
-
64
- // Generic concept for announcements
65
- concept Announcing<Target> {
66
- schema Announcements mixin Timestamps {
67
- id serial pkey;
68
- {Target}_id integer nonull ref(Target.id) ondelete(cascade);
69
- message text nonull;
70
-
71
- index({Target}_id);
72
- }
73
- }
74
-
75
- // Concrete tables
76
- schema Posts {
77
- id serial pkey;
78
- title varchar(255);
79
- }
80
-
81
- posts = Posts;
82
- post_announcements = Announcing<posts[post]>;
83
-
84
- // Per-instance indexes
85
- index(post_announcements, created_at);
86
- ```
87
-
88
- This generates proper SQL with:
89
-
90
- - A `posts` table
91
- - A `post_announcements` table with a `post_id` foreign key
92
- - Proper indexes and constraints
93
-
94
143
  ## Syntax Reference
95
144
 
96
145
  ### Schemas
@@ -107,16 +156,24 @@ schema Name mixin Mixin1, Mixin2 {
107
156
  ### Concepts
108
157
 
109
158
  ```gsql
110
- concept Name<TypeParam1, TypeParam2> {
111
- enum status { active; inactive; }
159
+ concept Tagging<Target> {
160
+ schema Tags {
161
+ id serial pkey;
162
+ name text;
163
+ }
112
164
 
113
- schema Table {
114
- {TypeParam1}_id integer ref(TypeParam1.id);
165
+ schema Taggings {
166
+ {Target}_id integer ref(Target.id);
167
+ {Tags}_id integer ref(Tags.id); // sibling reference
168
+ index({Target}_id, {Tags}_id) unique;
115
169
  }
116
170
  }
117
- ```
118
171
 
119
- **Template Variables:** Use uppercase type parameter names in curly braces (e.g., `{Target}_id`, `{Author}_id`).
172
+ users = Users;
173
+ // {Target}_id becomes user_id
174
+ // {Tags}_id becomes user_tag_id
175
+ user_tags[user_tag], user_taggings = Tagging<users[user]>;
176
+ ```
120
177
 
121
178
  ### Instantiation
122
179
 
@@ -181,21 +238,44 @@ announcements = Announcing<exams[exam], authors>;
181
238
 
182
239
  ## Development
183
240
 
241
+ This is a monorepo with multiple packages:
242
+
243
+ - **`packages/gsql`** - Core library and CLI (published as `@barishnamazov/gsql`)
244
+ - **`packages/playground`** - Browser-based playground
245
+
246
+ ### Building
247
+
184
248
  ```bash
185
- # Install dependencies
186
- npm install
249
+ # Build everything
250
+ npm run build
251
+
252
+ # Build just the library
253
+ npm run build:gsql
254
+
255
+ # Build just the playground
256
+ npm run build:playground
257
+ ```
258
+
259
+ ### Testing
187
260
 
188
- # Run tests
261
+ ```bash
189
262
  npm test
263
+ ```
190
264
 
191
- # Build
192
- npm run build
265
+ ### Playground Development
193
266
 
194
- # Lint
195
- npm run lint
267
+ ```bash
268
+ npm run dev:playground
269
+ ```
270
+
271
+ After building, open `packages/playground/dist/index.html` in your browser.
272
+
273
+ ### Linting and Formatting
196
274
 
197
- # Format
275
+ ```bash
276
+ npm run lint
198
277
  npm run format
278
+ npm run typecheck:all
199
279
  ```
200
280
 
201
281
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barishnamazov/gsql",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Generic SQL - Parametric Polymorphism for SQL Schemas",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -17,11 +17,14 @@
17
17
  "test:watch": "vitest",
18
18
  "lint": "eslint .",
19
19
  "format": "prettier --write --check \"src/**/*.ts\" \"test/**/*.ts\"",
20
- "typecheck": "tsc --noEmit"
20
+ "typecheck": "tsc --noEmit",
21
+ "postversion": "git push && git push --tags"
21
22
  },
22
23
  "devDependencies": {
23
24
  "@types/node": "^20.0.0",
25
+ "@types/pg": "^8.11.10",
24
26
  "esbuild": "^0.25.12",
27
+ "pg": "^8.16.3",
25
28
  "typescript": "^5.7.2",
26
29
  "vitest": "^2.1.8"
27
30
  },
@@ -39,7 +42,7 @@
39
42
  "license": "MIT",
40
43
  "repository": {
41
44
  "type": "git",
42
- "url": "https://github.com/BarishNamazov/gsql-dev",
45
+ "url": "git+https://github.com/BarishNamazov/gsql.git",
43
46
  "directory": "packages/gsql"
44
47
  },
45
48
  "files": [
package/src/cli.ts CHANGED
@@ -180,7 +180,7 @@ function main(): void {
180
180
  if (!result.success) {
181
181
  for (const error of result.errors) {
182
182
  console.error(
183
- formatError(error.message, error.location?.start.line, error.location?.start.column)
183
+ formatError(error.message, error.location?.start.line, error.location?.start.column),
184
184
  );
185
185
  }
186
186
  process.exit(1);
package/src/generator.ts CHANGED
@@ -47,6 +47,57 @@ function toSnakeCase(str: string): string {
47
47
  .toLowerCase();
48
48
  }
49
49
 
50
+ /**
51
+ * Format a value with type casting for PostgreSQL.
52
+ * Converts type::value to 'value'::type format.
53
+ *
54
+ * Examples:
55
+ * - "status::active" -> "'active'::status"
56
+ * - "'active'::status" -> "'active'::status" (already formatted)
57
+ * - "42" -> "42" (no cast)
58
+ */
59
+ function formatTypeCast(value: string): string {
60
+ // If no type cast operator, return as-is
61
+ if (!value.includes("::")) {
62
+ return value;
63
+ }
64
+
65
+ // Convert type::value to 'value'::type for any parts that need it
66
+ // This regex looks for word::word pattern and converts it
67
+ // Already formatted 'value'::type patterns are left unchanged
68
+ return value.replace(/(\w+)::(\w+)/g, "'$2'::$1");
69
+ }
70
+
71
+ /**
72
+ * Format an enum default value for PostgreSQL.
73
+ * Ensures the value is properly quoted and cast to the enum type.
74
+ *
75
+ * Examples:
76
+ * - "active" with type "status" -> "'active'::status"
77
+ * - "status::active" with type "status" -> "'active'::status"
78
+ * - "'active'::status" with type "status" -> "'active'::status" (already formatted)
79
+ * - "NULL" -> "NULL" (special value)
80
+ */
81
+ function formatEnumDefault(value: string, enumType: string): string {
82
+ if (value === "NULL") {
83
+ return value;
84
+ }
85
+
86
+ // Check if already in PostgreSQL cast format: 'value'::type
87
+ const alreadyFormatted = /^'[^']*'::\w+$/.test(value);
88
+ if (alreadyFormatted) {
89
+ return value;
90
+ }
91
+
92
+ // If has type cast (type::value), convert it
93
+ if (value.includes("::")) {
94
+ return formatTypeCast(value);
95
+ }
96
+
97
+ // Plain identifier, wrap in quotes and add type cast
98
+ return `'${value}'::${enumType}`;
99
+ }
100
+
50
101
  // ============================================================================
51
102
  // Generator Context
52
103
  // ============================================================================
@@ -164,13 +215,15 @@ function escapeRegExp(str: string): string {
164
215
  function expandCheckExpression(expr: string, ctx: GeneratorContext): string {
165
216
  let result = expr;
166
217
 
218
+ // Expand template variables
167
219
  for (const [param, value] of ctx.templateSubs) {
168
220
  const escapedParam = escapeRegExp(param);
169
221
  const pattern = new RegExp(`\\{${escapedParam}\\}`, "g");
170
222
  result = result.replace(pattern, value);
171
223
  }
172
224
 
173
- result = result.replace(/(\w+)::(\w+)/g, "'$2'::$1");
225
+ // Format type casts (type::value -> 'value'::type)
226
+ result = formatTypeCast(result);
174
227
 
175
228
  return result;
176
229
  }
@@ -185,6 +238,9 @@ function generateEnumSql(enumDecl: EnumDecl, ctx: GeneratorContext): void {
185
238
  if (ctx.generatedEnums.has(name)) return;
186
239
  ctx.generatedEnums.add(name);
187
240
 
241
+ // Add to enums map so it can be referenced for default value formatting
242
+ ctx.enums.set(name, enumDecl);
243
+
188
244
  const values = enumDecl.values.map((v) => `'${v}'`).join(", ");
189
245
  ctx.enumSql.push(`DO $$ BEGIN
190
246
  CREATE TYPE ${name} AS ENUM (${values});
@@ -211,9 +267,17 @@ function generateColumnSql(col: ColumnDef, ctx: GeneratorContext): string {
211
267
  case "Unique":
212
268
  parts.push("UNIQUE");
213
269
  break;
214
- case "Default":
215
- parts.push(`DEFAULT ${constraint.value ?? "NULL"}`);
270
+ case "Default": {
271
+ let defaultValue = constraint.value ?? "NULL";
272
+
273
+ const enumTypeName = col.dataType.toLowerCase();
274
+ if (ctx.enums.has(enumTypeName)) {
275
+ defaultValue = formatEnumDefault(defaultValue, enumTypeName);
276
+ }
277
+
278
+ parts.push(`DEFAULT ${defaultValue}`);
216
279
  break;
280
+ }
217
281
  case "Reference": {
218
282
  let tableName = constraint.table ?? "";
219
283
  if (ctx.tableToSchema.has(tableName)) {
@@ -234,9 +298,14 @@ function generateColumnSql(col: ColumnDef, ctx: GeneratorContext): string {
234
298
  return [...parts, ...postConstraints].join(" ");
235
299
  }
236
300
 
301
+ /**
302
+ * Generate CREATE INDEX SQL statement.
303
+ * Used for both schema-level indexes and per-instance indexes.
304
+ */
237
305
  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("_");
306
+ const expandedColumns = index.columns.map((c) => expandTemplate(c, ctx));
307
+ const columns = expandedColumns.join(", ");
308
+ const columnNames = expandedColumns.join("_");
240
309
  const indexName = `idx_${tableName}_${columnNames}`;
241
310
  const unique = index.unique === true ? "UNIQUE " : "";
242
311
  const using = index.using ? `USING ${index.using} ` : "";
@@ -252,7 +321,7 @@ function generateCheckSql(_tableName: string, check: CheckDef, ctx: GeneratorCon
252
321
  function generateTriggerSql(
253
322
  tableName: string,
254
323
  trigger: TriggerDef,
255
- _ctx: GeneratorContext
324
+ _ctx: GeneratorContext,
256
325
  ): string {
257
326
  const timing = trigger.timing.toUpperCase();
258
327
  const event = trigger.event.toUpperCase();
@@ -293,7 +362,7 @@ function resolveMixins(schema: SchemaDecl, ctx: GeneratorContext): SchemaBodyIte
293
362
  function resolveSchema(
294
363
  schema: SchemaDecl,
295
364
  tableName: string,
296
- ctx: GeneratorContext
365
+ ctx: GeneratorContext,
297
366
  ): ResolvedSchema {
298
367
  const allItems = resolveMixins(schema, ctx);
299
368
 
@@ -444,14 +513,14 @@ function processInstantiation(decl: Instantiation, ctx: GeneratorContext): void
444
513
  }
445
514
 
446
515
  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
- );
516
+ const indexDef: IndexDef = {
517
+ type: "IndexDef",
518
+ columns: decl.columns,
519
+ unique: decl.unique,
520
+ using: decl.using,
521
+ };
522
+
523
+ ctx.perInstanceIndexSql.push(generateIndexSql(decl.tableName, indexDef, ctx));
455
524
  }
456
525
 
457
526
  // ============================================================================
package/src/parser.ts CHANGED
@@ -1028,23 +1028,106 @@ class CstToAstVisitor {
1028
1028
  }
1029
1029
 
1030
1030
  private reconstructCheckExpression(node: CstNode): string {
1031
- const parts: string[] = [];
1031
+ // Collect all tokens/nodes with their positions
1032
+ const tokens: {
1033
+ image: string;
1034
+ startOffset: number;
1035
+ isParenExpr: boolean;
1036
+ }[] = [];
1032
1037
 
1033
1038
  for (const key of Object.keys(node.children)) {
1034
1039
  const children = node.children[key];
1035
1040
  if (!children) continue;
1036
1041
 
1037
1042
  for (const child of children) {
1038
- if ("image" in child) {
1039
- parts.push(child.image);
1043
+ if ("image" in child && "startOffset" in child) {
1044
+ // It's a token
1045
+ const startOffset = typeof child.startOffset === "number" ? child.startOffset : 0;
1046
+ tokens.push({
1047
+ image: child.image,
1048
+ startOffset,
1049
+ isParenExpr: false,
1050
+ });
1040
1051
  } else if ("children" in child && key === "checkExpression") {
1052
+ // It's a nested checkExpression - recursively reconstruct it
1053
+ // Note: The LParen and RParen tokens from the grammar rule are collected
1054
+ // separately as tokens in the parent, so we don't wrap the result here
1041
1055
  const inner = this.reconstructCheckExpression(child);
1042
- if (inner) parts.push(`(${inner})`);
1056
+ if (inner) {
1057
+ // Get the position from the first token in the child
1058
+ let minOffset = Infinity;
1059
+ for (const childKey of Object.keys(child.children)) {
1060
+ const childChildren = child.children[childKey];
1061
+ if (childChildren) {
1062
+ for (const c of childChildren) {
1063
+ if ("startOffset" in c && typeof c.startOffset === "number") {
1064
+ minOffset = Math.min(minOffset, c.startOffset);
1065
+ }
1066
+ }
1067
+ }
1068
+ }
1069
+ tokens.push({
1070
+ image: inner,
1071
+ startOffset: minOffset === Infinity ? 0 : minOffset,
1072
+ isParenExpr: false,
1073
+ });
1074
+ }
1075
+ }
1076
+ }
1077
+ }
1078
+
1079
+ // Sort tokens by their position in the source
1080
+ tokens.sort((a, b) => a.startOffset - b.startOffset);
1081
+
1082
+ // Join tokens intelligently - add spaces only where needed
1083
+ let result = "";
1084
+ for (let i = 0; i < tokens.length; i++) {
1085
+ const current = tokens[i];
1086
+ const prev = i > 0 ? tokens[i - 1] : null;
1087
+
1088
+ if (!current) continue;
1089
+ const currentImage = current.image;
1090
+
1091
+ // Tokens that should not have a space before them
1092
+ const noSpaceBefore = [")", ",", ".", "::", ">", "<"];
1093
+ // Tokens that should not have a space after them
1094
+ const noSpaceAfter = ["(", ".", "::"];
1095
+
1096
+ // Add space if needed
1097
+ if (prev && !current.isParenExpr) {
1098
+ const prevImage = prev.image;
1099
+ const prevLastChar = prevImage[prevImage.length - 1] ?? "";
1100
+
1101
+ // Special case: if prev is > or < and current is =, don't add space (for >=, <=)
1102
+ const isCompoundOperator = (prevImage === ">" || prevImage === "<") && currentImage === "=";
1103
+ // Add space before comparison operators (unless making a compound one)
1104
+ const isOperatorStart =
1105
+ (currentImage === ">" || currentImage === "<") &&
1106
+ !noSpaceAfter.includes(prevImage) &&
1107
+ prevLastChar !== "(";
1108
+
1109
+ const needsSpace =
1110
+ (!noSpaceBefore.includes(currentImage) &&
1111
+ !noSpaceAfter.includes(prevImage) &&
1112
+ prevLastChar !== "(" &&
1113
+ !isCompoundOperator) ||
1114
+ isOperatorStart;
1115
+
1116
+ if (needsSpace) {
1117
+ result += " ";
1118
+ }
1119
+ } else if (prev && current.isParenExpr) {
1120
+ // For parenthesized expressions, add space unless previous ends with (
1121
+ const prevImage = prev.image;
1122
+ const prevLastChar = prevImage[prevImage.length - 1] ?? "";
1123
+ if (prevLastChar !== "(") {
1124
+ result += " ";
1043
1125
  }
1044
1126
  }
1127
+ result += currentImage;
1045
1128
  }
1046
1129
 
1047
- return parts.join(" ");
1130
+ return result;
1048
1131
  }
1049
1132
 
1050
1133
  private visitIndexDef(node: CstNode): IndexDef {