dzql 0.2.3 → 0.3.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.
@@ -235,7 +235,8 @@ SELECT dzql.register_entity(
235
235
  p_temporal_fields JSONB DEFAULT '{}'::jsonb,
236
236
  p_notification_paths JSONB DEFAULT '{}'::jsonb,
237
237
  p_permission_paths JSONB DEFAULT '{}'::jsonb,
238
- p_graph_rules JSONB DEFAULT '{}'::jsonb
238
+ p_graph_rules JSONB DEFAULT '{}'::jsonb,
239
+ p_field_defaults JSONB DEFAULT '{}'::jsonb
239
240
  );
240
241
  ```
241
242
 
@@ -251,7 +252,8 @@ SELECT dzql.register_entity(
251
252
  | `p_temporal_fields` | JSONB | no | Temporal field config (valid_from/valid_to) |
252
253
  | `p_notification_paths` | JSONB | no | Who receives real-time updates |
253
254
  | `p_permission_paths` | JSONB | no | CRUD permission rules |
254
- | `p_graph_rules` | JSONB | no | Automatic relationship management |
255
+ | `p_graph_rules` | JSONB | no | Automatic relationship management + M2M |
256
+ | `p_field_defaults` | JSONB | no | Auto-populate fields on INSERT |
255
257
 
256
258
  ### FK Includes
257
259
 
@@ -302,7 +304,76 @@ const rights = await ws.api.get.contractor_rights({id: 1});
302
304
  const past = await ws.api.get.contractor_rights({id: 1, on_date: '2023-01-01'});
303
305
  ```
304
306
 
305
- ### Example Registration
307
+ ### Field Defaults
308
+
309
+ Auto-populate fields on INSERT with values or variables:
310
+
311
+ ```sql
312
+ '{
313
+ "owner_id": "@user_id", -- Current user ID
314
+ "created_by": "@user_id", -- Current user ID
315
+ "created_at": "@now", -- Current timestamp
316
+ "status": "draft" -- Literal value
317
+ }'
318
+ ```
319
+
320
+ **Available variables:**
321
+ - `@user_id` - Current user ID from `p_user_id`
322
+ - `@now` - Current timestamp
323
+ - `@today` - Current date
324
+ - Literal values - Any JSON value (`"draft"`, `0`, `true`)
325
+
326
+ **Behavior:**
327
+ - Only applied on INSERT (not UPDATE)
328
+ - Explicit values override defaults
329
+ - Reduces client boilerplate
330
+
331
+ See [Field Defaults Guide](../guides/field-defaults.md) for details.
332
+
333
+ ### Many-to-Many Relationships
334
+
335
+ Configure M2M relationships via `graph_rules.many_to_many`:
336
+
337
+ ```sql
338
+ '{
339
+ "many_to_many": {
340
+ "tags": {
341
+ "junction_table": "brand_tags",
342
+ "local_key": "brand_id",
343
+ "foreign_key": "tag_id",
344
+ "target_entity": "tags",
345
+ "id_field": "tag_ids",
346
+ "expand": false
347
+ }
348
+ }
349
+ }'
350
+ ```
351
+
352
+ **Client usage:**
353
+ ```javascript
354
+ // Save with relationships in single call
355
+ await api.save_brands({
356
+ data: {
357
+ name: "My Brand",
358
+ tag_ids: [1, 2, 3] // Junction table synced atomically
359
+ }
360
+ })
361
+
362
+ // Response includes tag_ids array
363
+ { id: 5, name: "My Brand", tag_ids: [1, 2, 3] }
364
+ ```
365
+
366
+ **Configuration:**
367
+ - `junction_table` - Name of junction table
368
+ - `local_key` - FK to this entity
369
+ - `foreign_key` - FK to target entity
370
+ - `target_entity` - Target table name
371
+ - `id_field` - Field name for ID array
372
+ - `expand` - Include full objects (default: false)
373
+
374
+ See [Many-to-Many Guide](../guides/many-to-many.md) for details.
375
+
376
+ ### Example Registration (Basic)
306
377
 
