dzql 0.1.0 → 0.1.2

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  DZQL is a PostgreSQL framework that gives you **atomic real-time updates** via WebSocket. Every database change broadcasts instantly to all connected clients. Zero boilerplate.
4
4
 
5
- > **See also:** [REFERENCE.md](REFERENCE.md) for complete API documentation | [CLAUDE.md](../../CLAUDE.md) for AI development guide
5
+ > **See also:** [REFERENCE.md](REFERENCE.md) for complete API documentation | [CLAUDE.md](../../docs/CLAUDE.md) for AI development guide
6
6
 
7
7
  ## The Core Pattern
8
8
 
package/README.md CHANGED
@@ -9,7 +9,7 @@ All documentation is maintained in the repository root:
9
9
  - **[README.md](../../README.md)** - Project overview and quick start
10
10
  - **[GETTING_STARTED.md](GETTING_STARTED.md)** - Complete tutorial with working todo app
11
11
  - **[REFERENCE.md](REFERENCE.md)** - Complete API reference
12
- - **[CLAUDE.md](../../CLAUDE.md)** - Development guide for AI assistants
12
+ - **[CLAUDE.md](../../docs/CLAUDE.md)** - Development guide for AI assistants
13
13
  - **[Venues Example](../venues/)** - Full working application
14
14
 
15
15
  ## Quick Install
package/REFERENCE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DZQL API Reference
2
2
 
3
- Complete API documentation for DZQL framework. For tutorials, see [GETTING_STARTED.md](GETTING_STARTED.md). For AI development guide, see [CLAUDE.md](../../CLAUDE.md).
3
+ Complete API documentation for DZQL framework. For tutorials, see [GETTING_STARTED.md](GETTING_STARTED.md). For AI development guide, see [CLAUDE.md](../../docs/CLAUDE.md).
4
4
 
5
5
  ## Table of Contents
6
6
 
@@ -403,12 +403,16 @@ Automatically manage entity relationships when data changes.
403
403
  "on_create": {
404
404
  "rule_name": {
405
405
  "description": "Human-readable description",
406
+ "condition": "@after.field = 'value'", // Optional: only run if condition is true
406
407
  "actions": [
407
408
  {
408
- "type": "create|update|delete",
409
- "entity": "target_table",
409
+ "type": "create|update|delete|validate|execute",
410
+ "entity": "target_table", // for create/update/delete
410
411
  "data": {"field": "@variable"}, // for create/update
411
- "match": {"field": "@variable"} // for update/delete
412
+ "match": {"field": "@variable"}, // for update/delete
413
+ "function": "function_name", // for validate/execute
414
+ "params": {"param": "@variable"}, // for validate/execute
415
+ "error_message": "Validation failed" // for validate (optional)
412
416
  }
413
417
  ]
414
418
  }
@@ -425,6 +429,8 @@ Automatically manage entity relationships when data changes.
425
429
  | `create` | `entity`, `data` | INSERT new record |
426
430
  | `update` | `entity`, `match`, `data` | UPDATE matching records |
427
431
  | `delete` | `entity`, `match` | DELETE matching records |
432
+ | `validate` | `function`, `params`, `error_message` | Call validation function, rollback if returns false |
433
+ | `execute` | `function`, `params` | Fire-and-forget function execution |
428
434
 
429
435
  ### Variables
430
436
 
@@ -499,6 +505,69 @@ Variables reference data from the triggering operation:
499
505
  }
