dzql 0.5.24 → 0.5.26

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/docs/README.md CHANGED
@@ -16,6 +16,7 @@ Feature-specific guides and how-tos:
16
16
 
17
17
  - **[Live Query Subscriptions](guides/subscriptions.md)** - Real-time denormalized documents
18
18
  - **[Many-to-Many Relationships](guides/many-to-many.md)** - Junction table management
19
+ - **[Composite Primary Keys](guides/composite-primary-keys.md)** - Tables with compound keys
19
20
  - **[Field Defaults](guides/field-defaults.md)** - Auto-populate fields on create
20
21
  - **[Custom Functions](guides/custom-functions.md)** - Extend with PostgreSQL or Bun functions
21
22
  - **[Client Stores](guides/client-stores.md)** - Pinia store patterns for Vue.js
@@ -23,10 +23,16 @@ Entity Registration:
23
23
  temporal_fields, -- '{}'
24
24
  notification_paths, -- '{"ownership": ["@org_id->acts_for..."]}'
25
25
  permission_paths, -- '{"view": [], "create": [...]}'
26
- graph_rules, -- '{"on_create": {...}, "many_to_many": {...}}'
26
+ graph_rules, -- '{"on_create": {...}, "many_to_many": {...}, "primary_key": [...]}'
27
27
  field_defaults -- '{"owner_id": "@user_id"}'
28
28
  )
29
29
 
30
+ Composite Primary Keys:
31
+ graph_rules: '{"primary_key": ["entity_type", "entity_id"]}'
32
+ - GET/DELETE accept JSONB: get_table(user_id, '{"col1": "val", "col2": 123}')
33
+ - SAVE detects insert/update by checking if all PK fields exist
34
+ - Columns ending with _id are cast to ::int, others stay text
35
+
30
36
  M2M id_field naming: tag_ids (singular + _ids), NOT tags_ids
31
37
  Permission [] = public, omitted = denied
32
38
  Path syntax: @field->table[filter]{temporal}.target_field
