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.
@@ -223,6 +223,62 @@ BEGIN
223
223
  END LOOP;
224
224
  END IF;
225
225
 
226
+ -- Expand many-to-many relationships (if configured)
227
+ IF l_entity_config.many_to_many IS NOT NULL AND l_entity_config.many_to_many != '{}'::jsonb THEN
228
+ DECLARE
229
+ l_m2m_key text;
230
+ l_m2m_config jsonb;
231
+ l_id_field text;
232
+ l_junction_table text;
233
+ l_local_key text;
234
+ l_foreign_key text;
235
+ l_target_entity text;
236
+ l_expand boolean;
237
+ l_record_id text;
238
+ l_id_array jsonb;
239
+ l_expanded_objects jsonb;
240
+ BEGIN
241
+ -- Get the primary key value from the result
242
+ l_record_id := l_result->>l_pk_cols[1]; -- Assume single PK for now
243
+
244
+ FOR l_m2m_key IN SELECT jsonb_object_keys(l_entity_config.many_to_many)
245
+ LOOP
246
+ l_m2m_config := l_entity_config.many_to_many->l_m2m_key;
247
+ l_id_field := l_m2m_config->>'id_field';
248
+ l_junction_table := l_m2m_config->>'junction_table';
249
+ l_local_key := l_m2m_config->>'local_key';
250
+ l_foreign_key := l_m2m_config->>'foreign_key';
251
+ l_target_entity := l_m2m_config->>'target_entity';
252
+ l_expand := COALESCE((l_m2m_config->>'expand')::boolean, false);
253
+
254
+ -- Always include array of IDs
255
+ EXECUTE format('
256
+ SELECT COALESCE(jsonb_agg(%I), ''[]''::jsonb)
257
+ FROM %I
258
+ WHERE %I = $1::int
259
+ ', l_foreign_key, l_junction_table, l_local_key)
260
+ INTO l_id_array
261
+ USING l_record_id;
262
+
263
+ l_result := l_result || jsonb_build_object(l_id_field, l_id_array);
264
+
265
+ -- Conditionally include expanded objects if expand: true
266
+ IF l_expand THEN
267
+ EXECUTE format('
268
+ SELECT COALESCE(jsonb_agg(to_jsonb(t.*)), ''[]''::jsonb)
269
+ FROM %I jt
270
+ JOIN %I t ON t.id = jt.%I
271
+ WHERE jt.%I = $1::int
272
+ ', l_junction_table, l_target_entity, l_foreign_key, l_local_key)
273
+ INTO l_expanded_objects
274
+ USING l_record_id;
275
+
276
+ l_result := l_result || jsonb_build_object(l_m2m_key, l_expanded_objects);
277
+ END IF;
278
+ END LOOP;
279
+ END;
280
+ END IF;
281
+
226
282
  RETURN l_result;
227
283
  END $$;
228
284
 
@@ -333,7 +389,29 @@ BEGIN
333
389
  LOOP
334
390
  -- Don't update any primary key columns
335
391
  IF NOT (l_col_name = ANY(l_pk_cols)) THEN
336
- l_set_clauses := l_set_clauses || format('%I = %L', l_col_name, l_merged_data ->> l_col_name);
392
+ -- Skip M2M ID fields (they're not real table columns)
393
+ IF l_entity_config.many_to_many IS NOT NULL THEN
394
+ DECLARE
395
+ l_m2m_id_field text;
396
+ l_skip boolean := false;
397
+ BEGIN
398
+ FOR l_m2m_id_field IN
399
+ SELECT value->>'id_field'
400
+ FROM jsonb_each(l_entity_config.many_to_many)
401
+ LOOP
402
+ IF l_col_name = l_m2m_id_field THEN
403
+ l_skip := true;
404
+ EXIT;
405
+ END IF;
406
+ END LOOP;
407
+
408
+ IF NOT l_skip THEN
409
+ l_set_clauses := l_set_clauses || format('%I = %L', l_col_name, l_merged_data ->> l_col_name);
410
+ END IF;
411
+ END;
412
+ ELSE
413
+ l_set_clauses := l_set_clauses || format('%I = %L', l_col_name, l_merged_data ->> l_col_name);
414
+ END IF;
337
415
  END IF;
338
416
  END LOOP;
339
417
 
@@ -359,6 +437,38 @@ BEGIN
359
437
  l_args_json := l_args_json || jsonb_build_object('user_id', p_user_id);
360
438
  END IF;
361
439
 
440
+ -- Apply field defaults for INSERT (if configured)
441
+ IF l_entity_config.field_defaults IS NOT NULL AND l_entity_config.field_defaults != '{}' THEN
442
+ FOR l_col_name IN SELECT jsonb_object_keys(l_entity_config.field_defaults)
443
+ LOOP
444
+ -- Only apply default if field is not already provided
445
+ IF NOT (l_args_json ? l_col_name) THEN
446
+ DECLARE
447
+ l_default_value text;
448
+ l_resolved_value text;
449
+ BEGIN
450
+ l_default_value := l_entity_config.field_defaults->>l_col_name;
451
+
452
+ -- Resolve variable if it starts with @
453
+ IF l_default_value LIKE '@%' THEN
454
+ l_resolved_value := dzql.resolve_graph_variable(
455
+ l_default_value,
456
+ NULL, -- no before record for INSERT
457
+ l_args_json, -- current data being inserted
458
+ p_user_id
459
+ );
460
+ ELSE
461
+ -- Use literal value
462
+ l_resolved_value := l_default_value;
463
+ END IF;
464
+
465
+ -- Add to l_args_json
466
+ l_args_json := l_args_json || jsonb_build_object(l_col_name, l_resolved_value);
467
+ END;
468
+ END IF;
469
+ END LOOP;
470
+ END IF;
471
+
362
472
  -- Check create permission on new values
363
473
  l_operation := 'create';
364
474
  l_permission_record := l_args_json;
@@ -371,6 +481,28 @@ BEGIN
371
481
 
372
482
  FOR l_col_name IN SELECT jsonb_object_keys(l_args_json)
373
483
  LOOP
484
+ -- Skip M2M ID fields (they're not real table columns)
485
+ IF l_entity_config.many_to_many IS NOT NULL THEN
486
+ DECLARE
487
+ l_m2m_id_field text;
488
+ l_skip boolean := false;
489
+ BEGIN
490
+ FOR l_m2m_id_field IN
491
+ SELECT value->>'id_field'
492
+ FROM jsonb_each(l_entity_config.many_to_many)
493
+ LOOP
494
+ IF l_col_name = l_m2m_id_field THEN
495
+ l_skip := true;
496
+ EXIT;
497
+ END IF;
498
+ END LOOP;
499
+
500
+ IF l_skip THEN
501
+ CONTINUE;
502
+ END IF;
503
+ END;
504
+ END IF;
505
+
374
506
  IF l_args_json ->> l_col_name IS NOT NULL AND l_args_json ->> l_col_name != '' THEN
375
507
  l_cols := l_cols || quote_ident(l_col_name);
376
508
  l_vals := l_vals || quote_literal(l_args_json ->> l_col_name);
@@ -391,6 +523,109 @@ BEGIN
391
523
 
392
524
  END IF;
393
525
 
526
+ -- Sync many-to-many relationships (if configured)
527
+ IF l_entity_config.many_to_many IS NOT NULL AND l_entity_config.many_to_many != '{}'::jsonb THEN
528
+ DECLARE
529
+ l_m2m_key text;
530
+ l_m2m_config jsonb;
531
+ l_id_field text;
532
+ l_junction_table text;
533
+ l_local_key text;
534
+ l_foreign_key text;
535
+ l_record_id text;
536
+ BEGIN
537
+ -- Get the primary key value from the result
538
+ l_record_id := l_result->>l_pk_cols[1]; -- Assume single PK for now
539
+
540
+ FOR l_m2m_key IN SELECT jsonb_object_keys(l_entity_config.many_to_many)
541
+ LOOP
542
+ l_m2m_config := l_entity_config.many_to_many->l_m2m_key;
543
+ l_id_field := l_m2m_config->>'id_field';
544
+
545
+ -- Only sync if the ID field is present in the data
546
+ IF l_args_json ? l_id_field THEN
547
+ l_junction_table := l_m2m_config->>'junction_table';
548
+ l_local_key := l_m2m_config->>'local_key';
549
+ l_foreign_key := l_m2m_config->>'foreign_key';
550
+
551
+ -- Delete relationships not in new list
552
+ EXECUTE format('
553
+ DELETE FROM %I
554
+ WHERE %I = $1::int
555
+ AND %I <> ALL($2::int[])
556
+ ', l_junction_table, l_local_key, l_foreign_key)
557
+ USING l_record_id,
558
+ ARRAY(SELECT jsonb_array_elements_text(l_args_json->l_id_field))::int[];
559
+
560
+ -- Insert new relationships (ignore conflicts)
561
+ EXECUTE format('
562
+ INSERT INTO %I (%I, %I)
563
+ SELECT $1::int, value::int
564
+ FROM jsonb_array_elements_text($2)
565
+ ON CONFLICT DO NOTHING
566
+ ', l_junction_table, l_local_key, l_foreign_key)
567
+ USING l_record_id, l_args_json->l_id_field;
568
+ END IF;
569
+ END LOOP;
570
+ END;
571
+ END IF;
572
+
573
+ -- Expand many-to-many relationships in result (after sync)
574
+ IF l_entity_config.many_to_many IS NOT NULL AND l_entity_config.many_to_many != '{}'::jsonb THEN
575
+ DECLARE
576
+ l_m2m_key text;
577
+ l_m2m_config jsonb;
578
+ l_id_field text;
579
+ l_junction_table text;
580
+ l_local_key text;
581
+ l_foreign_key text;
582
+ l_target_entity text;
583
+ l_expand boolean;
584
+ l_record_id text;
585
+ l_id_array jsonb;
586
+ l_expanded_objects jsonb;
587
+ BEGIN
588
+ -- Get the primary key value from the result
589
+ l_record_id := l_result->>l_pk_cols[1]; -- Assume single PK for now
590
+
591
+ FOR l_m2m_key IN SELECT jsonb_object_keys(l_entity_config.many_to_many)
592
+ LOOP
593
+ l_m2m_config := l_entity_config.many_to_many->l_m2m_key;
594
+ l_id_field := l_m2m_config->>'id_field';
595
+ l_junction_table := l_m2m_config->>'junction_table';
596
+ l_local_key := l_m2m_config->>'local_key';
597
+ l_foreign_key := l_m2m_config->>'foreign_key';
598
+ l_target_entity := l_m2m_config->>'target_entity';
599
+ l_expand := COALESCE((l_m2m_config->>'expand')::boolean, false);
600
+
601
+ -- Always include array of IDs
602
+ EXECUTE format('
603
+ SELECT COALESCE(jsonb_agg(%I), ''[]''::jsonb)
604
+ FROM %I
605
+ WHERE %I = $1::int
606
+ ', l_foreign_key, l_junction_table, l_local_key)
607
+ INTO l_id_array
608
+ USING l_record_id;
609
+
610
+ l_result := l_result || jsonb_build_object(l_id_field, l_id_array);
611
+
612
+ -- Conditionally include expanded objects if expand: true
613
+ IF l_expand THEN
614
+ EXECUTE format('
615
+ SELECT COALESCE(jsonb_agg(to_jsonb(t.*)), ''[]''::jsonb)
616
+ FROM %I jt
617
+ JOIN %I t ON t.id = jt.%I
618
+ WHERE jt.%I = $1::int
619
+ ', l_junction_table, l_target_entity, l_foreign_key, l_local_key)
620
+ INTO l_expanded_objects
621
+ USING l_record_id;
622
+
623
+ l_result := l_result || jsonb_build_object(l_m2m_key, l_expanded_objects);
624
+ END IF;
625
+ END LOOP;
626
+ END;
627
+ END IF;
628
+
394
629
  -- Execute graph rules for the appropriate operation
395
630
  l_graph_rules_result := dzql.execute_graph_rules(
396
631
  p_entity,
@@ -398,6 +398,70 @@ BEGIN
398
398
  END IF;
399
399
  END LOOP;
400
400
 
401
+ -- Expand many-to-many relationships for this record (if configured)
402
+ IF l_entity_config.many_to_many IS NOT NULL AND l_entity_config.many_to_many != '{}'::jsonb THEN
403
+ DECLARE
404
+ l_m2m_key text;
405
+ l_m2m_config jsonb;
406
+ l_id_field text;
407
+ l_junction_table text;
408
+ l_local_key text;
409
+ l_foreign_key text;
410
+ l_target_entity text;
411
+ l_expand boolean;
412
+ l_record_id text;
413
+ l_id_array jsonb;
414
+ l_expanded_objects jsonb;
415
+ l_pk_cols text[];
416
+ BEGIN
417
+ -- Get primary key columns for this entity
418
+ SELECT array_agg(a.attname ORDER BY a.attnum)
419
+ INTO l_pk_cols
420
+ FROM pg_index idx
421
+ JOIN pg_attribute a ON a.attrelid = idx.indrelid AND a.attnum = ANY(idx.indkey)
422
+ WHERE idx.indrelid = p_entity::regclass AND idx.indisprimary;
423
+
424
+ -- Get the primary key value from the record
425
+ l_record_id := l_record->>l_pk_cols[1]; -- Assume single PK for now
426
+
427
+ FOR l_m2m_key IN SELECT jsonb_object_keys(l_entity_config.many_to_many)
428
+ LOOP
429
+ l_m2m_config := l_entity_config.many_to_many->l_m2m_key;
430
+ l_id_field := l_m2m_config->>'id_field';
431
+ l_junction_table := l_m2m_config->>'junction_table';
432
+ l_local_key := l_m2m_config->>'local_key';
433
+ l_foreign_key := l_m2m_config->>'foreign_key';
434
+ l_target_entity := l_m2m_config->>'target_entity';
435
+ l_expand := COALESCE((l_m2m_config->>'expand')::boolean, false);
436
+
437
+ -- Always include array of IDs
438
+ EXECUTE format('
439
+ SELECT COALESCE(jsonb_agg(%I), ''[]''::jsonb)
440
+ FROM %I
441
+ WHERE %I = $1::int
442
+ ', l_foreign_key, l_junction_table, l_local_key)
443
+ INTO l_id_array
444
+ USING l_record_id;
445
+
446
+ l_record := l_record || jsonb_build_object(l_id_field, l_id_array);
447
+
448
+ -- Conditionally include expanded objects if expand: true
449
+ IF l_expand THEN
450
+ EXECUTE format('
451
+ SELECT COALESCE(jsonb_agg(to_jsonb(t.*)), ''[]''::jsonb)
452
+ FROM %I jt
453
+ JOIN %I t ON t.id = jt.%I
454
+ WHERE jt.%I = $1::int
455
+ ', l_junction_table, l_target_entity, l_foreign_key, l_local_key)
456
+ INTO l_expanded_objects
457
+ USING l_record_id;
458
+
459
+ l_record := l_record || jsonb_build_object(l_m2m_key, l_expanded_objects);
460
+ END IF;
461
+ END LOOP;
462
+ END;
463
+ END IF;
464
+
401
465
  l_processed_data := l_processed_data || l_record;
402
466
  END LOOP;
403
467
 
@@ -579,7 +579,8 @@ CREATE OR REPLACE FUNCTION dzql.register_entity(
579
579
  p_temporal_fields jsonb DEFAULT '{}',
580
580
  p_notification_paths jsonb DEFAULT '{}',
581
581
  p_permission_paths jsonb DEFAULT '{}',
582
- p_graph_rules jsonb DEFAULT '{}'
582
+ p_graph_rules jsonb DEFAULT '{}',
583
+ p_field_defaults jsonb DEFAULT '{}'
583
584
  ) RETURNS void
584
585
  LANGUAGE plpgsql AS $$
585
586
  DECLARE
@@ -587,6 +588,7 @@ DECLARE
587
588
  l_rule_name text;
588
589
  l_rule_config jsonb;
589
590
  l_action jsonb;
591
+ l_many_to_many jsonb;
590
592
  BEGIN
591
593
  -- Validate permission paths if provided
592
594
  IF p_permission_paths IS NOT NULL AND p_permission_paths != '{}' THEN
@@ -602,11 +604,14 @@ BEGIN
602
604
  END IF;
603
605
  END IF;
604
606
 
607
+ -- Extract many_to_many from graph_rules if present
608
+ l_many_to_many := COALESCE(p_graph_rules->'many_to_many', '{}'::jsonb);
609
+
605
610
  -- Insert or update entity configuration
606
611
  INSERT INTO dzql.entities
607
- (table_name, label_field, searchable_fields, fk_includes, soft_delete, temporal_fields, notification_paths, permission_paths, graph_rules)
612
+ (table_name, label_field, searchable_fields, fk_includes, soft_delete, temporal_fields, notification_paths, permission_paths, graph_rules, field_defaults, many_to_many)
608
613
  VALUES
609
- (p_table_name, p_label_field, p_searchable_fields, p_fk_includes, p_soft_delete, p_temporal_fields, p_notification_paths, p_permission_paths, p_graph_rules)
614
+ (p_table_name, p_label_field, p_searchable_fields, p_fk_includes, p_soft_delete, p_temporal_fields, p_notification_paths, p_permission_paths, p_graph_rules, p_field_defaults, l_many_to_many)
610
615
  ON CONFLICT (table_name) DO UPDATE SET
611
616
  label_field = EXCLUDED.label_field,
612
617
  searchable_fields = EXCLUDED.searchable_fields,
@@ -615,7 +620,9 @@ BEGIN
615
620
  temporal_fields = EXCLUDED.temporal_fields,
616
621
  notification_paths = EXCLUDED.notification_paths,
617
622
  permission_paths = EXCLUDED.permission_paths,
618
- graph_rules = EXCLUDED.graph_rules;
623
+ graph_rules = EXCLUDED.graph_rules,
624
+ field_defaults = EXCLUDED.field_defaults,
625
+ many_to_many = EXCLUDED.many_to_many;
619
626
 
620
627
  -- Create API functions for this entity
621
628
  PERFORM dzql.create_entity_functions(p_table_name);