dzql 0.5.22 → 0.5.24

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.5.22",
3
+ "version": "0.5.24",
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",
@@ -11,6 +11,18 @@ export class OperationCodegen {
11
11
  this.isCompositePK = this.primaryKey.length > 1 || this.primaryKey[0] !== 'id';
12
12
  }
13
13
 
14
+ /**
15
+ * Determine appropriate type cast for a column name
16
+ * Only casts to int if column is 'id' or ends with '_id'
17
+ * @private
18
+ */
19
+ _getTypeCast(columnName) {
20
+ if (columnName === 'id' || columnName.endsWith('_id')) {
21
+ return '::int';
22
+ }
23
+ return '';
24
+ }
25
+
14
26
  /**
15
27
  * Generate all operation functions
16
28
  * @returns {string} SQL for all operations
@@ -36,7 +48,7 @@ export class OperationCodegen {
36
48
  // For composite PKs, accept JSONB containing all PK fields
37
49
  // For simple PKs, accept INT for backwards compatibility
38
50
  if (this.isCompositePK) {
39
- const whereClause = this.primaryKey.map(col => `${col} = (p_pk->>'${col}')::int`).join(' AND ');
51
+ const whereClause = this.primaryKey.map(col => `${col} = (p_pk->>'${col}')${this._getTypeCast(col)}`).join(' AND ');
40
52
  const pkDescription = this.primaryKey.join(', ');
41
53
 
42
54
  return `-- GET operation for ${this.tableName} (composite primary key: ${pkDescription})
@@ -125,6 +137,21 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
125
137
  const m2mExpansion = this._generateM2MExpansion();
126
138
  const fieldDefaults = this._generateFieldDefaults();
127
139
 
140
+ // For composite PKs, generate a different function signature and logic
141
+ if (this.isCompositePK) {
142
+ return this._generateCompositePKSaveFunction({
143
+ graphRulesCall,
144
+ notificationSQL,
145
+ filterSensitiveFields,
146
+ m2mVariables,
147
+ m2mExtraction,
148
+ m2mSync,
149
+ m2mExpansion,
150
+ fieldDefaults
151
+ });
152
+ }
153
+
154
+ // Simple PK (id column) - original implementation for backwards compatibility
128
155
  return `-- SAVE operation for ${this.tableName}
129
156
  CREATE OR REPLACE FUNCTION save_${this.tableName}(
130
157
  p_user_id INT,
@@ -207,6 +234,141 @@ ${m2mExpansion}
207
234
  ${graphRulesCall}
208
235
  ${notificationSQL}
209
236
 
237
+ ${filterSensitiveFields}
238
+
239
+ RETURN v_output;
240
+ END;
241
+ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
242
+ }
243
+
244
+ /**
245
+ * Generate SAVE function for composite primary keys
246
+ * @private
247
+ */
248
+ _generateCompositePKSaveFunction(helpers) {
249
+ const {
250
+ graphRulesCall,
251
+ notificationSQL,
252
+ filterSensitiveFields,
253
+ m2mVariables,
254
+ m2mExtraction,
255
+ m2mSync,
256
+ m2mExpansion,
257
+ fieldDefaults
258
+ } = helpers;
259
+
260
+ const pkDescription = this.primaryKey.join(', ');
261
+
262
+ // Generate WHERE clause for composite PK lookup
263
+ // e.g., "entity_type = (p_data->>'entity_type') AND entity_id = (p_data->>'entity_id')::int"
264
+ const whereClause = this.primaryKey.map(col => {
265
+ return `${col} = (p_data->>'${col}')${this._getTypeCast(col)}`;
266
+ }).join(' AND ');
267
+
268
+ // For use inside format() string literal with %L placeholders
269
+ // e.g., "entity_type = %L AND entity_id = %L"
270
+ const whereClauseForFormat = this.primaryKey.map(col => `${col} = %L`).join(' AND ');
271
+
272
+ // Format arguments for the WHERE clause (to be passed to format())
273
+ // e.g., "(p_data->>'entity_type'), (p_data->>'entity_id')::int"
274
+ const whereClauseFormatArgs = this.primaryKey.map(col => {
275
+ return `(p_data->>'${col}')${this._getTypeCast(col)}`;
276
+ }).join(', ');
277
+
278
+ // Check if all PK fields are present in p_data
279
+ const pkNullCheck = this.primaryKey.map(col => `p_data->>'${col}' IS NULL`).join(' OR ');
280
+
281
+ // Build the list of PK field names for exclusion from SET clause
282
+ const pkFieldsExclusion = this.primaryKey.map(col => `jsonb_object_keys != '${col}'`).join(' AND ');
283
+
284
+ // For M2M sync and expansion, we need to reference the PK fields from v_result
285
+ const m2mSyncCompositePK = this._generateM2MSyncCompositePK();
286
+ const m2mExpansionCompositePK = this._generateM2MExpansionCompositePK();
287
+ const m2mExpansionForBeforeCompositePK = this._generateM2MExpansionForBeforeCompositePK();
288
+
289
+ return `-- SAVE operation for ${this.tableName} (composite primary key: ${pkDescription})
290
+ CREATE OR REPLACE FUNCTION save_${this.tableName}(
291
+ p_user_id INT,
292
+ p_data JSONB
293
+ ) RETURNS JSONB AS $$
294
+ DECLARE
295
+ v_result ${this.tableName}%ROWTYPE;
296
+ v_existing ${this.tableName}%ROWTYPE;
297
+ v_output JSONB;
298
+ v_before JSONB;
299
+ v_is_insert BOOLEAN := false;
300
+ v_notify_users INT[];
301
+ ${m2mVariables}
302
+ BEGIN
303
+ ${m2mExtraction}
304
+ -- Determine if this is insert or update (composite PK: ${pkDescription})
305
+ IF ${pkNullCheck} THEN
306
+ -- Missing one or more PK fields - this is an insert
307
+ v_is_insert := true;
308
+ ELSE
309
+ -- Try to fetch existing record by composite PK
310
+ SELECT * INTO v_existing
311
+ FROM ${this.tableName}
312
+ WHERE ${whereClause};
313
+
314
+ v_is_insert := NOT FOUND;
315
+ END IF;
316
+
317
+ -- Check permissions
318
+ IF v_is_insert THEN
319
+ IF NOT can_create_${this.tableName}(p_user_id, p_data) THEN
320
+ RAISE EXCEPTION 'Permission denied: create on ${this.tableName}';
321
+ END IF;
322
+ ELSE
323
+ IF NOT can_update_${this.tableName}(p_user_id, to_jsonb(v_existing)) THEN
324
+ RAISE EXCEPTION 'Permission denied: update on ${this.tableName}';
325
+ END IF;
326
+ END IF;
327
+
328
+ -- Expand M2M for existing record (for UPDATE events "before" field)
329
+ IF NOT v_is_insert THEN
330
+ v_before := to_jsonb(v_existing);
331
+ ${m2mExpansionForBeforeCompositePK}
332
+ END IF;
333
+ ${fieldDefaults}
334
+ -- Perform UPSERT
335
+ IF v_is_insert THEN
336
+ -- Dynamic INSERT from JSONB
337
+ EXECUTE (
338
+ SELECT format(
339
+ 'INSERT INTO ${this.tableName} (%s) VALUES (%s) RETURNING *',
340
+ string_agg(quote_ident(key), ', '),
341
+ string_agg(quote_nullable(value), ', ')
342
+ )
343
+ FROM jsonb_each_text(p_data) kv(key, value)
344
+ ) INTO v_result;
345
+ ELSE
346
+ -- Dynamic UPDATE from JSONB (only if there are non-PK fields to update)
347
+ IF (SELECT COUNT(*) FROM jsonb_object_keys(p_data) WHERE ${pkFieldsExclusion}) > 0 THEN
348
+ EXECUTE (
349
+ SELECT format(
350
+ 'UPDATE ${this.tableName} SET %s WHERE ${whereClauseForFormat} RETURNING *',
351
+ string_agg(quote_ident(key) || ' = ' || quote_nullable(value), ', '),
352
+ ${whereClauseFormatArgs}
353
+ )
354
+ FROM jsonb_each_text(p_data) kv(key, value)
355
+ WHERE ${this.primaryKey.map(col => `key != '${col}'`).join(' AND ')}
356
+ ) INTO v_result;
357
+ ELSE
358
+ -- No fields to update (only M2M fields were provided), just fetch existing
359
+ v_result := v_existing;
360
+ END IF;
361
+ END IF;
362
+
363
+ ${m2mSyncCompositePK}
364
+
365
+ -- Prepare output with M2M fields (BEFORE event creation for real-time notifications!)
366
+ v_output := to_jsonb(v_result);
367
+ ${m2mExpansionCompositePK}
368
+
369
+ ${graphRulesCall}
370
+ ${notificationSQL}
371
+
210
372
  ${filterSensitiveFields}
211
373
 
212
374
  RETURN v_output;
@@ -224,7 +386,7 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
224
386
 
225
387
  // For composite PKs, accept JSONB containing all PK fields
226
388
  if (this.isCompositePK) {
227
- const whereClause = this.primaryKey.map(col => `${col} = (p_pk->>'${col}')::int`).join(' AND ');
389
+ const whereClause = this.primaryKey.map(col => `${col} = (p_pk->>'${col}')${this._getTypeCast(col)}`).join(' AND ');
228
390
  const pkDescription = this.primaryKey.join(', ');
229
391
 
230
392
  const deleteSQL = this.entity.softDelete
@@ -740,6 +902,129 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
740
902
  return expansions.join('');
741
903
  }
742
904
 
905
+ /**
906
+ * Generate M2M sync for composite primary keys
907
+ * For tables with composite PKs, M2M relationships are rare but possible
908
+ * @private
909
+ */
910
+ _generateM2MSyncCompositePK() {
911
+ const manyToMany = this.entity.manyToMany || {};
912
+ if (Object.keys(manyToMany).length === 0) return '';
913
+
914
+ // For composite PKs, we need to build a composite key reference
915
+ // This is a rare case - most tables with composite PKs are junction tables themselves
916
+ const syncs = [];
917
+
918
+ for (const [relationKey, config] of Object.entries(manyToMany)) {
919
+ const idField = config.id_field;
920
+ const junctionTable = config.junction_table;
921
+ const localKey = config.local_key;
922
+ const foreignKey = config.foreign_key;
923
+
924
+ // For composite PK tables with M2M, we'd need a different junction table structure
925
+ // This is an edge case - log a warning comment in the generated SQL
926
+ syncs.push(`
927
+ -- ============================================================================
928
+ -- M2M Sync: ${relationKey} (junction: ${junctionTable}) - COMPOSITE PK
929
+ -- Note: M2M on composite PK tables requires junction table to reference all PK columns
930
+ -- ============================================================================
931
+ IF v_${idField} IS NOT NULL THEN
932
+ -- Delete relationships not in new list (using first PK column as reference)
933
+ DELETE FROM ${junctionTable}
934
+ WHERE ${localKey} = v_result.${this.primaryKey[0]}
935
+ AND (${foreignKey} <> ALL(v_${idField}) OR v_${idField} = '{}');
936
+
937
+ -- Insert new relationships (idempotent)
938
+ IF array_length(v_${idField}, 1) > 0 THEN
939
+ INSERT INTO ${junctionTable} (${localKey}, ${foreignKey})
940
+ SELECT v_result.${this.primaryKey[0]}, unnest(v_${idField})
941
+ ON CONFLICT (${localKey}, ${foreignKey}) DO NOTHING;
942
+ END IF;
943
+ END IF;`);
944
+ }
945
+
946
+ return syncs.join('');
947
+ }
948
+
949
+ /**
950
+ * Generate M2M expansion for composite primary keys (for SAVE output)
951
+ * @private
952
+ */
953
+ _generateM2MExpansionCompositePK() {
954
+ const manyToMany = this.entity.manyToMany || {};
955
+ if (Object.keys(manyToMany).length === 0) return '';
956
+
957
+ const expansions = [];
958
+
959
+ for (const [relationKey, config] of Object.entries(manyToMany)) {
960
+ const idField = config.id_field;
961
+ const junctionTable = config.junction_table;
962
+ const localKey = config.local_key;
963
+ const foreignKey = config.foreign_key;
964
+ const targetEntity = config.target_entity;
965
+ const expand = config.expand || false;
966
+
967
+ // Use first PK column for M2M lookups
968
+ expansions.push(`
969
+ -- Add M2M IDs: ${idField} (composite PK table)
970
+ v_output := v_output || jsonb_build_object('${idField}',
971
+ (SELECT COALESCE(jsonb_agg(${foreignKey} ORDER BY ${foreignKey}), '[]'::jsonb)
972
+ FROM ${junctionTable} WHERE ${localKey} = v_result.${this.primaryKey[0]})
973
+ );`);
974
+
975
+ if (expand) {
976
+ expansions.push(`
977
+ -- Expand M2M objects: ${relationKey} (expand=true, composite PK table)
978
+ v_output := v_output || jsonb_build_object('${relationKey}',
979
+ (SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
980
+ FROM ${junctionTable} jt
981
+ JOIN ${targetEntity} t ON t.id = jt.${foreignKey}
982
+ WHERE jt.${localKey} = v_result.${this.primaryKey[0]})
983
+ );`);
984
+ }
985
+ }
986
+
987
+ return expansions.join('');
988
+ }
989
+
990
+ /**
991
+ * Generate M2M expansion for "before" record with composite primary keys
992
+ * @private
993
+ */
994
+ _generateM2MExpansionForBeforeCompositePK() {
995
+ const manyToMany = this.entity.manyToMany || {};
996
+ if (Object.keys(manyToMany).length === 0) return '';
997
+
998
+ const expansions = [];
999
+
1000
+ for (const [relationKey, config] of Object.entries(manyToMany)) {
1001
+ const idField = config.id_field;
1002
+ const junctionTable = config.junction_table;
1003
+ const localKey = config.local_key;
1004
+ const foreignKey = config.foreign_key;
1005
+ const targetEntity = config.target_entity;
1006
+ const expand = config.expand || false;
1007
+
1008
+ expansions.push(`
1009
+ v_before := v_before || jsonb_build_object('${idField}',
1010
+ (SELECT COALESCE(jsonb_agg(${foreignKey} ORDER BY ${foreignKey}), '[]'::jsonb)
1011
+ FROM ${junctionTable} WHERE ${localKey} = v_existing.${this.primaryKey[0]})
1012
+ );`);
1013
+
1014
+ if (expand) {
1015
+ expansions.push(`
1016
+ v_before := v_before || jsonb_build_object('${relationKey}',
1017
+ (SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
1018
+ FROM ${junctionTable} jt
1019
+ JOIN ${targetEntity} t ON t.id = jt.${foreignKey}
1020
+ WHERE jt.${localKey} = v_existing.${this.primaryKey[0]})
1021
+ );`);
1022
+ }
1023
+ }
1024
+
1025
+ return expansions.join('');
1026
+ }
1027
+
743
1028
  /**
744
1029
  * Generate FK expansions for GET
745
1030
  * @private
@@ -1001,6 +1286,7 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
1001
1286
  ${hasNotificationPaths ? `v_notify_users := _resolve_notification_paths_${this.tableName}(p_user_id, to_jsonb(v_result));` : 'v_notify_users := ARRAY[]::INT[];'}
1002
1287
 
1003
1288
  -- Create event for real-time notifications
1289
+ -- Include full record data so _affected_documents can resolve subscription FKs
1004
1290
  INSERT INTO dzql.events (
1005
1291
  table_name,
1006
1292
  op,
@@ -1012,7 +1298,7 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
1012
1298
  '${this.tableName}',
1013
1299
  'delete',
1014
1300
  ${pkBuildObject},
1015
- NULL,
1301
+ to_jsonb(v_result),
1016
1302
  p_user_id,
1017
1303
  v_notify_users
1018
1304
  );`;
@@ -10,6 +10,7 @@ const DEFAULT_USER_ID = 1;
10
10
 
11
11
  /**
12
12
  * Discover available entities from dzql.entities table or compiled functions
13
+ * @returns {Promise<Object>} Map of entity name to {label, searchable, description}
13
14
  */
14
15
  async function discoverEntities() {
15
16
  // First try dzql.entities table (runtime mode)
@@ -58,6 +59,9 @@ async function discoverEntities() {
58
59
  /**
59
60
  * DZQL operations namespace - provides CLI-style access to DZQL operations
60
61
  *
62
+ * Each method outputs JSON to console and calls sql.end() before returning,
63
+ * making instances single-use. On error, methods call process.exit(1).
64
+ *
61
65
  * Usage in tasks.js:
62
66
  * ```js
63
67
  * import { DzqlNamespace } from 'dzql/namespace';
@@ -70,11 +74,17 @@ async function discoverEntities() {
70
74
  * ```
71
75
  */
72
76
  export class DzqlNamespace {
77
+ /**
78
+ * @param {number} [userId=1] - User ID for permission checks
79
+ */
73
80
  constructor(userId = DEFAULT_USER_ID) {
74
81
  this.userId = userId;
75
82
  }
76
83
 
77
- /** List all available entities */
84
+ /**
85
+ * List all available entities
86
+ * @returns {Promise<void>} Outputs JSON to console
87
+ */
78
88
  async entities(c) {
79
89
  try {
80
90
  const entities = await discoverEntities();
@@ -89,7 +99,12 @@ export class DzqlNamespace {
89
99
  }
90
100
  }
91
101
 
92
- /** Search an entity */
102
+ /**
103
+ * Search an entity
104
+ * @example invj dzql:search venues '{"query": "test"}'
105
+ * @param {string} entity - Entity/table name to search
106
+ * @param {string} [argsJson] - JSON string with search args (query, limit, offset, filters)
107
+ */
93
108
  async search(c, entity, argsJson = "{}") {
94
109
  if (!entity) {
95
110
  console.error("Error: entity name required");
@@ -123,7 +138,12 @@ export class DzqlNamespace {
123
138
  }
124
139
  }
125
140
 
126
- /** Get entity by ID */
141
+ /**
142
+ * Get entity by ID
143
+ * @example invj dzql:get venues '{"id": 1}'
144
+ * @param {string} entity - Entity/table name
145
+ * @param {string} [argsJson] - JSON string with {id: number}
146
+ */
127
147
  async get(c, entity, argsJson = "{}") {
128
148
  if (!entity) {
129
149
  console.error("Error: entity name required");
@@ -155,7 +175,12 @@ export class DzqlNamespace {
155
175
  }
156
176
  }
157
177
 
158
- /** Save (create or update) entity */
178
+ /**
179
+ * Save (create or update) entity
180
+ * @example invj dzql:save venues '{"name": "New Venue", "org_id": 1}'
181
+ * @param {string} entity - Entity/table name
182
+ * @param {string} [argsJson] - JSON string with entity data (include id to update, omit to create)
183
+ */
159
184
  async save(c, entity, argsJson = "{}") {
160
185
  if (!entity) {
161
186
  console.error("Error: entity name required");
@@ -189,7 +214,12 @@ export class DzqlNamespace {
189
214
  }
190
215
  }
191
216
 
192
- /** Delete entity by ID */
217
+ /**
218
+ * Delete entity by ID
219
+ * @example invj dzql:delete venues '{"id": 1}'
220
+ * @param {string} entity - Entity/table name
221
+ * @param {string} [argsJson] - JSON string with {id: number}
222
+ */
193
223
  async delete(c, entity, argsJson = "{}") {
194
224
  if (!entity) {
195
225
  console.error("Error: entity name required");
@@ -221,7 +251,12 @@ export class DzqlNamespace {
221
251
  }
222
252
  }
223
253
 
224
- /** Lookup entity (for dropdowns/autocomplete) */
254
+ /**
255
+ * Lookup entity (for dropdowns/autocomplete)
256
+ * @example invj dzql:lookup organisations '{"query": "acme"}'
257
+ * @param {string} entity - Entity/table name
258
+ * @param {string} [argsJson] - JSON string with {query: string, limit?: number}
259
+ */
225
260
  async lookup(c, entity, argsJson = "{}") {
226
261
  if (!entity) {
227
262
  console.error("Error: entity name required");