@@ -0,0 +1,158 @@
1
+ # Composite Primary Keys
2
+
3
+ DZQL supports tables with composite (compound) primary keys. This guide explains how to register entities with composite keys and how the generated CRUD functions work.
4
+
5
+ ## When to Use Composite Keys
6
+
7
+ Composite primary keys are useful for:
8
+
9
+ - **Junction tables** with additional data (beyond simple M2M)
10
+ - **Position/state tables** keyed by entity type and ID
11
+ - **Multi-tenant tables** keyed by tenant + entity
12
+ - **Versioned records** keyed by ID + version
13
+
14
+ ## Registering an Entity with a Composite Key
15
+
16
+ To register an entity with a composite primary key, add `primary_key` to the `graph_rules` parameter:
17
+
18
+ ```sql
19
+ -- Create a table with composite primary key
20
+ CREATE TABLE canvas_positions (
21
+ entity_type VARCHAR(50) NOT NULL,
22
+ entity_id INTEGER NOT NULL,
23
+ x INTEGER NOT NULL,
24
+ y INTEGER NOT NULL,
25
+ width INTEGER DEFAULT 100,
26
+ height INTEGER DEFAULT 100,
27
+ PRIMARY KEY (entity_type, entity_id)
28
+ );
29
+
30
+ -- Register with DZQL using composite key
31
+ SELECT dzql.register_entity(
32
+ 'canvas_positions', -- table_name
33
+ 'entity_type', -- label_field
34
+ array['x', 'y', 'width', 'height'], -- searchable_fields
35
+ '{}', -- fk_includes
36
+ false, -- soft_delete
37
+ '{}', -- temporal_fields
38
+ '{}', -- notification_paths
39
+ jsonb_build_object( -- permission_paths
40
+ 'view', array[]::text[],
41
+ 'create', array[]::text[],
42
+ 'update', array[]::text[],
43
+ 'delete', array[]::text[]
44
+ ),
45
+ jsonb_build_object( -- graph_rules
46
+ 'primary_key', array['entity_type', 'entity_id']
47
+ )
48
+ );
49
+ ```
50
+
51
+ The `primary_key` array specifies the columns that form the composite key, in order.
52
+
53
+ ## Generated Function Signatures
54
+
55
+ When you compile an entity with a composite primary key, the generated functions have different signatures:
56
+
57
+ ### GET Function
58
+
59
+ ```sql
60
+ -- Accepts JSONB with all PK fields
61
+ SELECT get_canvas_positions(
62
+ 1, -- user_id
63
+ '{"entity_type": "node", "entity_id": 42}' -- composite PK as JSONB
64
+ );
65
+ ```
66
+
67
+ ### SAVE Function
68
+
69
+ ```sql
70
+ -- Insert: provide all PK fields plus data
71
+ SELECT save_canvas_positions(
72
+ 1, -- user_id
73
+ '{"entity_type": "node", "entity_id": 42, "x": 100, "y": 200}'
74
+ );
75
+
76
+ -- Update: same signature, existing record detected by PK
77
+ SELECT save_canvas_positions(
78
+ 1,
79
+ '{"entity_type": "node", "entity_id": 42, "x": 150, "y": 250}'
80
+ );
81
+ ```
82
+
83
+ The save function determines insert vs update by checking if a record with the composite key exists.
84
+
85
+ ### DELETE Function
86
+
87
+ ```sql
88
+ -- Accepts JSONB with all PK fields
89
+ SELECT delete_canvas_positions(
90
+ 1, -- user_id
91
+ '{"entity_type": "node", "entity_id": 42}' -- composite PK as JSONB
92
+ );
93
+ ```
94
+
95
+ ### SEARCH Function
96
+
97
+ Search works the same as simple PK entities - it returns paginated results with all fields.
98
+
99
+ ## Type Casting
100
+
101
+ DZQL automatically determines type casting for PK columns:
102
+
103
+ - Columns named `id` or ending with `_id` are cast to `::int`
104
+ - Other columns (like `entity_type`) are left as text
105
+
106
+ This means for a key like `(entity_type, entity_id)`:
107
+ - `entity_type` is compared as text
108
+ - `entity_id` is cast to integer
109
+
110
+ ## Events and Notifications
111
+
112
+ Events for composite PK entities include the full composite key in the `pk` field:
113
+
114
+ ```json
115
+ {
116
+ "table_name": "canvas_positions",
117
+ "op": "insert",
118
+ "pk": {"entity_type": "node", "entity_id": 42},
119
+ "data": {"entity_type": "node", "entity_id": 42, "x": 100, "y": 200}
120
+ }
121
+ ```
122
+
123
+ ## Limitations
124
+
125
+ - **M2M relationships**: Tables with composite PKs can have M2M relationships, but this is an advanced use case. The M2M sync uses the first PK column for junction table lookups.
126
+ - **Auto-increment**: Composite keys don't support auto-increment. All PK values must be provided on insert.
127
+
128
+ ## Example: Template Dependencies
129
+
130
+ A practical example - tracking dependencies between templates:
131
+
132
+ ```sql
133
+ CREATE TABLE template_dependencies (
134
+ template_id INTEGER NOT NULL REFERENCES templates(id),
135
+ depends_on_template_id INTEGER NOT NULL REFERENCES templates(id),
136
+ dependency_type VARCHAR(20) DEFAULT 'requires',
137
+ PRIMARY KEY (template_id, depends_on_template_id)
138
+ );
139
+
140
+ SELECT dzql.register_entity(
141
+ 'template_dependencies',
142
+ 'dependency_type',
143
+ array['dependency_type'],
144
+ '{"template": "templates", "depends_on": "templates"}',
145
+ false,
146
+ '{}',
147
+ '{}',
148
+ jsonb_build_object(
149
+ 'view', array[]::text[],
150
+ 'create', array[]::text[],
151
+ 'update', array[]::text[],
152
+ 'delete', array[]::text[]
153
+ ),
154
+ jsonb_build_object(
155
+ 'primary_key', array['template_id', 'depends_on_template_id']
156
+ )
157
+ );
158
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.5.24",
3
+ "version": "0.5.26",
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",
@@ -462,6 +462,11 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
462
462
  return `rel.${fk} = root.id`;
463
463
  }
464
464
 
465
+ // Dashboard collection: filter="TRUE" means fetch ALL rows (no FK filter)
466
+ if (filter === 'TRUE') {
467
+ return 'TRUE';
468
+ }
469
+
465
470
  // Parse filter expression like "venue_id=$venue_id"
466
471
  // Replace $param with v_param variable
467
472
  return filter.replace(/\$(\w+)/g, 'v_$1');
@@ -529,10 +534,19 @@ $$ LANGUAGE plpgsql IMMUTABLE;`;
529
534
  ? relConfig.foreignKey
530
535
  : `${this.rootEntity}_id`;
531
536
  const relVia = typeof relConfig === 'object' ? relConfig.via : null;
537
+ const relFilter = typeof relConfig === 'object' ? relConfig.filter : null;
532
538
 
533
539
  const params = Object.keys(this.paramSchema);
534
540
  const firstParam = params[0] || 'id';
535
541
 
542
+ // Dashboard collection: filter="TRUE" means notify ALL subscribers
543
+ // This relation is independent from the root entity
544
+ if (relFilter === 'TRUE') {
545
+ return `-- Dashboard collection (${relEntity}) - notify all subscribers
546
+ WHEN '${relEntity}' THEN
547
+ v_affected := ARRAY['{}'::jsonb];`;
548
+ }
549
+
536
550
  // Check if this is a nested relation (has parent FK)
537
551
  const nestedIncludes = typeof relConfig === 'object' ? relConfig.include : null;
538
552