dzql 0.5.33 → 0.6.1

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.
Files changed (142) hide show
  1. package/.env.sample +28 -0
  2. package/compose.yml +28 -0
  3. package/dist/client/index.ts +1 -0
  4. package/dist/client/stores/useMyProfileStore.ts +114 -0
  5. package/dist/client/stores/useOrgDashboardStore.ts +131 -0
  6. package/dist/client/stores/useVenueDetailStore.ts +117 -0
  7. package/dist/client/ws.ts +716 -0
  8. package/dist/db/migrations/000_core.sql +92 -0
  9. package/dist/db/migrations/20251229T212912022Z_schema.sql +3020 -0
  10. package/dist/db/migrations/20251229T212912022Z_subscribables.sql +371 -0
  11. package/dist/runtime/manifest.json +1562 -0
  12. package/docs/README.md +309 -36
  13. package/docs/feature-requests/applyPatch-bug-report.md +85 -0
  14. package/docs/feature-requests/connection-ready-profile.md +57 -0
  15. package/docs/feature-requests/hidden-bug-report.md +111 -0
  16. package/docs/feature-requests/hidden-fields-subscribables.md +34 -0
  17. package/docs/feature-requests/subscribable-param-key-bug.md +38 -0
  18. package/docs/feature-requests/todo.md +146 -0
  19. package/docs/for_ai.md +653 -0
  20. package/docs/project-setup.md +456 -0
  21. package/examples/blog.ts +50 -0
  22. package/examples/invalid.ts +18 -0
  23. package/examples/venues.js +485 -0
  24. package/package.json +23 -60
  25. package/src/cli/codegen/client.ts +99 -0
  26. package/src/cli/codegen/manifest.ts +95 -0
  27. package/src/cli/codegen/pinia.ts +174 -0
  28. package/src/cli/codegen/realtime.ts +58 -0
  29. package/src/cli/codegen/sql.ts +698 -0
  30. package/src/cli/codegen/subscribable_sql.ts +547 -0
  31. package/src/cli/codegen/subscribable_store.ts +184 -0
  32. package/src/cli/codegen/types.ts +142 -0
  33. package/src/cli/compiler/analyzer.ts +52 -0
  34. package/src/cli/compiler/graph_rules.ts +251 -0
  35. package/src/cli/compiler/ir.ts +233 -0
  36. package/src/cli/compiler/loader.ts +132 -0
  37. package/src/cli/compiler/permissions.ts +227 -0
  38. package/src/cli/index.ts +166 -0
  39. package/src/client/index.ts +1 -0
  40. package/src/client/ws.ts +286 -0
  41. package/src/runtime/auth.ts +39 -0
  42. package/src/runtime/db.ts +33 -0
  43. package/src/runtime/errors.ts +51 -0
  44. package/src/runtime/index.ts +98 -0
  45. package/src/runtime/js_functions.ts +63 -0
  46. package/src/runtime/manifest_loader.ts +29 -0
  47. package/src/runtime/namespace.ts +483 -0
  48. package/src/runtime/server.ts +87 -0
  49. package/src/runtime/ws.ts +197 -0
  50. package/src/shared/ir.ts +197 -0
  51. package/tests/client.test.ts +38 -0
  52. package/tests/codegen.test.ts +71 -0
  53. package/tests/compiler.test.ts +45 -0
  54. package/tests/graph_rules.test.ts +173 -0
  55. package/tests/integration/db.test.ts +174 -0
  56. package/tests/integration/e2e.test.ts +65 -0
  57. package/tests/integration/features.test.ts +922 -0
  58. package/tests/integration/full_stack.test.ts +262 -0
  59. package/tests/integration/setup.ts +45 -0
  60. package/tests/ir.test.ts +32 -0
  61. package/tests/namespace.test.ts +395 -0
  62. package/tests/permissions.test.ts +55 -0
  63. package/tests/pinia.test.ts +48 -0
  64. package/tests/realtime.test.ts +22 -0
  65. package/tests/runtime.test.ts +80 -0
  66. package/tests/subscribable_gen.test.ts +72 -0
  67. package/tests/subscribable_reactivity.test.ts +258 -0
  68. package/tests/venues_gen.test.ts +25 -0
  69. package/tsconfig.json +20 -0
  70. package/tsconfig.tsbuildinfo +1 -0
  71. package/README.md +0 -90
  72. package/bin/cli.js +0 -727
  73. package/docs/compiler/ADVANCED_FILTERS.md +0 -183
  74. package/docs/compiler/CODING_STANDARDS.md +0 -415
  75. package/docs/compiler/COMPARISON.md +0 -673
  76. package/docs/compiler/QUICKSTART.md +0 -326
  77. package/docs/compiler/README.md +0 -134
  78. package/docs/examples/README.md +0 -38
  79. package/docs/examples/blog.sql +0 -160
  80. package/docs/examples/venue-detail-simple.sql +0 -8
  81. package/docs/examples/venue-detail-subscribable.sql +0 -45
  82. package/docs/for-ai/claude-guide.md +0 -1210
  83. package/docs/getting-started/quickstart.md +0 -125
  84. package/docs/getting-started/subscriptions-quick-start.md +0 -203
  85. package/docs/getting-started/tutorial.md +0 -1104
  86. package/docs/guides/atomic-updates.md +0 -299
  87. package/docs/guides/client-stores.md +0 -730
  88. package/docs/guides/composite-primary-keys.md +0 -158
  89. package/docs/guides/custom-functions.md +0 -362
  90. package/docs/guides/drop-semantics.md +0 -554
  91. package/docs/guides/field-defaults.md +0 -240
  92. package/docs/guides/interpreter-vs-compiler.md +0 -237
  93. package/docs/guides/many-to-many.md +0 -929
  94. package/docs/guides/subscriptions.md +0 -537
  95. package/docs/reference/api.md +0 -1373
  96. package/docs/reference/client.md +0 -224
  97. package/src/client/stores/index.js +0 -8
  98. package/src/client/stores/useAppStore.js +0 -285
  99. package/src/client/stores/useWsStore.js +0 -289
  100. package/src/client/ws.js +0 -762
  101. package/src/compiler/cli/compile-example.js +0 -33
  102. package/src/compiler/cli/compile-subscribable.js +0 -43
  103. package/src/compiler/cli/debug-compile.js +0 -44
  104. package/src/compiler/cli/debug-parse.js +0 -26
  105. package/src/compiler/cli/debug-path-parser.js +0 -18
  106. package/src/compiler/cli/debug-subscribable-parser.js +0 -21
  107. package/src/compiler/cli/index.js +0 -174
  108. package/src/compiler/codegen/auth-codegen.js +0 -153
  109. package/src/compiler/codegen/drop-semantics-codegen.js +0 -553
  110. package/src/compiler/codegen/graph-rules-codegen.js +0 -450
  111. package/src/compiler/codegen/notification-codegen.js +0 -232
  112. package/src/compiler/codegen/operation-codegen.js +0 -1382
  113. package/src/compiler/codegen/permission-codegen.js +0 -318
  114. package/src/compiler/codegen/subscribable-codegen.js +0 -827
  115. package/src/compiler/compiler.js +0 -371
  116. package/src/compiler/index.js +0 -11
  117. package/src/compiler/parser/entity-parser.js +0 -440
  118. package/src/compiler/parser/path-parser.js +0 -290
  119. package/src/compiler/parser/subscribable-parser.js +0 -244
  120. package/src/database/dzql-core.sql +0 -161
  121. package/src/database/migrations/001_schema.sql +0 -60
  122. package/src/database/migrations/002_functions.sql +0 -890
  123. package/src/database/migrations/003_operations.sql +0 -1135
  124. package/src/database/migrations/004_search.sql +0 -581
  125. package/src/database/migrations/005_entities.sql +0 -730
  126. package/src/database/migrations/006_auth.sql +0 -94
  127. package/src/database/migrations/007_events.sql +0 -133
  128. package/src/database/migrations/008_hello.sql +0 -18
  129. package/src/database/migrations/008a_meta.sql +0 -172
  130. package/src/database/migrations/009_subscriptions.sql +0 -240
  131. package/src/database/migrations/010_atomic_updates.sql +0 -157
  132. package/src/database/migrations/010_fix_m2m_events.sql +0 -94
  133. package/src/index.js +0 -40
  134. package/src/server/api.js +0 -9
  135. package/src/server/db.js +0 -442
  136. package/src/server/index.js +0 -317
  137. package/src/server/logger.js +0 -259
  138. package/src/server/mcp.js +0 -594
  139. package/src/server/meta-route.js +0 -251
  140. package/src/server/namespace.js +0 -292
  141. package/src/server/subscriptions.js +0 -351
  142. package/src/server/ws.js +0 -573
