dzql 0.2.3 → 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.
- package/docs/guides/custom-functions.md +362 -0
- package/docs/guides/field-defaults.md +240 -0
- package/docs/guides/many-to-many.md +894 -0
- package/docs/reference/api.md +147 -3
- package/package.json +2 -2
- package/src/compiler/compiler.js +23 -13
- package/src/compiler/parser/entity-parser.js +74 -14
- package/src/database/migrations/001_schema.sql +3 -1
- package/src/database/migrations/002_functions.sql +5 -0
- package/src/database/migrations/003_operations.sql +236 -1
- package/src/database/migrations/004_search.sql +64 -0
- package/src/database/migrations/005_entities.sql +11 -4
package/docs/reference/api.md
CHANGED
|
@@ -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
|
-
###
|
|
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.
|
|
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.
|
|
25
|
+
"prepublishOnly": "echo '✅ Publishing DZQL v0.3.0...'"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"jose": "^6.1.0",
|
package/src/compiler/compiler.js
CHANGED
|
@@ -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
|
-
|
|
189
|
+
// Use parseEntitiesFromSQL to properly extract custom functions
|
|
190
|
+
const entities = parseEntitiesFromSQL(sqlContent);
|
|
184
191
|
|
|
185
|
-
if (
|
|
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
|
-
* @
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
348
|
+
while ((match = registerPattern.exec(sql)) !== null) {
|
|
290
349
|
try {
|
|
291
|
-
const entity = parser.parseFromSQL(
|
|
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 '{}'
|
|
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;
|