dzql 0.1.0-alpha.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.
@@ -0,0 +1,505 @@
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 = 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 != 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
+ -- Build base SQL
346
+ l_base_sql := format('FROM %I t %s', p_entity, l_where_clause);
347
+
348
+ -- Get total count
349
+ l_count_sql := 'SELECT COUNT(*) ' || l_base_sql;
350
+ EXECUTE l_count_sql INTO l_total;
351
+
352
+ -- Get paginated data - Always use subquery to ensure LIMIT works correctly
353
+ IF l_order_clause != '' THEN
354
+ l_data_sql := format('SELECT COALESCE(jsonb_agg(to_jsonb(sub.*)), ''[]''::jsonb) FROM (SELECT t.* %s %s LIMIT %L OFFSET %L) sub',
355
+ l_base_sql, l_order_clause, l_limit, l_offset);
356
+ ELSE
357
+ -- Use subquery even without ORDER BY to ensure LIMIT is applied before aggregation
358
+ l_data_sql := format('SELECT COALESCE(jsonb_agg(to_jsonb(sub.*)), ''[]''::jsonb) FROM (SELECT t.* %s ORDER BY %s LIMIT %L OFFSET %L) sub',
359
+ l_base_sql, l_pk_order, l_limit, l_offset);
360
+ END IF;
361
+
362
+ EXECUTE l_data_sql INTO l_data;
363
+
364
+ -- Process FK dereferencing for each record
365
+ l_fk_includes := l_entity_config.fk_includes;
366
+ 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
367
+ -- Process each record in the data array
368
+ FOR i IN 0..jsonb_array_length(l_data) - 1 LOOP
369
+ l_record := l_data->i;
370
+
371
+ -- Dereference foreign keys for this record
372
+ FOR l_key, l_value IN SELECT key, value FROM jsonb_each_text(l_fk_includes)
373
+ LOOP
374
+ -- Handle different FK reference formats
375
+ IF l_value LIKE '%.%' THEN
376
+ -- Format: "table.field" for reverse foreign keys
377
+ l_fk_result := dzql.resolve_reverse_fk(l_record, l_key, l_value, l_on_date);
378
+ ELSIF l_key = l_value THEN
379
+ -- When key equals value (e.g., "sites": "sites"), it's a reverse FK
380
+ -- The target table has a field named {entity_singular}_id pointing back to this entity
381
+ -- Convert plural entity name to singular (simple rule: remove trailing 's')
382
+ l_fk_result := dzql.resolve_reverse_fk(l_record, l_key,
383
+ l_value || '.' || regexp_replace(p_entity, 's$', '') || '_id', l_on_date);
384
+ ELSE
385
+ -- Format: "table" for direct foreign keys
386
+ l_fk_result := dzql.resolve_direct_fk(l_record, l_key, l_value, l_on_date);
387
+ END IF;
388
+
389
+ IF l_fk_result IS NOT NULL THEN
390
+ l_record := l_record || jsonb_build_object(l_key, l_fk_result);
391
+ END IF;
392
+ END LOOP;
393
+
394
+ l_processed_data := l_processed_data || l_record;
395
+ END LOOP;
396
+
397
+ -- Convert processed data back to jsonb array
398
+ l_data := to_jsonb(l_processed_data);
399
+ END IF;
400
+
401
+ RETURN jsonb_build_object(
402
+ 'data', COALESCE(l_data, '[]'::jsonb),
403
+ 'total', l_total,
404
+ 'page', l_page,
405
+ 'limit', l_limit,
406
+ 'pages', CEIL(l_total::numeric / l_limit)
407
+ );
408
+
409
+ EXCEPTION
410
+ WHEN OTHERS THEN
411
+ RAISE EXCEPTION 'DZQL: search error for entity %: %', p_entity, SQLERRM;
412
+ END $$;
413
+
414
+ -- ============================================================================
415
+ -- SEARCH UTILITIES
416
+ -- ============================================================================
417
+
418
+ -- Build faceted search aggregations
419
+ CREATE OR REPLACE FUNCTION dzql.build_search_facets(
420
+ p_entity text,
421
+ p_filters jsonb,
422
+ p_facet_fields text[]
423
+ ) RETURNS jsonb
424
+ LANGUAGE plpgsql AS $$
425
+ DECLARE
426
+ l_base_where text;
427
+ l_facet_field text;
428
+ l_facet_sql text;
429
+ l_facet_result jsonb;
430
+ l_facets jsonb := '{}'::jsonb;
431
+ BEGIN
432
+ -- Build base WHERE clause (without the facet field being aggregated)
433
+ l_base_where := COALESCE(dzql.build_where_clause(p_entity, p_filters), '1=1');
434
+
435
+ -- Build facets for each requested field
436
+ FOREACH l_facet_field IN ARRAY p_facet_fields
437
+ LOOP
438
+ l_facet_sql := format(
439
+ 'SELECT COALESCE(jsonb_agg(jsonb_build_object(''value'', %I, ''count'', count)), ''[]''::jsonb)
440
+ FROM (
441
+ SELECT %I, COUNT(*) as count
442
+ FROM %I
443
+ WHERE %s AND %I IS NOT NULL
444
+ GROUP BY %I
445
+ ORDER BY count DESC, %I
446
+ LIMIT 20
447
+ ) facet_data',
448
+ l_facet_field, l_facet_field, p_entity, l_base_where,
449
+ l_facet_field, l_facet_field, l_facet_field
450
+ );
451
+
452
+ EXECUTE l_facet_sql INTO l_facet_result;
453
+ l_facets := l_facets || jsonb_build_object(l_facet_field, l_facet_result);
454
+ END LOOP;
455
+
456
+ RETURN l_facets;
457
+ END $$;
458
+
459
+ -- Build search suggestions based on searchable fields
460
+ CREATE OR REPLACE FUNCTION dzql.build_search_suggestions(
461
+ p_entity text,
462
+ p_partial_text text,
463
+ p_limit int DEFAULT 10
464
+ ) RETURNS jsonb
465
+ LANGUAGE plpgsql AS $$
466
+ DECLARE
467
+ l_entity_config record;
468
+ l_field text;
469
+ l_suggestions_sql text;
470
+ l_field_sqls text[] := array[]::text[];
471
+ l_result jsonb;
472
+ BEGIN
473
+ -- Get entity configuration
474
+ SELECT * INTO l_entity_config FROM dzql.entities WHERE table_name = p_entity;
475
+
476
+ IF l_entity_config IS NULL THEN
477
+ RETURN '[]'::jsonb;
478
+ END IF;
479
+
480
+ -- Build UNION query for all searchable fields
481
+ FOREACH l_field IN ARRAY l_entity_config.searchable_fields
482
+ LOOP
483
+ l_field_sqls := l_field_sqls || format(
484
+ 'SELECT DISTINCT %I::text as suggestion FROM %I WHERE %I::text ILIKE %L AND %I IS NOT NULL',
485
+ l_field, p_entity, l_field, p_partial_text || '%', l_field
486
+ );
487
+ END LOOP;
488
+
489
+ IF array_length(l_field_sqls, 1) = 0 THEN
490
+ RETURN '[]'::jsonb;
491
+ END IF;
492
+
493
+ l_suggestions_sql := format(
494
+ 'SELECT COALESCE(jsonb_agg(suggestion ORDER BY suggestion), ''[]''::jsonb) FROM (
495
+ %s
496
+ LIMIT %s
497
+ ) suggestions',
498
+ array_to_string(l_field_sqls, ' UNION '),
499
+ p_limit
500
+ );
501
+
502
+ EXECUTE l_suggestions_sql INTO l_result;
503
+
504
+ RETURN COALESCE(l_result, '[]'::jsonb);
505
+ END $$;