@@ -1,581 +0,0 @@
1
- -- DZQL Search Operations - Version 3.0.0
2
- -- Advanced search and filtering capabilities for DZQL entities
3
-
4
- -- ============================================================================
5
- -- FILTER PROCESSING HELPERS
6
- -- ============================================================================
7
-
8
- -- Get column data type for proper casting
9
- CREATE OR REPLACE FUNCTION dzql.get_column_type(
10
- p_table_name text,
11
- p_column_name text
12
- ) RETURNS text
13
- LANGUAGE sql STABLE AS $$
14
- SELECT format_type(atttypid, atttypmod)
15
- FROM pg_attribute
16
- WHERE attrelid = p_table_name::regclass
17
- AND attname = p_column_name
18
- AND NOT attisdropped
19
- AND attnum > 0;
20
- $$;
21
-
22
- -- Build operator-based WHERE clause fragment using direct SQL
23
- CREATE OR REPLACE FUNCTION dzql.build_operator_clause(
24
- p_column_name text,
25
- p_operator_obj jsonb,
26
- p_column_type text
27
- ) RETURNS text
28
- LANGUAGE plpgsql AS $$
29
- DECLARE
30
- l_op_key text;
31
- l_op_value jsonb;
32
- l_clauses text[] := array[]::text[];
33
- BEGIN
34
- -- Process each operator in the object
35
- FOR l_op_key, l_op_value IN SELECT * FROM jsonb_each(p_operator_obj)
36
- LOOP
37
- CASE lower(l_op_key)
38
- WHEN 'eq', '=' THEN
39
- l_clauses := l_clauses || format('%I = %L', p_column_name, l_op_value#>>'{}');
40
-
41
- WHEN 'neq', '!=', '<>' THEN
42
- l_clauses := l_clauses || format('%I != %L', p_column_name, l_op_value#>>'{}');
43
-
44
- WHEN 'gt', '>' THEN
45
- l_clauses := l_clauses || format('%I > %L', p_column_name, l_op_value#>>'{}');
46
-
47
- WHEN 'gte', '>=' THEN
48
- l_clauses := l_clauses || format('%I >= %L', p_column_name, l_op_value#>>'{}');
49
-
50
- WHEN 'lt', '<' THEN
51
- l_clauses := l_clauses || format('%I < %L', p_column_name, l_op_value#>>'{}');
52
-
53
- WHEN 'lte', '<=' THEN
54
- l_clauses := l_clauses || format('%I <= %L', p_column_name, l_op_value#>>'{}');
55
-
56
- WHEN 'like' THEN
57
- l_clauses := l_clauses || format('%I LIKE %L', p_column_name, l_op_value#>>'{}');
58
-
59
- WHEN 'ilike' THEN
60
- l_clauses := l_clauses || format('%I ILIKE %L', p_column_name, l_op_value#>>'{}');
61
-
62
- WHEN 'in' THEN
63
- IF jsonb_typeof(l_op_value) = 'array' THEN
64
- l_clauses := l_clauses || format('%I::TEXT = ANY(%L)',
65
- p_column_name,
66
- ARRAY(SELECT jsonb_array_elements_text(l_op_value))
67
- );
68
- END IF;
69
-
70
- WHEN 'not_in' THEN
71
- IF jsonb_typeof(l_op_value) = 'array' THEN
72
- l_clauses := l_clauses || format('%I::TEXT != ALL(%L)',
73
- p_column_name,
74
- ARRAY(SELECT jsonb_array_elements_text(l_op_value))
75
- );
76
- END IF;
77
-
78
- WHEN 'between' THEN
79
- IF jsonb_typeof(l_op_value) = 'array' AND jsonb_array_length(l_op_value) = 2 THEN
80
- l_clauses := l_clauses || format('%I BETWEEN %L AND %L',
81
- p_column_name, l_op_value->0#>>'{}', l_op_value->1#>>'{}');
82
- END IF;
83
-
84
- WHEN 'is_null', 'null' THEN
85
- IF (l_op_value::text = 'true') THEN
86
- l_clauses := l_clauses || format('%I IS NULL', p_column_name);
87
- END IF;
88
-
89
- WHEN 'not_null', 'not' THEN
90
- IF (l_op_value::text = 'true' OR l_op_value::text = 'null') THEN
91
- l_clauses := l_clauses || format('%I IS NOT NULL', p_column_name);
92
- END IF;
93
-
94
- ELSE
95
- -- Unknown operator, skip silently
96
- NULL;
97
- END CASE;
98
- END LOOP;
99
-
100
- IF array_length(l_clauses, 1) > 0 THEN
101
- RETURN '(' || array_to_string(l_clauses, ' AND ') || ')';
102
- ELSE
103
- RETURN NULL;
104
- END IF;
105
- END $$;
106
-
107
- -- Build complete WHERE clause from filter object
108
- CREATE OR REPLACE FUNCTION dzql.build_where_clause(
109
- p_table_name text,
110
- p_filters jsonb
111
- ) RETURNS text
112
- LANGUAGE plpgsql AS $$
113
- DECLARE
114
- l_clauses text[] := array[]::text[];
115
- l_key text;
116
- l_value jsonb;
117
- l_column_type text;
118
- l_column_exists boolean;
119
- l_clause text;
120
- BEGIN
121
- -- Skip _search key (handled separately)
122
- FOR l_key, l_value IN SELECT key, value FROM jsonb_each(p_filters)
123
- LOOP
124
- IF l_key = '_search' THEN
125
- CONTINUE;
126
- END IF;
127
-
128
- -- Check if column exists
129
- SELECT EXISTS (
130
- SELECT 1 FROM pg_attribute
131
- WHERE attrelid = p_table_name::regclass
132
- AND attname = l_key
133
- AND NOT attisdropped
134
- AND attnum > 0
135
- ) INTO l_column_exists;
136
-
137
- IF NOT l_column_exists THEN
138
- RAISE EXCEPTION 'Column % does not exist in table %', l_key, p_table_name;
139
- END IF;
140
-
141
- -- Get column type
142
- l_column_type := dzql.get_column_type(p_table_name, l_key);
143
-
144
- -- Build clause based on value type
145
- CASE jsonb_typeof(l_value)
146
- WHEN 'object' THEN
147
- -- Handle operator objects like {gte: 100, lt: 500}
148
- l_clause := dzql.build_operator_clause(l_key, l_value, l_column_type);
149
- IF l_clause IS NOT NULL THEN
150
- l_clauses := l_clauses || l_clause;
151
- END IF;
152
-
153
- WHEN 'array' THEN
154
- -- Handle IN clause
155
- l_clauses := l_clauses || format('%I = ANY(%L)',
156
- l_key,
157
- ARRAY(SELECT jsonb_array_elements_text(l_value))
158
- );
159
-
160
- WHEN 'null' THEN
161
- -- Handle IS NULL
162
- l_clauses := l_clauses || format('%I IS NULL', l_key);
163
-
164
- ELSE
165
- -- Handle exact match
166
- l_clauses := l_clauses || format('%I = %L', l_key, l_value#>>'{}');
167
- END CASE;
168
- END LOOP;
169
-
170
- IF array_length(l_clauses, 1) > 0 THEN
171
- RETURN array_to_string(l_clauses, ' AND ');
172
- ELSE
173
- RETURN NULL;
174
- END IF;
175
- END $$;
176
-
177
- -- Build text search clause
178
- CREATE OR REPLACE FUNCTION dzql.build_search_clause(
179
- p_search_text text,
180
- p_searchable_fields text[]
181
- ) RETURNS text
182
- LANGUAGE plpgsql AS $$
183
- DECLARE
184
- l_search_clauses text[] := array[]::text[];
185
- l_field text;
186
- BEGIN
187
- IF p_search_text IS NOT NULL AND p_search_text != '' THEN
188
- FOREACH l_field IN ARRAY p_searchable_fields
189
- LOOP
190
- l_search_clauses := l_search_clauses ||
191
- format('%I::text ILIKE %L', l_field, '%' || p_search_text || '%');
192
- END LOOP;
193
-
194
- IF array_length(l_search_clauses, 1) > 0 THEN
195
- RETURN '(' || array_to_string(l_search_clauses, ' OR ') || ')';
196
- END IF;
197
- END IF;
198
-
199
- RETURN NULL;
200
- END $$;
201
-
202
- -- ============================================================================
203
- -- GENERIC SEARCH OPERATION
204
- -- ============================================================================
205
-
206
- -- Generic SEARCH with advanced filtering support
207
- CREATE OR REPLACE FUNCTION dzql.generic_search(
208
- p_entity text,
209
- p_args jsonb,
210
- p_user_id int
211
- ) RETURNS jsonb
212
- LANGUAGE plpgsql
213
- SECURITY INVOKER
214
- AS $$
215
- DECLARE
216
- l_entity_config record;
217
- l_filters jsonb;
218
- l_where_clause text := '';
219
- l_filter_clause text;
220
- l_search_clause text;
221
- l_temporal_clause text;
222
- l_order_clause text := '';
223
- l_on_date timestamptz;
224
- l_page int := 1;
225
- l_limit int := 50;
226
- l_offset int := 0;
227
- l_sort jsonb;
228
- l_sort_field text;
229
- l_sort_order text;
230
- l_base_sql text;
231
- l_count_sql text;
232
- l_data_sql text;
233
- l_total int;
234
- l_data jsonb;
235
- l_column_exists boolean;
236
- l_pk_cols text[];
237
- l_pk_order text;
238
- l_fk_includes jsonb;
239
- l_key text;
240
- l_value text;
241
- l_fk_result jsonb;
242
- l_record jsonb;
243
- l_processed_data jsonb[] := '{}';
244
- i int;
245
- BEGIN
246
- -- Get entity configuration
247
- SELECT * INTO l_entity_config FROM dzql.entities WHERE table_name = p_entity;
248
-
249
- IF l_entity_config IS NULL THEN
250
- RAISE EXCEPTION 'DZQL: entity % not configured', p_entity;
251
- END IF;
252
-
253
- -- Get primary key columns for default ordering
254
- SELECT array_agg(a.attname ORDER BY a.attnum)
255
- INTO l_pk_cols
256
- FROM pg_index i
257
- JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
258
- WHERE i.indrelid = p_entity::regclass AND i.indisprimary;
259
-
260
- IF l_pk_cols IS NOT NULL THEN
261
- l_pk_order := array_to_string(array(SELECT format('t.%I', col) FROM unnest(l_pk_cols) AS col), ', ');
262
- ELSE
263
- l_pk_order := 't.*';
264
- END IF;
265
-
266
- -- Extract filters and parameters
267
- l_filters := COALESCE(p_args->'filters', p_args->'p_filters', '{}'::jsonb);
268
- l_on_date := (p_args->>'on_date')::timestamptz;
269
-
270
- -- Extract pagination from args or filters
271
- l_page := COALESCE(
272
- (p_args->>'page')::int,
273
- (l_filters->>'page')::int,
274
- 1
275
- );
276
- l_limit := COALESCE(
277
- (p_args->>'limit')::int,
278
- (l_filters->>'limit')::int,
279
- 50
280
- );
281
- l_offset := (l_page - 1) * l_limit;
282
-
283
- -- Build WHERE clause from filters
284
- l_filter_clause := dzql.build_where_clause(p_entity, l_filters);
285
-
286
- -- Build text search clause
287
- l_search_clause := dzql.build_search_clause(
288
- l_filters->>'_search',
289
- l_entity_config.searchable_fields
290
- );
291
-
292
- -- Build temporal filter
293
- l_temporal_clause := dzql.apply_temporal_filter(
294
- p_entity::regclass,
295
- l_entity_config.temporal_fields,
296
- l_on_date
297
- );
298
-
299
- -- Extract sort parameters
300
- l_sort := COALESCE(p_args->'sort', l_filters->'sort');
301
- IF l_sort IS NOT NULL THEN
302
- l_sort_field := COALESCE(l_sort->>'field', l_sort->>'column');
303
- l_sort_order := COALESCE(l_sort->>'order', l_sort->>'dir', 'asc');
304
-
305
- IF l_sort_field IS NOT NULL THEN
306
- -- Validate sort field exists, fall back to 'id' if invalid
307
- SELECT EXISTS (
308
- SELECT 1 FROM pg_attribute
309
- WHERE attrelid = p_entity::regclass
310
- AND attname = l_sort_field
311
- AND NOT attisdropped
312
- AND attnum > 0
313
- ) INTO l_column_exists;
314
-
315
- IF NOT l_column_exists THEN
316
- l_sort_field := 'id'; -- Fall back to default
317
- END IF;
318
-
319
- l_order_clause := format(' ORDER BY %I %s', l_sort_field,
320
- CASE WHEN upper(l_sort_order) = 'DESC' THEN 'DESC' ELSE 'ASC' END);
321
- END IF;
322
- END IF;
323
-
324
- -- Build complete WHERE clause
325
- IF l_filter_clause IS NOT NULL THEN
326
- l_where_clause := 'WHERE ' || l_filter_clause;
327
- END IF;
328
-
329
- IF l_search_clause IS NOT NULL THEN
330
- IF l_where_clause = '' THEN
331
- l_where_clause := 'WHERE ' || l_search_clause;
332
- ELSE
333
- l_where_clause := l_where_clause || ' AND (' || l_search_clause || ')';
334
- END IF;
335
- END IF;
336
-
337
- IF l_temporal_clause != '' THEN
338
- IF l_where_clause = '' THEN
339
- l_where_clause := 'WHERE 1=1' || l_temporal_clause;
340
- ELSE
341
- l_where_clause := l_where_clause || l_temporal_clause;
342
- END IF;
343
- END IF;
344
-
345
- -- Add permission check to WHERE clause
346
- IF l_where_clause = '' OR l_where_clause = 'WHERE' THEN
347
- l_where_clause := format('WHERE dzql.check_permission(%L, ''view'', %L, to_jsonb(t.*))', p_user_id, p_entity);
348
- ELSE
349
- l_where_clause := l_where_clause || format(' AND dzql.check_permission(%L, ''view'', %L, to_jsonb(t.*))', p_user_id, p_entity);
350
- END IF;
351
-
352
- -- Add soft delete filter if enabled for this entity
353
- IF l_entity_config.soft_delete THEN
354
- l_where_clause := l_where_clause || ' AND t.deleted_at IS NULL';
355
- END IF;
356
-
357
- -- Build base SQL
358
- l_base_sql := format('FROM %I t %s', p_entity, l_where_clause);
359
-
360
- -- Get total count
361
- l_count_sql := 'SELECT COUNT(*) ' || l_base_sql;
362
- EXECUTE l_count_sql INTO l_total;
363
-
364
- -- Get paginated data - Always use subquery to ensure LIMIT works correctly
365
- IF l_order_clause != '' THEN
366
- l_data_sql := format('SELECT COALESCE(jsonb_agg(to_jsonb(sub.*)), ''[]''::jsonb) FROM (SELECT t.* %s %s LIMIT %L OFFSET %L) sub',
367
- l_base_sql, l_order_clause, l_limit, l_offset);
368
- ELSE
369
- -- Use subquery even without ORDER BY to ensure LIMIT is applied before aggregation
370
- l_data_sql := format('SELECT COALESCE(jsonb_agg(to_jsonb(sub.*)), ''[]''::jsonb) FROM (SELECT t.* %s ORDER BY %s LIMIT %L OFFSET %L) sub',
371
- l_base_sql, l_pk_order, l_limit, l_offset);
372
- END IF;
373
-
374
- EXECUTE l_data_sql INTO l_data;
375
-
376
- -- Process FK dereferencing for each record
377
- l_fk_includes := l_entity_config.fk_includes;
378
- IF l_fk_includes IS NOT NULL AND l_fk_includes != '{}' AND l_data IS NOT NULL AND jsonb_array_length(l_data) > 0 THEN
379
- -- Process each record in the data array
380
- FOR i IN 0..jsonb_array_length(l_data) - 1 LOOP
381
- l_record := l_data->i;
382
-
383
- -- Dereference foreign keys for this record
384
- FOR l_key, l_value IN SELECT key, value FROM jsonb_each_text(l_fk_includes)
385
- LOOP
386
- -- Handle different FK reference formats
387
- IF l_value LIKE '%.%' THEN
388
- -- Format: "table.field" for reverse foreign keys
389
- l_fk_result := dzql.resolve_reverse_fk(l_record, l_key, l_value, l_on_date);
390
- ELSIF l_key = l_value THEN
391
- -- When key equals value (e.g., "sites": "sites"), it's a reverse FK
392
- -- The target table has a field named {entity_singular}_id pointing back to this entity
393
- -- Convert plural entity name to singular (simple rule: remove trailing 's')
394
- l_fk_result := dzql.resolve_reverse_fk(l_record, l_key,
395
- l_value || '.' || regexp_replace(p_entity, 's$', '') || '_id', l_on_date);
396
- ELSE
397
- -- Format: "table" for direct foreign keys
398
- l_fk_result := dzql.resolve_direct_fk(l_record, l_key, l_value, l_on_date);
399
- END IF;
400
-
401
- IF l_fk_result IS NOT NULL THEN
402
- l_record := l_record || jsonb_build_object(l_key, l_fk_result);
403
- END IF;
404
- END LOOP;
405
-
406
- -- Expand many-to-many relationships for this record (if configured)
407
- IF l_entity_config.many_to_many IS NOT NULL AND l_entity_config.many_to_many != '{}'::jsonb THEN
408
- DECLARE
409
- l_m2m_key text;
410
- l_m2m_config jsonb;
411
- l_id_field text;
412
- l_junction_table text;
413
- l_local_key text;
414
- l_foreign_key text;
415
- l_target_entity text;
416
- l_expand boolean;
417
- l_record_id text;
418
- l_id_array jsonb;
419
- l_expanded_objects jsonb;
420
- l_pk_cols text[];
421
- BEGIN
422
- -- Get primary key columns for this entity
423
- SELECT array_agg(a.attname ORDER BY a.attnum)
424
- INTO l_pk_cols
425
- FROM pg_index idx
426
- JOIN pg_attribute a ON a.attrelid = idx.indrelid AND a.attnum = ANY(idx.indkey)
427
- WHERE idx.indrelid = p_entity::regclass AND idx.indisprimary;
428
-
429
- -- Get the primary key value from the record
430
- l_record_id := l_record->>l_pk_cols[1]; -- Assume single PK for now
431
-
432
- FOR l_m2m_key IN SELECT jsonb_object_keys(l_entity_config.many_to_many)
433
- LOOP
434
- l_m2m_config := l_entity_config.many_to_many->l_m2m_key;
435
- l_id_field := l_m2m_config->>'id_field';
436
- l_junction_table := l_m2m_config->>'junction_table';
437
- l_local_key := l_m2m_config->>'local_key';
438
- l_foreign_key := l_m2m_config->>'foreign_key';
439
- l_target_entity := l_m2m_config->>'target_entity';
440
- l_expand := COALESCE((l_m2m_config->>'expand')::boolean, false);
441
-
442
- -- Always include array of IDs
443
- EXECUTE format('
444
- SELECT COALESCE(jsonb_agg(%I), ''[]''::jsonb)
445
- FROM %I
446
- WHERE %I = $1::int
447
- ', l_foreign_key, l_junction_table, l_local_key)
448
- INTO l_id_array
449
- USING l_record_id;
450
-
451
- l_record := l_record || jsonb_build_object(l_id_field, l_id_array);
452
-
453
- -- Conditionally include expanded objects if expand: true
454
- IF l_expand THEN
455
- EXECUTE format('
456
- SELECT COALESCE(jsonb_agg(to_jsonb(t.*)), ''[]''::jsonb)
457
- FROM %I jt
458
- JOIN %I t ON t.id = jt.%I
459
- WHERE jt.%I = $1::int
460
- ', l_junction_table, l_target_entity, l_foreign_key, l_local_key)
461
- INTO l_expanded_objects
462
- USING l_record_id;
463
-
464
- l_record := l_record || jsonb_build_object(l_m2m_key, l_expanded_objects);
465
- END IF;
466
- END LOOP;
467
- END;
468
- END IF;
469
-
470
- l_processed_data := l_processed_data || l_record;
471
- END LOOP;
472
-
473
- -- Convert processed data back to jsonb array
474
- l_data := to_jsonb(l_processed_data);
475
- END IF;
476
-
477
- RETURN jsonb_build_object(
478
- 'data', COALESCE(l_data, '[]'::jsonb),
479
- 'total', l_total,
480
- 'page', l_page,
481
- 'limit', l_limit,
482
- 'pages', CEIL(l_total::numeric / l_limit)
483
- );
484
-
485
- EXCEPTION
486
- WHEN OTHERS THEN
487
- RAISE EXCEPTION 'DZQL: search error for entity %: %', p_entity, SQLERRM;
488
- END $$;
489
-
490
- -- ============================================================================
491
- -- SEARCH UTILITIES
492
- -- ============================================================================
493
-
494
- -- Build faceted search aggregations
495
- CREATE OR REPLACE FUNCTION dzql.build_search_facets(
496
- p_entity text,
497
- p_filters jsonb,
498
- p_facet_fields text[]
499
- ) RETURNS jsonb
500
- LANGUAGE plpgsql AS $$
501
- DECLARE
502
- l_base_where text;
503
- l_facet_field text;
504
- l_facet_sql text;
505
- l_facet_result jsonb;
506
- l_facets jsonb := '{}'::jsonb;
507
- BEGIN
508
- -- Build base WHERE clause (without the facet field being aggregated)
509
- l_base_where := COALESCE(dzql.build_where_clause(p_entity, p_filters), '1=1');
510
-
511
- -- Build facets for each requested field
512
- FOREACH l_facet_field IN ARRAY p_facet_fields
513
- LOOP
514
- l_facet_sql := format(
515
- 'SELECT COALESCE(jsonb_agg(jsonb_build_object(''value'', %I, ''count'', count)), ''[]''::jsonb)
516
- FROM (
517
- SELECT %I, COUNT(*) as count
518
- FROM %I
519
- WHERE %s AND %I IS NOT NULL
520
- GROUP BY %I
521
- ORDER BY count DESC, %I
522
- LIMIT 20
523
- ) facet_data',
524
- l_facet_field, l_facet_field, p_entity, l_base_where,
525
- l_facet_field, l_facet_field, l_facet_field
526
- );
527
-
528
- EXECUTE l_facet_sql INTO l_facet_result;
529
- l_facets := l_facets || jsonb_build_object(l_facet_field, l_facet_result);
530
- END LOOP;
531
-
532
- RETURN l_facets;
533
- END $$;
534
-
535
- -- Build search suggestions based on searchable fields
536
- CREATE OR REPLACE FUNCTION dzql.build_search_suggestions(
537
- p_entity text,
538
- p_partial_text text,
539
- p_limit int DEFAULT 10
540
- ) RETURNS jsonb
541
- LANGUAGE plpgsql AS $$
542
- DECLARE
543
- l_entity_config record;
544
- l_field text;
545
- l_suggestions_sql text;
546
- l_field_sqls text[] := array[]::text[];
547
- l_result jsonb;
548
- BEGIN
549
- -- Get entity configuration
550
- SELECT * INTO l_entity_config FROM dzql.entities WHERE table_name = p_entity;
551
-
552
- IF l_entity_config IS NULL THEN
553
- RETURN '[]'::jsonb;
554
- END IF;
555
-
556
- -- Build UNION query for all searchable fields
557
- FOREACH l_field IN ARRAY l_entity_config.searchable_fields
558
- LOOP
559
- l_field_sqls := l_field_sqls || format(
560
- 'SELECT DISTINCT %I::text as suggestion FROM %I WHERE %I::text ILIKE %L AND %I IS NOT NULL',
561
- l_field, p_entity, l_field, p_partial_text || '%', l_field
562
- );
563
- END LOOP;
564
-
565
- IF array_length(l_field_sqls, 1) = 0 THEN
566
- RETURN '[]'::jsonb;
567
- END IF;
568
-
569
- l_suggestions_sql := format(
570
- 'SELECT COALESCE(jsonb_agg(suggestion ORDER BY suggestion), ''[]''::jsonb) FROM (
571
- %s
572
- LIMIT %s
573
- ) suggestions',
574
- array_to_string(l_field_sqls, ' UNION '),
575
- p_limit
576
- );
577
-
578
- EXECUTE l_suggestions_sql INTO l_result;
579
-
580
- RETURN COALESCE(l_result, '[]'::jsonb);
581
- END $$;