dzql 0.5.0 → 0.5.3
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/bin/cli.js +3 -2
- package/docs/compiler/CODING_STANDARDS.md +66 -0
- package/docs/compiler/README.md +17 -0
- package/docs/reference/api.md +39 -1
- package/package.json +1 -1
- package/src/compiler/codegen/auth-codegen.js +3 -2
- package/src/compiler/codegen/graph-rules-codegen.js +22 -4
- package/src/compiler/codegen/operation-codegen.js +19 -2
- package/src/compiler/compiler.js +2 -1
- package/src/compiler/parser/entity-parser.js +8 -1
package/bin/cli.js
CHANGED
|
@@ -240,7 +240,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
|
240
240
|
|
|
241
241
|
-- Register new user
|
|
242
242
|
-- p_options: optional JSON object with additional fields to set on the user record
|
|
243
|
-
CREATE OR REPLACE FUNCTION register_user(p_email TEXT, p_password TEXT, p_options
|
|
243
|
+
CREATE OR REPLACE FUNCTION register_user(p_email TEXT, p_password TEXT, p_options JSON DEFAULT NULL)
|
|
244
244
|
RETURNS JSONB
|
|
245
245
|
LANGUAGE plpgsql
|
|
246
246
|
SECURITY DEFINER
|
|
@@ -256,9 +256,10 @@ BEGIN
|
|
|
256
256
|
v_hash := crypt(p_password, v_salt);
|
|
257
257
|
|
|
258
258
|
-- Build insert data: options fields + email + password_hash
|
|
259
|
+
-- Cast p_options to JSONB for internal operations (JSON type is for API boundary convenience)
|
|
259
260
|
v_insert_data := jsonb_build_object('email', p_email, 'password_hash', v_hash);
|
|
260
261
|
IF p_options IS NOT NULL THEN
|
|
261
|
-
v_insert_data := (p_options - 'id' - 'email' - 'password_hash' - 'password') || v_insert_data;
|
|
262
|
+
v_insert_data := (p_options::jsonb - 'id' - 'email' - 'password_hash' - 'password') || v_insert_data;
|
|
262
263
|
END IF;
|
|
263
264
|
|
|
264
265
|
-- Dynamic INSERT from JSONB
|
|
@@ -72,6 +72,72 @@ CREATE FUNCTION lookup_users(p_user_id INT, p_filter TEXT, ...)
|
|
|
72
72
|
CREATE FUNCTION search_users(p_user_id INT, p_filters JSONB, ...)
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
+
## JSON vs JSONB for Function Parameters
|
|
76
|
+
|
|
77
|
+
### External API Parameters: Use JSON
|
|
78
|
+
|
|
79
|
+
When defining function parameters that accept JSON from external callers (API boundary), use `JSON` type (text-based) rather than `JSONB`. This allows callers to pass `JSON.stringify(options)` as a plain string without needing special serialization like `sql.json()`.
|
|
80
|
+
|
|
81
|
+
```sql
|
|
82
|
+
-- ✅ CORRECT - JSON for external input parameters
|
|
83
|
+
CREATE FUNCTION register_user(
|
|
84
|
+
p_email TEXT,
|
|
85
|
+
p_password TEXT,
|
|
86
|
+
p_options JSON DEFAULT NULL -- Accepts plain JSON string from API
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
-- ❌ WRONG - JSONB requires special serialization from clients
|
|
90
|
+
CREATE FUNCTION register_user(
|
|
91
|
+
p_email TEXT,
|
|
92
|
+
p_password TEXT,
|
|
93
|
+
p_options JSONB DEFAULT NULL -- Harder to call from JavaScript
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Internal Operations: Cast to JSONB
|
|
98
|
+
|
|
99
|
+
Inside the function, cast to JSONB if you need JSONB operators (`->`, `->>`, `-`, `||`, `?`, etc.):
|
|
100
|
+
|
|
101
|
+
```sql
|
|
102
|
+
CREATE FUNCTION register_user(p_email TEXT, p_password TEXT, p_options JSON DEFAULT NULL)
|
|
103
|
+
RETURNS JSONB AS $$
|
|
104
|
+
DECLARE
|
|
105
|
+
v_insert_data JSONB;
|
|
106
|
+
BEGIN
|
|
107
|
+
v_insert_data := jsonb_build_object('email', p_email);
|
|
108
|
+
|
|
109
|
+
IF p_options IS NOT NULL THEN
|
|
110
|
+
-- Cast to JSONB for internal operations
|
|
111
|
+
v_insert_data := (p_options::jsonb - 'id' - 'password') || v_insert_data;
|
|
112
|
+
END IF;
|
|
113
|
+
|
|
114
|
+
-- ...
|
|
115
|
+
END;
|
|
116
|
+
$$ LANGUAGE plpgsql;
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Table Columns: Use JSONB
|
|
120
|
+
|
|
121
|
+
Table columns should still use `JSONB` for efficient storage and indexing:
|
|
122
|
+
|
|
123
|
+
```sql
|
|
124
|
+
-- ✅ CORRECT - JSONB for table columns
|
|
125
|
+
CREATE TABLE users (
|
|
126
|
+
id SERIAL PRIMARY KEY,
|
|
127
|
+
metadata JSONB DEFAULT '{}' -- Efficient storage & indexing
|
|
128
|
+
);
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Summary
|
|
132
|
+
|
|
133
|
+
| Context | Type | Reason |
|
|
134
|
+
|---------|------|--------|
|
|
135
|
+
| Function input parameters | `JSON` | Easy to pass from JavaScript (`JSON.stringify()`) |
|
|
136
|
+
| Internal function operations | `::jsonb` cast | Access to JSONB operators |
|
|
137
|
+
| Table columns | `JSONB` | Efficient storage and indexing |
|
|
138
|
+
|
|
139
|
+
This pattern - **JSON for input parameters, JSONB for storage** - eliminates serialization confusion at the API boundary.
|
|
140
|
+
|
|
75
141
|
## Function Categories
|
|
76
142
|
|
|
77
143
|
### Public API Functions (No underscore)
|
package/docs/compiler/README.md
CHANGED
|
@@ -87,6 +87,23 @@ SELECT dzql.register_entity(
|
|
|
87
87
|
|
|
88
88
|
See [Many-to-Many Guide](../guides/many-to-many.md) for details.
|
|
89
89
|
|
|
90
|
+
### Composite Primary Keys
|
|
91
|
+
```sql
|
|
92
|
+
SELECT dzql.register_entity(
|
|
93
|
+
'product_task_template_dependencies', 'template_id', ARRAY[]::text[],
|
|
94
|
+
'{}', false, '{}', '{}', '{}',
|
|
95
|
+
'{
|
|
96
|
+
"primary_key": ["template_id", "depends_on_template_id"]
|
|
97
|
+
}',
|
|
98
|
+
'{}'
|
|
99
|
+
);
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Generated code:** Event records use all primary key columns
|
|
103
|
+
- Default assumes `id` column
|
|
104
|
+
- Composite keys generate `jsonb_build_object('col1', v_result.col1, 'col2', v_result.col2)`
|
|
105
|
+
- Required for junction tables and other composite PK scenarios
|
|
106
|
+
|
|
90
107
|
### Field Defaults
|
|
91
108
|
```sql
|
|
92
109
|
'{
|
package/docs/reference/api.md
CHANGED
|
@@ -252,9 +252,14 @@ SELECT dzql.register_entity(
|
|
|
252
252
|
| `p_temporal_fields` | JSONB | no | Temporal field config (valid_from/valid_to) |
|
|
253
253
|
| `p_notification_paths` | JSONB | no | Who receives real-time updates |
|
|
254
254
|
| `p_permission_paths` | JSONB | no | CRUD permission rules |
|
|
255
|
-
| `p_graph_rules` | JSONB | no | Automatic relationship management
|
|
255
|
+
| `p_graph_rules` | JSONB | no | Automatic relationship management, M2M, and primary_key |
|
|
256
256
|
| `p_field_defaults` | JSONB | no | Auto-populate fields on INSERT |
|
|
257
257
|
|
|
258
|
+
**Note:** `p_graph_rules` can include:
|
|
259
|
+
- `primary_key` - Array of column names for composite primary keys (default: `["id"]`)
|
|
260
|
+
- `many_to_many` - M2M relationship configurations
|
|
261
|
+
- `on_create`, `on_update`, `on_delete` - Graph rule triggers
|
|
262
|
+
|
|
258
263
|
### FK Includes
|
|
259
264
|
|
|
260
265
|
Configure which foreign keys to dereference in GET operations:
|
|
@@ -330,6 +335,39 @@ Auto-populate fields on INSERT with values or variables:
|
|
|
330
335
|
|
|
331
336
|
See [Field Defaults Guide](../guides/field-defaults.md) for details.
|
|
332
337
|
|
|
338
|
+
### Composite Primary Keys
|
|
339
|
+
|
|
340
|
+
For tables with composite primary keys (not just `id`), specify the primary key columns via `graph_rules.primary_key`:
|
|
341
|
+
|
|
342
|
+
```sql
|
|
343
|
+
'{
|
|
344
|
+
"primary_key": ["template_id", "depends_on_template_id"]
|
|
345
|
+
}'
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
**Or in JavaScript:**
|
|
349
|
+
```javascript
|
|
350
|
+
{
|
|
351
|
+
tableName: "product_task_template_dependencies",
|
|
352
|
+
primaryKey: ["template_id", "depends_on_template_id"],
|
|
353
|
+
// ...
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
**Default:** `["id"]` - assumes a single `id` column as primary key.
|
|
358
|
+
|
|
359
|
+
**Why this matters:** The compiler generates event records with a `pk` field containing the primary key values. Without this configuration, tables with composite primary keys would fail with "record has no field id" errors.
|
|
360
|
+
|
|
361
|
+
**Generated SQL (composite):**
|
|
362
|
+
```sql
|
|
363
|
+
jsonb_build_object('template_id', v_result.template_id, 'depends_on_template_id', v_result.depends_on_template_id)
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
**Generated SQL (simple, default):**
|
|
367
|
+
```sql
|
|
368
|
+
jsonb_build_object('id', v_result.id)
|
|
369
|
+
```
|
|
370
|
+
|
|
333
371
|
### Many-to-Many Relationships
|
|
334
372
|
|
|
335
373
|
Configure M2M relationships via `graph_rules.many_to_many`:
|
package/package.json
CHANGED
|
@@ -69,7 +69,7 @@ $$;`;
|
|
|
69
69
|
-- p_options: optional JSON object with additional fields to set on the user record
|
|
70
70
|
-- Example: register_user('test@example.com', 'password', '{"name": "Test User"}')
|
|
71
71
|
-- ============================================================================
|
|
72
|
-
CREATE OR REPLACE FUNCTION register_user(p_email TEXT, p_password TEXT, p_options
|
|
72
|
+
CREATE OR REPLACE FUNCTION register_user(p_email TEXT, p_password TEXT, p_options JSON DEFAULT NULL)
|
|
73
73
|
RETURNS JSONB
|
|
74
74
|
LANGUAGE plpgsql
|
|
75
75
|
SECURITY DEFINER
|
|
@@ -85,9 +85,10 @@ BEGIN
|
|
|
85
85
|
v_hash := crypt(p_password, v_salt);
|
|
86
86
|
|
|
87
87
|
-- Build insert data: options fields + email + password_hash (options cannot override core fields)
|
|
88
|
+
-- Cast p_options to JSONB for internal operations (JSON type is for API boundary convenience)
|
|
88
89
|
v_insert_data := jsonb_build_object('email', p_email, 'password_hash', v_hash);
|
|
89
90
|
IF p_options IS NOT NULL THEN
|
|
90
|
-
v_insert_data := (p_options - 'id' - 'email' - 'password_hash' - 'password') || v_insert_data;
|
|
91
|
+
v_insert_data := (p_options::jsonb - 'id' - 'email' - 'password_hash' - 'password') || v_insert_data;
|
|
91
92
|
END IF;
|
|
92
93
|
|
|
93
94
|
-- Dynamic INSERT from JSONB (same pattern as compiled save functions)
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
export class GraphRulesCodegen {
|
|
7
|
-
constructor(tableName, graphRules) {
|
|
7
|
+
constructor(tableName, graphRules, primaryKey = ['id']) {
|
|
8
8
|
this.tableName = tableName;
|
|
9
9
|
this.graphRules = graphRules;
|
|
10
|
+
this.primaryKey = Array.isArray(primaryKey) ? primaryKey : [primaryKey];
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -233,6 +234,20 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
233
234
|
PERFORM ${functionName}(${paramSQL});`;
|
|
234
235
|
}
|
|
235
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Generate jsonb_build_object for primary key columns from a JSONB record variable
|
|
239
|
+
* Supports composite primary keys
|
|
240
|
+
* @param {string} recordVar - The JSONB record variable name (e.g., 'p_record')
|
|
241
|
+
* @private
|
|
242
|
+
*/
|
|
243
|
+
_generatePKBuildObject(recordVar = 'p_record') {
|
|
244
|
+
// Build jsonb_build_object with all primary key columns
|
|
245
|
+
// e.g., jsonb_build_object('id', (p_record->>'id')::int)
|
|
246
|
+
// or jsonb_build_object('template_id', (p_record->>'template_id')::int, 'depends_on_template_id', (p_record->>'depends_on_template_id')::int)
|
|
247
|
+
const pairs = this.primaryKey.map(col => `'${col}', (${recordVar}->>'${col}')::int`);
|
|
248
|
+
return `jsonb_build_object(${pairs.join(', ')})`;
|
|
249
|
+
}
|
|
250
|
+
|
|
236
251
|
/**
|
|
237
252
|
* Generate NOTIFY action
|
|
238
253
|
* Creates an event that will be broadcast to specified users
|
|
@@ -298,6 +313,8 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
298
313
|
? `jsonb_build_object(${dataFields.join(', ')})`
|
|
299
314
|
: "'{}'::jsonb";
|
|
300
315
|
|
|
316
|
+
const pkBuildObject = this._generatePKBuildObject('p_record');
|
|
317
|
+
|
|
301
318
|
return `${comment}
|
|
302
319
|
-- Create notification event
|
|
303
320
|
INSERT INTO dzql.events (
|
|
@@ -310,7 +327,7 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
310
327
|
) VALUES (
|
|
311
328
|
'${this.tableName}',
|
|
312
329
|
'notify',
|
|
313
|
-
|
|
330
|
+
${pkBuildObject},
|
|
314
331
|
${dataSQL},
|
|
315
332
|
p_user_id,
|
|
316
333
|
${userIdSQL}
|
|
@@ -407,9 +424,10 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
407
424
|
* Generate graph rule functions for an entity
|
|
408
425
|
* @param {string} tableName - Table name
|
|
409
426
|
* @param {Object} graphRules - Graph rules object
|
|
427
|
+
* @param {Array<string>} primaryKey - Primary key column(s) (defaults to ['id'])
|
|
410
428
|
* @returns {string} SQL for graph rule functions
|
|
411
429
|
*/
|
|
412
|
-
export function generateGraphRuleFunctions(tableName, graphRules) {
|
|
413
|
-
const codegen = new GraphRulesCodegen(tableName, graphRules);
|
|
430
|
+
export function generateGraphRuleFunctions(tableName, graphRules, primaryKey = ['id']) {
|
|
431
|
+
const codegen = new GraphRulesCodegen(tableName, graphRules, primaryKey);
|
|
414
432
|
return codegen.generate();
|
|
415
433
|
}
|
|
@@ -855,12 +855,29 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
855
855
|
return calls.join('');
|
|
856
856
|
}
|
|
857
857
|
|
|
858
|
+
/**
|
|
859
|
+
* Generate jsonb_build_object for primary key columns
|
|
860
|
+
* Supports composite primary keys by building an object with all pk columns
|
|
861
|
+
* @param {string} recordVar - The record variable name (e.g., 'v_result', 'v_existing')
|
|
862
|
+
* @private
|
|
863
|
+
*/
|
|
864
|
+
_generatePKBuildObject(recordVar = 'v_result') {
|
|
865
|
+
const primaryKey = this.entity.primaryKey || ['id'];
|
|
866
|
+
|
|
867
|
+
// Build jsonb_build_object with all primary key columns
|
|
868
|
+
// e.g., jsonb_build_object('id', v_result.id)
|
|
869
|
+
// or jsonb_build_object('template_id', v_result.template_id, 'depends_on_template_id', v_result.depends_on_template_id)
|
|
870
|
+
const pairs = primaryKey.map(col => `'${col}', ${recordVar}.${col}`);
|
|
871
|
+
return `jsonb_build_object(${pairs.join(', ')})`;
|
|
872
|
+
}
|
|
873
|
+
|
|
858
874
|
/**
|
|
859
875
|
* Generate notification SQL
|
|
860
876
|
* @private
|
|
861
877
|
*/
|
|
862
878
|
_generateNotificationSQL(operation = 'save') {
|
|
863
879
|
const hasNotificationPaths = this.entity.notificationPaths && Object.keys(this.entity.notificationPaths).length > 0;
|
|
880
|
+
const pkBuildObject = this._generatePKBuildObject('v_result');
|
|
864
881
|
|
|
865
882
|
if (operation === 'save') {
|
|
866
883
|
return `
|
|
@@ -878,7 +895,7 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
878
895
|
) VALUES (
|
|
879
896
|
'${this.tableName}',
|
|
880
897
|
CASE WHEN v_is_insert THEN 'insert' ELSE 'update' END,
|
|
881
|
-
|
|
898
|
+
${pkBuildObject},
|
|
882
899
|
v_output,
|
|
883
900
|
p_user_id,
|
|
884
901
|
v_notify_users
|
|
@@ -899,7 +916,7 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
899
916
|
) VALUES (
|
|
900
917
|
'${this.tableName}',
|
|
901
918
|
'delete',
|
|
902
|
-
|
|
919
|
+
${pkBuildObject},
|
|
903
920
|
NULL,
|
|
904
921
|
p_user_id,
|
|
905
922
|
v_notify_users
|
package/src/compiler/compiler.js
CHANGED
|
@@ -95,6 +95,9 @@ export class EntityParser {
|
|
|
95
95
|
// Extract many_to_many from graph_rules if present
|
|
96
96
|
const manyToMany = graphRules.many_to_many || {};
|
|
97
97
|
|
|
98
|
+
// Extract primary_key from graph_rules if present (defaults to ['id'])
|
|
99
|
+
const primaryKey = graphRules.primary_key || ['id'];
|
|
100
|
+
|
|
98
101
|
const config = {
|
|
99
102
|
tableName: this._cleanString(params[0]),
|
|
100
103
|
labelField: this._cleanString(params[1]),
|
|
@@ -106,7 +109,8 @@ export class EntityParser {
|
|
|
106
109
|
permissionPaths: params[7] ? this._parseJSON(params[7]) : {},
|
|
107
110
|
graphRules: graphRules,
|
|
108
111
|
fieldDefaults: params[9] ? this._parseJSON(params[9]) : {},
|
|
109
|
-
manyToMany: manyToMany
|
|
112
|
+
manyToMany: manyToMany,
|
|
113
|
+
primaryKey: Array.isArray(primaryKey) ? primaryKey : [primaryKey]
|
|
110
114
|
};
|
|
111
115
|
|
|
112
116
|
return config;
|
|
@@ -328,6 +332,8 @@ export class EntityParser {
|
|
|
328
332
|
parseFromObject(entity) {
|
|
329
333
|
const graphRules = entity.graphRules || {};
|
|
330
334
|
const manyToMany = entity.manyToMany || graphRules.many_to_many || {};
|
|
335
|
+
// Primary key can be specified directly on entity or in graphRules (defaults to ['id'])
|
|
336
|
+
const primaryKey = entity.primaryKey || graphRules.primary_key || ['id'];
|
|
331
337
|
|
|
332
338
|
return {
|
|
333
339
|
tableName: entity.tableName || entity.table,
|
|
@@ -341,6 +347,7 @@ export class EntityParser {
|
|
|
341
347
|
graphRules: graphRules,
|
|
342
348
|
fieldDefaults: entity.fieldDefaults || {},
|
|
343
349
|
manyToMany: manyToMany,
|
|
350
|
+
primaryKey: Array.isArray(primaryKey) ? primaryKey : [primaryKey],
|
|
344
351
|
customFunctions: entity.customFunctions || []
|
|
345
352
|
};
|
|
346
353
|
}
|