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 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 JSONB DEFAULT NULL)
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)
@@ -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
  '{
@@ -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 + M2M |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.5.0",
3
+ "version": "0.5.3",
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",
@@ -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 JSONB DEFAULT NULL)
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
- jsonb_build_object('id', (p_record->>'id')::int),
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
- jsonb_build_object('id', v_result.id),
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
- jsonb_build_object('id', v_result.id),
919
+ ${pkBuildObject},
903
920
  NULL,
904
921
  p_user_id,
905
922
  v_notify_users
@@ -258,7 +258,8 @@ export class DZQLCompiler {
258
258
  _generateGraphRuleFunctions(entity) {
259
259
  return generateGraphRuleFunctions(
260
260
  entity.tableName,
261
- entity.graphRules
261
+ entity.graphRules,
262
+ entity.primaryKey
262
263
  );
263
264
  }
264
265
 
@@ -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
  }