500
506
  ```
501
507
 
508
+ #### Data Validation
509
+ ```jsonb
510
+ {
511
+ "on_create": {
512
+ "validate_positive_price": {
513
+ "description": "Ensure price is positive",
514
+ "actions": [{
515
+ "type": "validate",
516
+ "function": "validate_positive_value",
517
+ "params": {"p_value": "@price"},
518
+ "error_message": "Price must be positive"
519
+ }]
520
+ }
521
+ }
522
+ }
523
+ ```
524
+
525
+ **Note:** Validation function must return BOOLEAN:
526
+ ```sql
527
+ CREATE FUNCTION validate_positive_value(p_value INT)
528
+ RETURNS BOOLEAN AS $$
529
+ SELECT p_value > 0;
530
+ $$ LANGUAGE sql;
531
+ ```
532
+
533
+ #### Conditional Execution
534
+ ```jsonb
535
+ {
536
+ "on_update": {
537
+ "prevent_posted_changes": {
538
+ "description": "Prevent modification of posted records",
539
+ "condition": "@before.status = 'posted'",
540
+ "actions": [{
541
+ "type": "validate",
542
+ "function": "always_false",
543
+ "params": {},
544
+ "error_message": "Cannot modify posted records"
545
+ }]
546
+ }
547
+ }
548
+ }
549
+ ```
550
+
551
+ **Available in conditions:** `@before.field`, `@after.field`, `@user_id`, and SQL expressions.
552
+
553
+ #### Fire-and-Forget Actions
554
+ ```jsonb
555
+ {
556
+ "on_create": {
557
+ "send_notification": {
558
+ "description": "Notify external system",
559
+ "actions": [{
560
+ "type": "execute",
561
+ "function": "log_event",
562
+ "params": {"p_event": "New record created", "p_record_id": "@id"}
563
+ }]
564
+ }
565
+ }
566
+ }
567
+ ```
568
+
569
+ **Note:** Execute actions don't affect transaction. Function errors are logged but don't rollback.
570
+
502
571
  ### Execution
503
572
 
504
573
  - **Atomic**: All rules execute in the same transaction
@@ -886,6 +955,6 @@ const result = await db.api.myCustomFunction({param: 'value'}, userId);
886
955
  ## See Also
887
956
 
888
957
  - [GETTING_STARTED.md](GETTING_STARTED.md) - Hands-on tutorial
889
- - [CLAUDE.md](../../CLAUDE.md) - AI development guide
958
+ - [CLAUDE.md](../../docs/CLAUDE.md) - AI development guide
890
959
  - [README.md](../../README.md) - Project overview
891
960
  - [Venues Example](../venues/) - Complete working application
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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",
@@ -16,17 +16,17 @@
16
16
  "README.md",
17
17
  "GETTING_STARTED.md",
18
18
  "REFERENCE.md",
19
- "CLAUDE.md",
20
19
  "LICENSE"
21
20
  ],
22
21
  "scripts": {
23
22
  "test": "bun test",
24
- "prepublishOnly": "echo '⚠️ Publishing DZQL alpha release...'"
23
+ "prepublishOnly": "echo 'Publishing DZQL v0.1.2...'"
25
24
  },
26
25
  "dependencies": {
27
26
  "jose": "^6.1.0",
28
27
  "postgres": "^3.4.7"
29
28
  },
29
+
30
30
  "keywords": [
31
31
  "postgresql",
32
32
  "postgres",
@@ -149,6 +149,82 @@ BEGIN
149
149
  RETURN l_result;
150
150
  END $$;
151
151
 
152
+ -- === Condition Evaluation ===
153
+ -- Evaluates a condition string with variable substitution
154
+ CREATE OR REPLACE FUNCTION dzql.evaluate_condition(
155
+ p_condition text,
156
+ p_record_before jsonb,
157
+ p_record_after jsonb,
158
+ p_user_id int
159
+ ) RETURNS boolean
160
+ LANGUAGE plpgsql AS $$
161
+ DECLARE
162
+ l_resolved_condition text;
163
+ l_result boolean;
164
+ l_match_array text[];
165
+ l_field_name text;
166
+ l_field_value text;
167
+ BEGIN
168
+ -- Replace variables in condition
169
+ l_resolved_condition := p_condition;
170
+
171
+ -- Replace @before.field references
172
+ IF p_record_before IS NOT NULL THEN
173
+ LOOP
174
+ l_match_array := regexp_match(l_resolved_condition, '@before\.(\w+)');
175
+ EXIT WHEN l_match_array IS NULL;
176
+
177
+ l_field_name := l_match_array[1];
178
+ l_field_value := COALESCE(p_record_before->>l_field_name, 'null');
179
+ l_resolved_condition := regexp_replace(
180
+ l_resolved_condition,
181
+ '@before\.' || l_field_name,
182
+ quote_literal(l_field_value),
183
+ 1
184
+ );
185
+ END LOOP;
186
+ END IF;
187
+
188
+ -- Replace @after.field references
189
+ IF p_record_after IS NOT NULL THEN
190
+ LOOP
191
+ l_match_array := regexp_match(l_resolved_condition, '@after\.(\w+)');
192
+ EXIT WHEN l_match_array IS NULL;
193
+
194
+ l_field_name := l_match_array[1];
195
+ l_field_value := COALESCE(p_record_after->>l_field_name, 'null');
196
+ l_resolved_condition := regexp_replace(
197
+ l_resolved_condition,
198
+ '@after\.' || l_field_name,
199
+ quote_literal(l_field_value),
200
+ 1
201
+ );
202
+ END LOOP;
203
+ END IF;
204
+
205
+ -- Replace @user_id
206
+ l_resolved_condition := replace(l_resolved_condition, '@user_id', p_user_id::text);
207
+
208
+ -- Replace @id (use after if available, otherwise before)
209
+ IF p_record_after IS NOT NULL THEN
210
+ l_resolved_condition := replace(l_resolved_condition, '@id',
211
+ COALESCE(p_record_after->>'id', 'null'));
212
+ ELSIF p_record_before IS NOT NULL THEN
213
+ l_resolved_condition := replace(l_resolved_condition, '@id',
214
+ COALESCE(p_record_before->>'id', 'null'));
215
+ END IF;
216
+
217
+ -- Execute the condition
218
+ BEGIN
219
+ EXECUTE 'SELECT (' || l_resolved_condition || ')::boolean' INTO l_result;
220
+ RETURN COALESCE(l_result, false);
221
+ EXCEPTION WHEN OTHERS THEN
222
+ -- If condition evaluation fails, log and return false
223
+ RAISE WARNING 'Graph rule condition evaluation failed: % (condition: %)', SQLERRM, l_resolved_condition;
224
+ RETURN false;
225
+ END;
226
+ END $$;
227
+
152
228
  -- === Path Resolution Functions ===
153
229
  -- Resolve notification/permission paths to user IDs
154
230
  -- === Path Resolution Functions ===
@@ -651,8 +727,8 @@ BEGIN
651
727
  LOOP
652
728
  l_action_type := l_action->>'type';
653
729
 
654
- -- Check valid action types
655
- IF l_action_type NOT IN ('create', 'update', 'delete') THEN
730
+ -- Check valid action types (includes new 'validate' and 'execute' types)
731
+ IF l_action_type NOT IN ('create', 'update', 'delete', 'validate', 'execute') THEN
656
732
  RAISE WARNING 'Invalid action type: %', l_action_type;
657
733
  RETURN false;
658
734
  END IF;
@@ -672,6 +748,17 @@ BEGIN
672
748
  RAISE WARNING 'Action type % requires "match" field', l_action_type;
673
749
  RETURN false;
674
750
  END IF;
751
+
752
+ -- Validate new action types
753
+ IF l_action_type IN ('validate', 'execute') AND NOT l_action ? 'function' THEN
754
+ RAISE WARNING 'Action type % requires "function" field', l_action_type;
755
+ RETURN false;
756
+ END IF;
757
+
758
+ IF l_action_type IN ('validate', 'execute') AND NOT l_action ? 'params' THEN
759
+ RAISE WARNING 'Action type % requires "params" field', l_action_type;
760
+ RETURN false;
761
+ END IF;
675
762
  END LOOP;
676
763
  END LOOP;
677
764
  END LOOP;
@@ -301,8 +301,16 @@ BEGIN
301
301
  EXECUTE l_sql_stmt INTO l_existing_record;
302
302
 
303
303
  IF l_existing_record IS NULL THEN
304
- -- User provided ID but record doesn't exist - this is an error
305
- RAISE EXCEPTION 'DZQL: record with id % not found in %', l_args_json ->> l_pk_cols[1], p_entity;
304
+ -- Record doesn't exist. For composite keys, treat as INSERT.
305
+ -- For single-column PKs, this is an error (user provided non-existent ID).
306
+ IF array_length(l_pk_cols, 1) > 1 THEN
307
+ -- Composite key: treat as INSERT
308
+ l_is_insert := true;
309
+ ELSE
310
+ -- Single PK: this is an error
311
+ RAISE EXCEPTION 'DZQL: record with %=% not found in %',
312
+ l_pk_cols[1], l_args_json ->> l_pk_cols[1], p_entity;
313
+ END IF;
306
314
  END IF;
307
315
  END IF;
308
316
 
@@ -163,6 +163,90 @@ BEGIN
163
163
  );
164
164
  END $$;
165
165
 
166
+ -- === Validate Action ===
167
+ -- Calls a validation function and raises exception if it returns false
168
+ CREATE OR REPLACE FUNCTION dzql.execute_graph_validate(
169
+ p_function_name text,
170
+ p_params jsonb,
171
+ p_error_message text DEFAULT 'Validation failed'
172
+ ) RETURNS void
173
+ LANGUAGE plpgsql AS $$
174
+ DECLARE
175
+ l_result boolean;
176
+ l_sql text;
177
+ l_param_list text[];
178
+ l_key text;
179
+ l_value text;
180
+ BEGIN
181
+ -- Validate function name (prevent SQL injection)
182
+ IF NOT p_function_name ~ '^[a-z_][a-z0-9_]*$' THEN
183
+ RAISE EXCEPTION 'Invalid function name: %', p_function_name;
184
+ END IF;
185
+
186
+ -- Build parameter list
187
+ l_param_list := array[]::text[];
188
+ FOR l_key, l_value IN SELECT * FROM jsonb_each_text(p_params)
189
+ LOOP
190
+ l_param_list := l_param_list || (l_key || ' => ' || quote_literal(l_value));
191
+ END LOOP;
192
+
193
+ -- Build and execute function call
194
+ IF array_length(l_param_list, 1) > 0 THEN
195
+ l_sql := format('SELECT %I(%s)', p_function_name, array_to_string(l_param_list, ', '));
196
+ ELSE
197
+ l_sql := format('SELECT %I()', p_function_name);
198
+ END IF;
199
+
200
+ EXECUTE l_sql INTO l_result;
201
+
202
+ -- Raise exception if validation failed
203
+ IF NOT COALESCE(l_result, false) THEN
204
+ RAISE EXCEPTION '%', p_error_message;
205
+ END IF;
206
+ END $$;
207
+
208
+ -- === Execute Action ===
209
+ -- Calls a custom function with parameters (fire-and-forget)
210
+ CREATE OR REPLACE FUNCTION dzql.execute_graph_function(
211
+ p_function_name text,
212
+ p_params jsonb
213
+ ) RETURNS jsonb
214
+ LANGUAGE plpgsql AS $$
215
+ DECLARE
216
+ l_result jsonb;
217
+ l_sql text;
218
+ l_param_list text[];
219
+ l_key text;
220
+ l_value text;
221
+ BEGIN
222
+ -- Validate function name (prevent SQL injection)
223
+ IF NOT p_function_name ~ '^[a-z_][a-z0-9_]*$' THEN
224
+ RAISE EXCEPTION 'Invalid function name: %', p_function_name;
225
+ END IF;
226
+
227
+ -- Build parameter list
228
+ l_param_list := array[]::text[];
229
+ FOR l_key, l_value IN SELECT * FROM jsonb_each_text(p_params)
230
+ LOOP
231
+ l_param_list := l_param_list || (l_key || ' => ' || quote_literal(l_value));
232
+ END LOOP;
233
+
234
+ -- Build and execute function call
235
+ IF array_length(l_param_list, 1) > 0 THEN
236
+ l_sql := format('SELECT %I(%s)', p_function_name, array_to_string(l_param_list, ', '));
237
+ ELSE
238
+ l_sql := format('SELECT %I()', p_function_name);
239
+ END IF;
240
+
241
+ EXECUTE l_sql INTO l_result;
242
+
243
+ RETURN COALESCE(l_result, '{}'::jsonb);
244
+ EXCEPTION WHEN OTHERS THEN
245
+ -- Log error but don't fail the transaction
246
+ RAISE WARNING 'Graph rule function execution failed: % (function: %)', SQLERRM, p_function_name;
247
+ RETURN jsonb_build_object('error', SQLERRM);
248
+ END $$;
249
+
166
250
  -- Main graph rules execution engine
167
251
  CREATE OR REPLACE FUNCTION dzql.execute_graph_rules(
168
252
  p_table_name text,
@@ -189,6 +273,10 @@ DECLARE
189
273
  l_execution_log jsonb := '[]'::jsonb;
190
274
  l_condition text;
191
275
  l_condition_result boolean;
276
+ l_function_name text;
277
+ l_function_params jsonb;
278
+ l_error_message text;
279
+ l_function_result jsonb;
192
280
  BEGIN
193
281
  -- Get entity configuration
194
282
  SELECT * INTO l_entity_config FROM dzql.entities WHERE table_name = p_table_name;
@@ -230,69 +318,88 @@ BEGIN
230
318
  l_condition := l_rule_config->>'condition';
231
319
  l_condition_result := true; -- Default to true if no condition
232
320
 
233
- -- TODO: Implement condition evaluation
234
- -- IF l_condition IS NOT NULL THEN
235
- -- l_condition_result := dzql.evaluate_condition(l_condition, p_record_before, p_record_after, p_user_id);
236
- -- END IF;
321
+ IF l_condition IS NOT NULL THEN
322
+ l_condition_result := dzql.evaluate_condition(l_condition, p_record_before, p_record_after, p_user_id);
323
+ END IF;
237
324
 
238
325
  IF l_condition_result THEN
239
326
  -- Execute each action in the rule
240
327
  FOR l_action IN SELECT * FROM jsonb_array_elements(l_rule_config->'actions')
241
328
  LOOP
242
329
  l_action_type := l_action->>'type';
243
- l_target_entity := l_action->>'entity';
244
- l_action_data := l_action->'data';
245
- l_action_match := l_action->'match';
246
-
247
- -- Resolve variables in data and match objects
248
- IF l_action_data IS NOT NULL THEN
249
- l_resolved_data := dzql.resolve_graph_data(l_action_data, p_record_before, p_record_after, p_user_id);
250
- END IF;
251
330
 
252
- IF l_action_match IS NOT NULL THEN
253
- l_resolved_match := dzql.resolve_graph_data(l_action_match, p_record_before, p_record_after, p_user_id);
254
- END IF;
255
-
256
- -- Execute the action
331
+ -- Execute the action based on type
257
332
  BEGIN
258
333
  CASE l_action_type
334
+ -- Existing action types
259
335
  WHEN 'create' THEN
336
+ l_target_entity := l_action->>'entity';
337
+ l_action_data := l_action->'data';
338
+ l_resolved_data := dzql.resolve_graph_data(l_action_data, p_record_before, p_record_after, p_user_id);
260
339
  PERFORM dzql.execute_graph_insert(l_target_entity, l_resolved_data, p_user_id);
261
340
 
262
341
  WHEN 'update' THEN
342
+ l_target_entity := l_action->>'entity';
343
+ l_action_data := l_action->'data';
344
+ l_action_match := l_action->'match';
345
+ l_resolved_data := dzql.resolve_graph_data(l_action_data, p_record_before, p_record_after, p_user_id);
346
+ l_resolved_match := dzql.resolve_graph_data(l_action_match, p_record_before, p_record_after, p_user_id);
263
347
  PERFORM dzql.execute_graph_update(l_target_entity, l_resolved_match, l_resolved_data, p_user_id);
264
348
 
265
349
  WHEN 'delete' THEN
350
+ l_target_entity := l_action->>'entity';
351
+ l_action_match := l_action->'match';
352
+ l_resolved_match := dzql.resolve_graph_data(l_action_match, p_record_before, p_record_after, p_user_id);
266
353
  PERFORM dzql.execute_graph_delete(l_target_entity, l_resolved_match, p_user_id);
354
+
355
+ -- NEW: Validation action
356
+ WHEN 'validate' THEN
357
+ l_function_name := l_action->>'function';
358
+ l_function_params := l_action->'params';
359
+ l_error_message := COALESCE(l_action->>'error_message', 'Validation failed');
360
+ l_resolved_data := dzql.resolve_graph_data(l_function_params, p_record_before, p_record_after, p_user_id);
361
+ PERFORM dzql.execute_graph_validate(l_function_name, l_resolved_data, l_error_message);
362
+
363
+ -- NEW: Execute function action
364
+ WHEN 'execute' THEN
365
+ l_function_name := l_action->>'function';
366
+ l_function_params := l_action->'params';
367
+ l_resolved_data := dzql.resolve_graph_data(l_function_params, p_record_before, p_record_after, p_user_id);
368
+ l_function_result := dzql.execute_graph_function(l_function_name, l_resolved_data);
369
+
370
+ ELSE
371
+ RAISE WARNING 'Unknown graph rule action type: %', l_action_type;
267
372
  END CASE;
268
373
 
269
374
  -- Log successful execution
270
375
  l_execution_log := l_execution_log || jsonb_build_object(
271
376
  'rule', l_rule_name,
272
- 'action_type', l_action_type,
273
- 'target_entity', l_target_entity,
377
+ 'action', l_action_type,
274
378
  'status', 'success'
275
379
  );
276
380
 
277
- EXCEPTION
278
- WHEN OTHERS THEN
279
- -- Log failed execution but continue with other rules
280
- l_execution_log := l_execution_log || jsonb_build_object(
281
- 'rule', l_rule_name,
282
- 'action_type', l_action_type,
283
- 'target_entity', l_target_entity,
284
- 'status', 'error',
285
- 'error', SQLERRM
286
- );
381
+ EXCEPTION WHEN OTHERS THEN
382
+ -- Log error and re-raise for validate actions, otherwise just log
383
+ l_execution_log := l_execution_log || jsonb_build_object(
384
+ 'rule', l_rule_name,
385
+ 'action', l_action_type,
386
+ 'status', 'error',
387
+ 'error', SQLERRM
388
+ );
389
+
390
+ -- Re-raise exceptions from validate actions to prevent operation
391
+ IF l_action_type = 'validate' THEN
392
+ RAISE;
393
+ END IF;
287
394
  END;
288
395
  END LOOP;
289
396
  END IF;
290
397
  END LOOP;
291
398
 
292
399
  RETURN jsonb_build_object(
293
- 'status', 'completed',
400
+ 'status', 'success',
294
401
  'trigger', l_trigger_key,
295
- 'execution_log', l_execution_log
402
+ 'executed_actions', l_execution_log
296
403
  );
297
404
  END $$;
298
405