307
378
  ```sql
308
379
  SELECT dzql.register_entity(
@@ -332,10 +403,83 @@ SELECT dzql.register_entity(
332
403
  }]
333
404
  }
334
405
  }
406
+ }',
407
+ '{}' -- field defaults (none)
408
+ );
409
+ ```
410
+
411
+ ### Example Registration (With All Features)
412
+
413
+ ```sql
414
+ SELECT dzql.register_entity(
415
+ 'resources',
416
+ 'title',
417
+ ARRAY['title', 'description'],
418
+ '{"org": "organisations"}', -- FK includes
419
+ false, -- soft delete
420
+ '{}', -- temporal
421
+ '{}', -- notifications
422
+ '{ -- permissions
423
+ "view": [],
424
+ "create": [],
425
+ "update": ["@owner_id"],
426
+ "delete": ["@owner_id"]
427
+ }',
428
+ '{ -- graph rules
429
+ "many_to_many": {
430
+ "tags": {
431
+ "junction_table": "resource_tags",
432
+ "local_key": "resource_id",
433
+ "foreign_key": "tag_id",
434
+ "target_entity": "tags",
435
+ "id_field": "tag_ids",
436
+ "expand": false
437
+ },
438
+ "collaborators": {
439
+ "junction_table": "resource_collaborators",
440
+ "local_key": "resource_id",
441
+ "foreign_key": "user_id",
442
+ "target_entity": "users",
443
+ "id_field": "collaborator_ids",
444
+ "expand": true
445
+ }
446
+ }
447
+ }',
448
+ '{ -- field defaults
449
+ "owner_id": "@user_id",
450
+ "created_by": "@user_id",
451
+ "created_at": "@now",
452
+ "status": "draft"
335
453
  }'
336
454
  );
337
455
  ```
338
456
 
457
+ **Client usage:**
458
+ ```javascript
459
+ // Single call with all features!
460
+ const resource = await api.save_resources({
461
+ data: {
462
+ title: "My Resource",
463
+ tag_ids: [1, 2, 3],
464
+ collaborator_ids: [10, 20]
465
+ // owner_id, created_by, created_at, status auto-populated
466
+ }
467
+ })
468
+
469
+ // Response
470
+ {
471
+ id: 1,
472
+ title: "My Resource",
473
+ owner_id: 123, // From field defaults
474
+ created_by: 123, // From field defaults
475
+ created_at: "2025-11-20...", // From field defaults
476
+ status: "draft", // From field defaults
477
+ tag_ids: [1, 2, 3], // M2M IDs
478
+ collaborator_ids: [10, 20], // M2M IDs
479
+ collaborators: [...] // Full objects (expand: true)
480
+ }
481
+ ```
482
+
339
483
  ---
340
484
 
341
485
  ## Search Operators
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "description": "PostgreSQL-powered framework with zero boilerplate CRUD operations and real-time WebSocket synchronization",
5
5
  "type": "module",
6
6
  "main": "src/server/index.js",
@@ -22,7 +22,7 @@
22
22
  ],
23
23
  "scripts": {
24
24
  "test": "bun test",
25
- "prepublishOnly": "echo '✅ Publishing DZQL v0.2.3...'"
25
+ "prepublishOnly": "echo '✅ Publishing DZQL v0.3.1...'"
26
26
  },
