dzql 0.2.2 → 0.3.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.
@@ -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.2",
3
+ "version": "0.3.0",
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.2...'"
25
+ "prepublishOnly": "echo '✅ Publishing DZQL v0.3.0...'"
26
26
  },
27
27
  "dependencies": {
28
28
  "jose": "^6.1.0",
package/src/client/ws.js CHANGED
@@ -52,6 +52,7 @@ class WebSocketManager {
52
52
  *
53
53
  * @param {Object} [options={}] - Configuration options
54
54
  * @param {number} [options.maxReconnectAttempts=5] - Maximum reconnection attempts before giving up
55
+ * @param {string} [options.tokenName='dzql_token'] - Name of the localStorage key for JWT token
55
56
  */
56
57
  constructor(options = {}) {
57
58
  this.ws = null;
@@ -62,6 +63,7 @@ class WebSocketManager {
62
63
  this.subscriptions = new Map(); // subscription_id -> { callback, unsubscribe }
63
64
  this.reconnectAttempts = 0;
64
65
  this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
66
+ this.tokenName = options.tokenName ?? 'dzql_token';
65
67
  this.isShuttingDown = false;
66
68
 
67
69
  // DZQL nested proxy API - matches server-side db.api pattern
@@ -265,7 +267,7 @@ class WebSocketManager {
265
267
 
266
268
  // Add JWT token as query parameter if available
267
269
  if (typeof localStorage !== 'undefined'){
268
- const storedToken = localStorage.getItem("dzql_token");
270
+ const storedToken = localStorage.getItem(this.tokenName);
269
271
  if (storedToken) {
270
272
  wsUrl += `?token=${encodeURIComponent(storedToken)}`;
271
273
  }
@@ -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
@@ -7,18 +7,25 @@ export class EntityParser {
7
7
  /**
8
8
  * Parse a dzql.register_entity() call from SQL
9
9
  * @param {string} sql - SQL containing register_entity call
10
- * @returns {Object} Parsed entity configuration
10
+ * @param {number} startOffset - Optional starting position in SQL
11
+ * @returns {Object} Parsed entity configuration with custom functions
11
12
  */
12
- parseFromSQL(sql) {
13
+ parseFromSQL(sql, startOffset = 0) {
13
14
  // Extract the register_entity call
14
- const registerMatch = sql.match(/dzql\.register_entity\s*\(([\s\S]*?)\);/i);
15
+ const searchSql = sql.substring(startOffset);
16
+ const registerMatch = searchSql.match(/dzql\.register_entity\s*\(([\s\S]*?)\);/i);
15
17
  if (!registerMatch) {
16
18
  throw new Error('No register_entity call found in SQL');
17
19
  }
18
20
 
19
21
  const params = this._parseParameters(registerMatch[1]);
22
+ const config = this._buildEntityConfig(params);
20
23
 
21
- return this._buildEntityConfig(params);
24
+ // Extract custom functions after this register_entity call
25
+ const registerEndPos = startOffset + registerMatch.index + registerMatch[0].length;
26
+ config.customFunctions = this._extractCustomFunctions(sql, registerEndPos);
27
+
28
+ return config;
22
29
  }
23
30
 
24
31
  /**
@@ -73,6 +80,11 @@ export class EntityParser {
73
80
  * @private
74
81
  */
75
82
  _buildEntityConfig(params) {
83
+ const graphRules = params[8] ? this._parseJSON(params[8]) : {};
84
+
85
+ // Extract many_to_many from graph_rules if present
86
+ const manyToMany = graphRules.many_to_many || {};
87
+
76
88
  const config = {
77
89
  tableName: this._cleanString(params[0]),
78
90
  labelField: this._cleanString(params[1]),
@@ -82,7 +94,9 @@ export class EntityParser {
82
94
  temporalFields: params[5] ? this._parseJSON(params[5]) : {},
83
95
  notificationPaths: params[6] ? this._parseJSON(params[6]) : {},
84
96
  permissionPaths: params[7] ? this._parseJSON(params[7]) : {},
85
- graphRules: params[8] ? this._parseJSON(params[8]) : {}
97
+ graphRules: graphRules,
98
+ fieldDefaults: params[9] ? this._parseJSON(params[9]) : {},
99
+ manyToMany: manyToMany
86
100
  };
87
101
 
88
102
  return config;
@@ -250,12 +264,56 @@ export class EntityParser {
250
264
  return cleaned === 'true' || cleaned === 't';
251
265
  }
252
266
 
267
+ /**
268
+ * Extract custom functions defined after register_entity() call
269
+ * @private
270
+ * @param {string} sql - Full SQL content
271
+ * @param {number} startPos - Position after register_entity call
272
+ * @returns {Array<string>} Array of custom function SQL statements
273
+ */
274
+ _extractCustomFunctions(sql, startPos) {
275
+ // Find the next register_entity call or end of file
276
+ const nextEntityMatch = sql.substring(startPos).match(/dzql\.register_entity\s*\(/i);
277
+ const endPos = nextEntityMatch ? startPos + nextEntityMatch.index : sql.length;
278
+
279
+ // Extract the SQL between this entity and the next
280
+ const customSql = sql.substring(startPos, endPos).trim();
281
+ if (!customSql) return [];
282
+
283
+ const functions = [];
284
+
285
+ // Extract CREATE [OR REPLACE] FUNCTION statements
286
+ // Match from CREATE to the final semicolon of the function (including $$ delimiters)
287
+ const functionPattern = /CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+[\s\S]*?(?:\$\$|\$[A-Za-z_][A-Za-z0-9_]*\$)\s*(?:LANGUAGE|;)[\s\S]*?;/gi;
288
+ let match;
289
+ while ((match = functionPattern.exec(customSql)) !== null) {
290
+ functions.push(match[0].trim());
291
+ }
292
+
293
+ // Extract INSERT INTO dzql.registry statements
294
+ const registryPattern = /INSERT\s+INTO\s+dzql\.registry\s+[\s\S]*?;/gi;
295
+ while ((match = registryPattern.exec(customSql)) !== null) {
296
+ functions.push(match[0].trim());
297
+ }
298
+
299
+ // Extract SELECT dzql.register_function() calls
300
+ const registerFunctionPattern = /SELECT\s+dzql\.register_function\s*\([\s\S]*?\)\s*;/gi;
301
+ while ((match = registerFunctionPattern.exec(customSql)) !== null) {
302
+ functions.push(match[0].trim());
303
+ }
304
+
305
+ return functions;
306
+ }
307
+
253
308
  /**
254
309
  * Parse entity definition from JS object (for programmatic use)
255
310
  * @param {Object} entity - Entity definition object
256
311
  * @returns {Object} Normalized entity configuration
257
312
  */
258
313
  parseFromObject(entity) {
314
+ const graphRules = entity.graphRules || {};
315
+ const manyToMany = entity.manyToMany || graphRules.many_to_many || {};
316
+
259
317
  return {
260
318
  tableName: entity.tableName || entity.table,
261
319
  labelField: entity.labelField || 'name',
@@ -265,7 +323,10 @@ export class EntityParser {
265
323
  temporalFields: entity.temporalFields || {},
266
324
  notificationPaths: entity.notificationPaths || {},
267
325
  permissionPaths: entity.permissionPaths || {},
268
- graphRules: entity.graphRules || {}
326
+ graphRules: graphRules,
327
+ fieldDefaults: entity.fieldDefaults || {},
328
+ manyToMany: manyToMany,
329
+ customFunctions: entity.customFunctions || []
269
330
  };
270
331
  }
271
332
  }
@@ -279,17 +340,16 @@ export function parseEntitiesFromSQL(sql) {
279
340
  const parser = new EntityParser();
280
341
  const entities = [];
281
342
 
282
- // Find all register_entity calls
283
- const registerCalls = sql.match(/dzql\.register_entity\s*\([\s\S]*?\);/gi);
284
-
285
- if (!registerCalls) {
286
- return entities;
287
- }
343
+ // Find all register_entity calls with their positions
344
+ const registerPattern = /dzql\.register_entity\s*\(/gi;
345
+ let match;
346
+ let currentPos = 0;
288
347
 
289
- for (const call of registerCalls) {
348
+ while ((match = registerPattern.exec(sql)) !== null) {
290
349
  try {
291
- const entity = parser.parseFromSQL(call);
350
+ const entity = parser.parseFromSQL(sql, match.index);
292
351
  entities.push(entity);
352
+ currentPos = match.index + 1; // Move past this match
293
353
  } catch (error) {
294
354
  console.warn('Failed to parse entity:', error.message);
295
355
  }
@@ -23,7 +23,9 @@ CREATE TABLE IF NOT EXISTS dzql.entities (
23
23
  temporal_fields jsonb DEFAULT '{}', -- valid_from/valid_to field names for temporal filtering
24
24
  notification_paths jsonb DEFAULT '{}', -- paths to determine who gets notified
25
25
  permission_paths jsonb DEFAULT '{}', -- paths to determine who has permission for operations
26
- graph_rules jsonb DEFAULT '{}' -- graph evolution rules for automatic relationship management
26
+ graph_rules jsonb DEFAULT '{}', -- graph evolution rules for automatic relationship management
27
+ field_defaults jsonb DEFAULT '{}', -- default values to auto-populate on INSERT
28
+ many_to_many jsonb DEFAULT '{}' -- many-to-many relationship configurations
27
29
  );
28
30
 
29
31
  -- === Registry (allowlist of callable functions) ===
@@ -744,6 +744,11 @@ BEGIN
744
744
  -- Validate top-level trigger types
745
745
  FOR l_trigger_key, l_trigger_rules IN SELECT * FROM jsonb_each(p_rules)
746
746
  LOOP
747
+ -- Skip validation for many_to_many (different structure)
748
+ IF l_trigger_key = 'many_to_many' THEN
749
+ CONTINUE;
750
+ END IF;
751
+
747
752
  -- Check valid trigger types
748
753
  IF l_trigger_key NOT IN ('on_create', 'on_update', 'on_delete', 'on_field_change') THEN
749
754
  RAISE WARNING 'Invalid trigger type: %', l_trigger_key;