27
27
  "dependencies": {
28
28
  "jose": "^6.1.0",
@@ -28,6 +28,7 @@ export class OperationCodegen {
28
28
  */
29
29
  generateGetFunction() {
30
30
  const fkExpansions = this._generateFKExpansions();
31
+ const m2mExpansionForGet = this._generateM2MExpansionForGet();
31
32
  const filterSensitiveFields = this._generateSensitiveFieldFilter();
32
33
 
33
34
  return `-- GET operation for ${this.tableName}
@@ -58,6 +59,7 @@ BEGIN
58
59
  END IF;
59
60
 
60
61
  ${fkExpansions}
62
+ ${m2mExpansionForGet}
61
63
  ${filterSensitiveFields}
62
64
 
63
65
  RETURN v_result;
@@ -72,6 +74,10 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
72
74
  const graphRulesCall = this._generateGraphRulesCall();
73
75
  const notificationSQL = this._generateNotificationSQL();
74
76
  const filterSensitiveFields = this._generateSensitiveFieldFilter('v_output');
77
+ const m2mVariables = this._generateM2MVariableDeclarations();
78
+ const m2mExtraction = this._generateM2MExtraction();
79
+ const m2mSync = this._generateM2MSync();
80
+ const m2mExpansion = this._generateM2MExpansion();
75
81
 
76
82
  return `-- SAVE operation for ${this.tableName}
77
83
  CREATE OR REPLACE FUNCTION save_${this.tableName}(
@@ -84,7 +90,9 @@ DECLARE
84
90
  v_output JSONB;
85
91
  v_is_insert BOOLEAN := false;
86
92
  v_notify_users INT[];
93
+ ${m2mVariables}
87
94
  BEGIN
95
+ ${m2mExtraction}
88
96
  -- Determine if this is insert or update
89
97
  IF p_data->>'id' IS NULL THEN
90
98
  v_is_insert := true;
@@ -120,23 +128,32 @@ BEGIN
120
128
  FROM jsonb_each_text(p_data) kv(key, value)
121
129
  ) INTO v_result;
122
130
  ELSE
123
- -- Dynamic UPDATE from JSONB
124
- EXECUTE (
125
- SELECT format(
126
- 'UPDATE ${this.tableName} SET %s WHERE id = %L RETURNING *',
127
- string_agg(quote_ident(key) || ' = ' || quote_nullable(value), ', '),
128
- (p_data->>'id')::int
129
- )
130
- FROM jsonb_each_text(p_data) kv(key, value)
131
- WHERE key != 'id'
132
- ) INTO v_result;
131
+ -- Dynamic UPDATE from JSONB (only if there are fields to update)
132
+ IF (SELECT COUNT(*) FROM jsonb_object_keys(p_data) WHERE jsonb_object_keys != 'id') > 0 THEN
133
+ EXECUTE (
134
+ SELECT format(
135
+ 'UPDATE ${this.tableName} SET %s WHERE id = %L RETURNING *',
136
+ string_agg(quote_ident(key) || ' = ' || quote_nullable(value), ', '),
137
+ (p_data->>'id')::int
138
+ )
139
+ FROM jsonb_each_text(p_data) kv(key, value)
140
+ WHERE key != 'id'
141
+ ) INTO v_result;
142
+ ELSE
143
+ -- No fields to update (only M2M fields were provided), just fetch existing
144
+ v_result := v_existing;
145
+ END IF;
133
146
  END IF;
134
147
 
148
+ ${m2mSync}
149
+
150
+ -- Prepare output with M2M fields (BEFORE event creation for real-time notifications!)
151
+ v_output := to_jsonb(v_result);
152
+ ${m2mExpansion}
153
+
135
154
  ${graphRulesCall}
136
155
  ${notificationSQL}
137
156
 
138
- -- Prepare output (removing sensitive fields)
139
- v_output := to_jsonb(v_result);
140
157
  ${filterSensitiveFields}
141
158
 
142
159
  RETURN v_output;
@@ -233,6 +250,7 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
233
250
  `${field} ILIKE '%' || p_search || '%'`
234
251
  ).join(' OR ');
235
252
  const filterSensitiveFieldsArray = this._generateSensitiveFieldFilterArray();
253
+ const m2mSearchExpansion = this._generateM2MExpansionForSearch();
236
254
 
237
255
  return `-- SEARCH operation for ${this.tableName}
238
256
  CREATE OR REPLACE FUNCTION search_${this.tableName}(
@@ -311,8 +329,8 @@ BEGIN
311
329
 
312
330
  -- Get data
313
331
  EXECUTE format('
314
- SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY %I %s), ''[]''::jsonb)
315
- FROM ${this.tableName} t
332
+ SELECT COALESCE(jsonb_agg(${m2mSearchExpansion.selectExpression} ORDER BY %I %s), ''[]''::jsonb)
333
+ FROM ${this.tableName} t${m2mSearchExpansion.lateralJoins}
316
334
  WHERE %s
317
335
  LIMIT %L OFFSET %L
318
336
  ', v_sort_field, v_sort_order, v_where_clause, p_limit, v_offset) INTO v_data;
@@ -329,6 +347,251 @@ END;
329
347
  $$ LANGUAGE plpgsql SECURITY DEFINER;`;
330
348
  }
331
349
 
350
+ /**
351
+ * Generate M2M variable declarations
352
+ * COMPILE TIME: Loop to generate static variable declarations
353
+ * RUNTIME: No loops, just variables
354
+ * @private
355
+ */
356
+ _generateM2MVariableDeclarations() {
357
+ const manyToMany = this.entity.manyToMany || {};
358
+ if (Object.keys(manyToMany).length === 0) return '';
359
+
360
+ const declarations = [];
361
+
362
+ // COMPILE TIME LOOP: Generate separate variable for each M2M relationship
363
+ for (const [relationKey, config] of Object.entries(manyToMany)) {
364
+ const idField = config.id_field;
365
+ declarations.push(` v_${idField} INT[]; -- M2M: ${relationKey}`);
366
+ }
367
+
368
+ return declarations.join('\n');
369
+ }
370
+
371
+ /**
372
+ * Generate M2M extraction logic
373
+ * COMPILE TIME: Loop to generate code
374
+ * RUNTIME: Separate IF blocks (NO loops!)
375
+ * @private
376
+ */
377
+ _generateM2MExtraction() {
378
+ const manyToMany = this.entity.manyToMany || {};
379
+ if (Object.keys(manyToMany).length === 0) return '';
380
+
381
+ const extractions = [];
382
+
383
+ // COMPILE TIME LOOP: Generate separate extraction block for each M2M
384
+ for (const [relationKey, config] of Object.entries(manyToMany)) {
385
+ const idField = config.id_field;
386
+
387
+ // Each M2M gets its own static IF block (no runtime loops!)
388
+ extractions.push(`
389
+ -- Extract M2M field: ${idField} (${relationKey})
390
+ IF p_data ? '${idField}' THEN
391
+ v_${idField} := ARRAY(SELECT jsonb_array_elements_text(p_data->'${idField}')::int);
392
+ p_data := p_data - '${idField}'; -- Remove from data (not a table column)
393
+ END IF;`);
394
+ }
395
+
396
+ return extractions.join('');
397
+ }
398
+
399
+ /**
400
+ * Generate M2M junction table sync logic
401
+ * COMPILE TIME: Loop to generate code
402
+ * RUNTIME: Direct SQL execution (NO loops!)
403
+ * @private
404
+ */
405
+ _generateM2MSync() {
406
+ const manyToMany = this.entity.manyToMany || {};
407
+ if (Object.keys(manyToMany).length === 0) return '';
408
+
409
+ const syncs = [];
410
+
411
+ // COMPILE TIME LOOP: Generate separate sync block for EACH relationship
412
+ for (const [relationKey, config] of Object.entries(manyToMany)) {
413
+ const idField = config.id_field;
414
+ const junctionTable = config.junction_table;
415
+ const localKey = config.local_key;
416
+ const foreignKey = config.foreign_key;
417
+
418
+ // Static SQL - all names known at compile time!
419
+ syncs.push(`
420
+ -- ============================================================================
421
+ -- M2M Sync: ${relationKey} (junction: ${junctionTable})
422
+ -- ============================================================================
423
+ IF v_${idField} IS NOT NULL THEN
424
+ -- Delete relationships not in new list
425
+ DELETE FROM ${junctionTable}
426
+ WHERE ${localKey} = v_result.id
427
+ AND (${foreignKey} <> ALL(v_${idField}) OR v_${idField} = '{}');
428
+
429
+ -- Insert new relationships (idempotent)
430
+ IF array_length(v_${idField}, 1) > 0 THEN
431
+ INSERT INTO ${junctionTable} (${localKey}, ${foreignKey})
432
+ SELECT v_result.id, unnest(v_${idField})
433
+ ON CONFLICT (${localKey}, ${foreignKey}) DO NOTHING;
434
+ END IF;
435
+ END IF;`);
436
+ }
437
+
438
+ return syncs.join('');
439
+ }
440
+
441
+ /**
442
+ * Generate M2M expansion in output (for SAVE function)
443
+ * COMPILE TIME: Loop to generate code
444
+ * RUNTIME: Direct SQL queries (NO loops!)
445
+ * Expands M2M fields into v_output BEFORE event creation (for real-time notifications)
446
+ * @private
447
+ */
448
+ _generateM2MExpansion() {
449
+ const manyToMany = this.entity.manyToMany || {};
450
+ if (Object.keys(manyToMany).length === 0) return '';
451
+
452
+ const expansions = [];
453
+
454
+ // COMPILE TIME LOOP: Generate code for each M2M relationship
455
+ for (const [relationKey, config] of Object.entries(manyToMany)) {
456
+ const idField = config.id_field;
457
+ const junctionTable = config.junction_table;
458
+ const localKey = config.local_key;
459
+ const foreignKey = config.foreign_key;
460
+ const targetEntity = config.target_entity;
461
+ const expand = config.expand || false;
462
+
463
+ // Always add ID array (static SQL) - use v_result.id since v_output is v_result as jsonb
464
+ expansions.push(`
465
+ -- Add M2M IDs: ${idField}
466
+ v_output := v_output || jsonb_build_object('${idField}',
467
+ (SELECT COALESCE(jsonb_agg(${foreignKey} ORDER BY ${foreignKey}), '[]'::jsonb)
468
+ FROM ${junctionTable} WHERE ${localKey} = v_result.id)
469
+ );`);
470
+
471
+ // Conditionally expand full objects (known at compile time!)
472
+ if (expand) {
473
+ expansions.push(`
474
+ -- Expand M2M objects: ${relationKey} (expand=true)
475
+ v_output := v_output || jsonb_build_object('${relationKey}',
476
+ (SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
477
+ FROM ${junctionTable} jt
478
+ JOIN ${targetEntity} t ON t.id = jt.${foreignKey}
479
+ WHERE jt.${localKey} = v_result.id)
480
+ );`);
481
+ }
482
+ }
483
+
484
+ return expansions.join('');
485
+ }
486
+
487
+ /**
488
+ * Generate M2M expansion for SEARCH operation
489
+ * COMPILE TIME: Loop to generate LATERAL joins
490
+ * RUNTIME: Static joins (NO loops!)
491
+ * @private
492
+ */
493
+ _generateM2MExpansionForSearch() {
494
+ const manyToMany = this.entity.manyToMany || {};
495
+
496
+ if (Object.keys(manyToMany).length === 0) {
497
+ return {
498
+ lateralJoins: '',
499
+ selectExpression: 'to_jsonb(t.*)'
500
+ };
501
+ }
502
+
503
+ const lateralJoins = [];
504
+ const mergeExpressions = [];
505
+
506
+ // COMPILE TIME LOOP: Generate LATERAL join for each M2M relationship
507
+ for (const [relationKey, config] of Object.entries(manyToMany)) {
508
+ const idField = config.id_field;
509
+ const junctionTable = config.junction_table;
510
+ const localKey = config.local_key;
511
+ const foreignKey = config.foreign_key;
512
+ const targetEntity = config.target_entity;
513
+ const expand = config.expand || false;
514
+
515
+ // LATERAL join for ID array (static SQL)
516
+ lateralJoins.push(`
517
+ LEFT JOIN LATERAL (
518
+ SELECT COALESCE(jsonb_agg(${foreignKey} ORDER BY ${foreignKey}), ''[]''::jsonb) as ${idField}
519
+ FROM ${junctionTable}
520
+ WHERE ${localKey} = t.id
521
+ ) m2m_${idField} ON true`);
522
+
523
+ mergeExpressions.push(`jsonb_build_object(''${idField}'', m2m_${idField}.${idField})`);
524
+
525
+ // Optionally expand full objects
526
+ if (expand) {
527
+ lateralJoins.push(`
528
+ LEFT JOIN LATERAL (
529
+ SELECT COALESCE(jsonb_agg(to_jsonb(target.*) ORDER BY target.id), ''[]''::jsonb) as ${relationKey}
530
+ FROM ${junctionTable} jt
531
+ JOIN ${targetEntity} target ON target.id = jt.${foreignKey}
532
+ WHERE jt.${localKey} = t.id
533
+ ) m2m_${relationKey} ON true`);
534
+
535
+ mergeExpressions.push(`jsonb_build_object(''${relationKey}'', m2m_${relationKey}.${relationKey})`);
536
+ }
537
+ }
538
+
539
+ // Build the select expression that merges M2M fields
540
+ const selectExpression = mergeExpressions.length > 0
541
+ ? `to_jsonb(t.*) || ${mergeExpressions.join(' || ')}`
542
+ : 'to_jsonb(t.*)';
543
+
544
+ return {
545
+ lateralJoins: lateralJoins.join(''),
546
+ selectExpression
547
+ };
548
+ }
549
+
550
+ /**
551
+ * Generate M2M expansion for GET operation
552
+ * COMPILE TIME: Loop to generate code
553
+ * RUNTIME: Direct SQL queries (NO loops!)
554
+ * @private
555
+ */
556
+ _generateM2MExpansionForGet() {
557
+ const manyToMany = this.entity.manyToMany || {};
558
+ if (Object.keys(manyToMany).length === 0) return '';
559
+
560
+ const expansions = [];
561
+
562
+ // COMPILE TIME LOOP: Generate code for each M2M relationship
563
+ for (const [relationKey, config] of Object.entries(manyToMany)) {
564
+ const idField = config.id_field;
565
+ const junctionTable = config.junction_table;
566
+ const localKey = config.local_key;
567
+ const foreignKey = config.foreign_key;
568
+ const targetEntity = config.target_entity;
569
+ const expand = config.expand || false;
570
+
571
+ // Always add ID array (static SQL)
572
+ expansions.push(`
573
+ -- Add M2M IDs: ${idField}
574
+ v_result := v_result || jsonb_build_object('${idField}',
575
+ (SELECT COALESCE(jsonb_agg(${foreignKey} ORDER BY ${foreignKey}), '[]'::jsonb)
576
+ FROM ${junctionTable} WHERE ${localKey} = v_record.id)
577
+ );`);
578
+
579
+ // Conditionally expand full objects (known at compile time!)
580
+ if (expand) {
581
+ expansions.push(`
582
+ -- Expand M2M objects: ${relationKey} (expand=true)
583
+ v_result := v_result || jsonb_build_object('${relationKey}',
584
+ (SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
585
+ FROM ${junctionTable} jt
586
+ JOIN ${targetEntity} t ON t.id = jt.${foreignKey}
587
+ WHERE jt.${localKey} = v_record.id)
588
+ );`);
589
+ }
590
+ }
591
+
592
+ return expansions.join('');
593
+ }
594
+
332
595
  /**
333
596
  * Generate FK expansions for GET
334
597
  * @private
@@ -460,10 +723,10 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
460
723
 
461
724
  if (operation === 'save') {
462
725
  return `
463
- -- Resolve notification recipients
464
- ${hasNotificationPaths ? `v_notify_users := _resolve_notification_paths_${this.tableName}(p_user_id, to_jsonb(v_result));` : 'v_notify_users := ARRAY[]::INT[];'}
726
+ -- Resolve notification recipients (use v_output with M2M fields!)
727
+ ${hasNotificationPaths ? `v_notify_users := _resolve_notification_paths_${this.tableName}(p_user_id, v_output);` : 'v_notify_users := ARRAY[]::INT[];'}
465
728
 
466
- -- Create event for real-time notifications
729
+ -- Create event for real-time notifications (v_output includes M2M fields!)
467
730
  INSERT INTO dzql.events (
468
731
  table_name,
469
732
  op,
@@ -477,7 +740,7 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
477
740
  CASE WHEN v_is_insert THEN 'insert' ELSE 'update' END,
478
741
  jsonb_build_object('id', v_result.id),
479
742
  CASE WHEN NOT v_is_insert THEN to_jsonb(v_existing) ELSE NULL END,
480
- to_jsonb(v_result),
743
+ v_output,
481
744
  p_user_id,
482
745
  v_notify_users
483
746
  );`;
@@ -3,7 +3,7 @@
3
3
  * Main compiler class that orchestrates parsing and code generation
4
4
  */
5
5
 
6
- import { EntityParser } from './parser/entity-parser.js';
6
+ import { EntityParser, parseEntitiesFromSQL } from './parser/entity-parser.js';
7
7
  import { SubscribableParser } from './parser/subscribable-parser.js';
8
8
  import { generatePermissionFunctions } from './codegen/permission-codegen.js';
9
9
  import { generateOperations } from './codegen/operation-codegen.js';
@@ -65,6 +65,12 @@ export class DZQLCompiler {
65
65
  sections.push(this._generateGraphRuleFunctions(normalizedEntity));
66
66
  }
67
67
 
68
+ // Custom functions (pass-through from entity definition)
69
+ if (normalizedEntity.customFunctions &&
70
+ normalizedEntity.customFunctions.length > 0) {
71
+ sections.push(this._generateCustomFunctionsSection(normalizedEntity));
72
+ }
73
+
68
74
  // Combine all sections
69
75
  const sql = sections.join('\n\n');
70
76
 
@@ -180,9 +186,10 @@ export class DZQLCompiler {
180
186
  * @returns {Object} Compilation results
181
187
  */
182
188
  compileFromSQL(sqlContent) {
183
- const registerCalls = sqlContent.match(/dzql\.register_entity\s*\([\s\S]*?\);/gi);
189
+ // Use parseEntitiesFromSQL to properly extract custom functions
190
+ const entities = parseEntitiesFromSQL(sqlContent);
184
191
 
185
- if (!registerCalls) {
192
+ if (entities.length === 0) {
186
193
  return {
187
194
  results: [],
188
195
  errors: [],
@@ -190,16 +197,6 @@ export class DZQLCompiler {
190
197
  };
191
198
  }
192
199
 
193
- const entities = [];
194
- for (const call of registerCalls) {
195
- try {
196
- const entity = this.parser.parseFromSQL(call);
197
- entities.push(entity);
198
- } catch (error) {
199
- console.warn('Failed to parse entity:', error.message);
200
- }
201
- }
202
-
203
200
  return this.compileAll(entities);
204
201
  }
205
202
 
@@ -258,6 +255,19 @@ export class DZQLCompiler {
258
255
  );
259
256
  }
260
257
 
258
+ /**
259
+ * Generate custom functions section (pass-through from entity definition)
260
+ * @private
261
+ */
262
+ _generateCustomFunctionsSection(entity) {
263
+ const header = `-- ============================================================================
264
+ -- Custom Functions for: ${entity.tableName}
265
+ -- Pass-through from entity definition
266
+ -- ============================================================================`;
267
+
268
+ return header + '\n\n' + entity.customFunctions.join('\n\n');
269
+ }
270
+
261
271
  /**
262
272
  * Calculate SHA-256 checksum of SQL
263
273
  * @private