dzql 0.6.34 → 0.6.35
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
|
@@ -109,6 +109,7 @@ generated/
|
|
|
109
109
|
|
|
110
110
|
- [Domain Modeling Guide](./for_ai.md) - Entity and permission patterns
|
|
111
111
|
- [Project Setup](./project-setup.md) - Manual setup and configuration
|
|
112
|
+
- [Architecture Roadmap](./futures.md) - Performance and scaling plan
|
|
112
113
|
- [Feature Requests](./feature-requests/) - Roadmap and proposals
|
|
113
114
|
|
|
114
115
|
## Package Exports
|
package/package.json
CHANGED
|
@@ -18,6 +18,7 @@ export { generateSaveFunction } from "./save.js";
|
|
|
18
18
|
export { generateDeleteFunction } from "./delete.js";
|
|
19
19
|
export { generateGetFunction, generateHistoryFunction } from "./get.js";
|
|
20
20
|
export { generateSearchFunction } from "./search.js";
|
|
21
|
+
export { generateLookupFunction } from "./lookup.js";
|
|
21
22
|
export { buildVisibleJsonb, getCastForType, stripFKReferences } from "./utils.js";
|
|
22
23
|
export type { ColumnInfo, EntityIR, ManyToManyIR, IncludeIR } from "./types.js";
|
|
23
24
|
|
|
@@ -26,6 +27,7 @@ import { generateSaveFunction } from "./save.js";
|
|
|
26
27
|
import { generateDeleteFunction } from "./delete.js";
|
|
27
28
|
import { generateGetFunction, generateHistoryFunction } from "./get.js";
|
|
28
29
|
import { generateSearchFunction } from "./search.js";
|
|
30
|
+
import { generateLookupFunction } from "./lookup.js";
|
|
29
31
|
|
|
30
32
|
// === AGGREGATE GENERATOR ===
|
|
31
33
|
export function generateEntitySQL(name: string, entityIR: EntityIR): string {
|
|
@@ -37,7 +39,8 @@ export function generateEntitySQL(name: string, entityIR: EntityIR): string {
|
|
|
37
39
|
generateSaveFunction(name, entityIR),
|
|
38
40
|
generateDeleteFunction(name, entityIR),
|
|
39
41
|
generateGetFunction(name, entityIR),
|
|
40
|
-
generateSearchFunction(name, entityIR)
|
|
42
|
+
generateSearchFunction(name, entityIR),
|
|
43
|
+
generateLookupFunction(name, entityIR)
|
|
41
44
|
];
|
|
42
45
|
|
|
43
46
|
// Add history function for temporal entities with refField
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { compilePermission } from "../../compiler/permissions.js";
|
|
2
|
+
import type { EntityIR } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generate Lookup Function
|
|
6
|
+
* Returns a set of { label: text, value: any } for autocomplete.
|
|
7
|
+
* Uses the entity's 'label' configuration for the display text.
|
|
8
|
+
*/
|
|
9
|
+
export function generateLookupFunction(name: string, entityIR: EntityIR): string {
|
|
10
|
+
const pk = entityIR.primaryKey[0] || 'id';
|
|
11
|
+
const labelField = entityIR.labelField || pk;
|
|
12
|
+
const searchable = entityIR.searchable || [labelField];
|
|
13
|
+
const softDelete = entityIR.softDelete || false;
|
|
14
|
+
const temporal = entityIR.temporal;
|
|
15
|
+
|
|
16
|
+
const viewPermRaw = entityIR.permissions?.view?.length > 0
|
|
17
|
+
? entityIR.permissions.view.map((rule: string) => compilePermission(name, rule, null, name)).join(' OR ')
|
|
18
|
+
: 'TRUE';
|
|
19
|
+
const viewPerm = viewPermRaw.replace(/p_user_id/g, '$1');
|
|
20
|
+
|
|
21
|
+
const softDeleteFilter = softDelete ? ' AND deleted_at IS NULL' : '';
|
|
22
|
+
const temporalFilter = temporal ? ` AND ${temporal.validTo} IS NULL` : '';
|
|
23
|
+
|
|
24
|
+
// Build ILIKE conditions for searchable fields
|
|
25
|
+
const searchConditions = searchable
|
|
26
|
+
.map(field => `t.${field}::TEXT ILIKE $2`)
|
|
27
|
+
.join(' OR ');
|
|
28
|
+
|
|
29
|
+
return `
|
|
30
|
+
CREATE OR REPLACE FUNCTION dzql_v2.lookup_${name}(p_user_id int, p_query jsonb)
|
|
31
|
+
RETURNS SETOF jsonb
|
|
32
|
+
LANGUAGE plpgsql
|
|
33
|
+
SECURITY DEFINER
|
|
34
|
+
SET search_path = dzql_v2, public
|
|
35
|
+
AS $$
|
|
36
|
+
DECLARE
|
|
37
|
+
v_q text;
|
|
38
|
+
v_limit int;
|
|
39
|
+
BEGIN
|
|
40
|
+
v_q := COALESCE(p_query->>'q', '');
|
|
41
|
+
v_limit := COALESCE((p_query->>'limit')::int, 20);
|
|
42
|
+
|
|
43
|
+
IF v_q = '' THEN
|
|
44
|
+
RETURN QUERY
|
|
45
|
+
SELECT jsonb_build_object('label', t.${labelField}, 'value', t.${pk})
|
|
46
|
+
FROM ${name} t
|
|
47
|
+
WHERE (${viewPerm})${softDeleteFilter}${temporalFilter}
|
|
48
|
+
ORDER BY t.${labelField} ASC
|
|
49
|
+
LIMIT v_limit;
|
|
50
|
+
ELSE
|
|
51
|
+
RETURN QUERY
|
|
52
|
+
SELECT jsonb_build_object('label', t.${labelField}, 'value', t.${pk})
|
|
53
|
+
FROM ${name} t
|
|
54
|
+
WHERE (${viewPerm})${softDeleteFilter}${temporalFilter}
|
|
55
|
+
AND (${searchConditions.replace(/\$2/g, "LOWER('%' || v_q || '%')")})
|
|
56
|
+
ORDER BY t.${labelField} ASC
|
|
57
|
+
LIMIT v_limit;
|
|
58
|
+
END IF;
|
|
59
|
+
END;
|
|
60
|
+
$$;
|
|
61
|
+
`;
|
|
62
|
+
}
|
|
@@ -66,13 +66,12 @@ export function generateSearchFunction(name: string, entityIR: EntityIR): string
|
|
|
66
66
|
|
|
67
67
|
return `
|
|
68
68
|
CREATE OR REPLACE FUNCTION dzql_v2.search_${name}(p_user_id int, p_query jsonb)
|
|
69
|
-
RETURNS jsonb
|
|
69
|
+
RETURNS SETOF jsonb
|
|
70
70
|
LANGUAGE plpgsql
|
|
71
71
|
SECURITY DEFINER
|
|
72
72
|
SET search_path = dzql_v2, public
|
|
73
73
|
AS $$
|
|
74
74
|
DECLARE
|
|
75
|
-
v_results jsonb;
|
|
76
75
|
v_filters jsonb;
|
|
77
76
|
v_sort_field text;
|
|
78
77
|
v_sort_order text;
|
|
@@ -175,9 +174,9 @@ BEGIN
|
|
|
175
174
|
END IF;
|
|
176
175
|
END LOOP;
|
|
177
176
|
|
|
178
|
-
-- Execute dynamic query
|
|
179
|
-
EXECUTE format('
|
|
180
|
-
SELECT
|
|
177
|
+
-- Execute dynamic query and return set of jsonb
|
|
178
|
+
RETURN QUERY EXECUTE format('
|
|
179
|
+
SELECT ${selectExpr}
|
|
181
180
|
FROM (
|
|
182
181
|
SELECT * FROM ${name}
|
|
183
182
|
WHERE (${viewPerm})${softDeleteFilter}${temporalFilter} %s
|
|
@@ -187,10 +186,7 @@ BEGIN
|
|
|
187
186
|
', v_where_clause, v_sort_field, v_sort_order,
|
|
188
187
|
COALESCE((p_query->>'limit')::int, 10),
|
|
189
188
|
COALESCE((p_query->>'offset')::int, 0))
|
|
190
|
-
INTO v_results
|
|
191
189
|
USING p_user_id;
|
|
192
|
-
|
|
193
|
-
RETURN v_results;
|
|
194
190
|
END;
|
|
195
191
|
$$;
|
|
196
192
|
`;
|
|
@@ -312,18 +312,19 @@ function generateGetFunction(name: string, sub: SubscribableIR, entities: Record
|
|
|
312
312
|
: '';
|
|
313
313
|
|
|
314
314
|
if (isList) {
|
|
315
|
-
// List subscribable - return
|
|
315
|
+
// List subscribable - return set of documents (STREAMING compatible)
|
|
316
|
+
// The Runtime will aggregate these or stream them.
|
|
317
|
+
// Schema metadata should be attached by the Runtime from the manifest.
|
|
316
318
|
return `CREATE OR REPLACE FUNCTION dzql_v2.get_${name}(
|
|
317
319
|
p_params JSONB,
|
|
318
320
|
p_user_id INT
|
|
319
|
-
) RETURNS JSONB
|
|
321
|
+
) RETURNS SETOF JSONB
|
|
320
322
|
LANGUAGE plpgsql
|
|
321
323
|
SECURITY DEFINER
|
|
322
324
|
SET search_path = dzql_v2, public
|
|
323
325
|
AS $$
|
|
324
326
|
DECLARE
|
|
325
327
|
${paramDecls}
|
|
326
|
-
v_data JSONB;
|
|
327
328
|
BEGIN
|
|
328
329
|
-- Extract parameters
|
|
329
330
|
${paramExtracts}
|
|
@@ -333,23 +334,15 @@ ${paramExtracts}
|
|
|
333
334
|
RAISE EXCEPTION 'permission_denied';
|
|
334
335
|
END IF;
|
|
335
336
|
|
|
336
|
-
--
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
), '[]'::jsonb)
|
|
340
|
-
INTO v_data
|
|
337
|
+
-- Return stream of documents
|
|
338
|
+
RETURN QUERY
|
|
339
|
+
SELECT to_jsonb(root.*) || jsonb_build_object(${relationSelects ? relationSelects.slice(1) : ''})
|
|
341
340
|
FROM ${sub.root.entity} root
|
|
342
341
|
${whereClause};
|
|
343
|
-
|
|
344
|
-
-- Return data with embedded schema for atomic updates
|
|
345
|
-
RETURN jsonb_build_object(
|
|
346
|
-
'data', jsonb_build_object('${sub.root.entity}', v_data),
|
|
347
|
-
'schema', '${schemaJson}'::jsonb
|
|
348
|
-
);
|
|
349
342
|
END;
|
|
350
343
|
$$;`;
|
|
351
344
|
} else {
|
|
352
|
-
// Single record subscribable
|
|
345
|
+
// Single record subscribable - return single JSONB
|
|
353
346
|
return `CREATE OR REPLACE FUNCTION dzql_v2.get_${name}(
|
|
354
347
|
p_params JSONB,
|
|
355
348
|
p_user_id INT
|
|
@@ -378,11 +371,8 @@ ${paramExtracts}
|
|
|
378
371
|
FROM ${sub.root.entity} root
|
|
379
372
|
${whereClause};
|
|
380
373
|
|
|
381
|
-
-- Return data
|
|
382
|
-
RETURN
|
|
383
|
-
'data', v_data,
|
|
384
|
-
'schema', '${schemaJson}'::jsonb
|
|
385
|
-
);
|
|
374
|
+
-- Return data
|
|
375
|
+
RETURN v_data;
|
|
386
376
|
END;
|
|
387
377
|
$$;`;
|
|
388
378
|
}
|
package/src/runtime/server.ts
CHANGED
|
@@ -85,7 +85,56 @@ export async function handleRequest(
|
|
|
85
85
|
|
|
86
86
|
const sql = `SELECT ${qualifiedName}(${sqlArgs.join(', ')}) as result`;
|
|
87
87
|
const rows = await db.query(sql, dbParams);
|
|
88
|
-
|
|
88
|
+
|
|
89
|
+
// 5. Post-Processing & Schema Attachment (for Subscribables)
|
|
90
|
+
const subName = method.startsWith('get_') ? method.slice(4) : null;
|
|
91
|
+
const subscribable = subName ? manifest.subscribables[subName] : null;
|
|
92
|
+
|
|
93
|
+
if (subscribable) {
|
|
94
|
+
// ... (existing code) ...
|
|
95
|
+
// It's a subscribable getter!
|
|
96
|
+
const pathMapping: Record<string, string> = {};
|
|
97
|
+
pathMapping[subscribable.root.entity] = '.';
|
|
98
|
+
|
|
99
|
+
const buildPaths = (incl: any, parent: string) => {
|
|
100
|
+
for (const [relName, relConfig] of Object.entries(incl || {})) {
|
|
101
|
+
const currentPath = parent ? `${parent}.${relName}` : relName;
|
|
102
|
+
pathMapping[(relConfig as any).entity] = currentPath;
|
|
103
|
+
if ((relConfig as any).includes) buildPaths((relConfig as any).includes, currentPath);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
buildPaths(subscribable.includes, '');
|
|
107
|
+
|
|
108
|
+
const schema = {
|
|
109
|
+
root: subscribable.root.entity,
|
|
110
|
+
paths: pathMapping,
|
|
111
|
+
scopeTables: subscribable.scopeTables
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
let data: any;
|
|
115
|
+
if (!subscribable.root.key) {
|
|
116
|
+
// List Subscribable: Aggregate rows in Bun
|
|
117
|
+
const items = rows.map((r: any) => r.result).filter((i: any) => i !== null);
|
|
118
|
+
data = { [subscribable.root.entity]: items };
|
|
119
|
+
} else {
|
|
120
|
+
// Single Item Subscribable
|
|
121
|
+
data = rows[0]?.result || null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { data, schema };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 6. Handle Search/Lookup (List results)
|
|
128
|
+
if (method.startsWith('search_') || method.startsWith('lookup_')) {
|
|
129
|
+
console.log(`[Runtime] Handling search/lookup: ${method}, rows:`, rows.length);
|
|
130
|
+
const results = rows.map((r: any) => r.result).filter((i: any) => i !== null);
|
|
131
|
+
console.log(`[Runtime] Mapped results type:`, Array.isArray(results) ? 'Array' : typeof results);
|
|
132
|
+
return results;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Standard CRUD (get/save/delete) -> return single row result
|
|
136
|
+
return rows[0]?.result;
|
|
137
|
+
|
|
89
138
|
} catch (err: any) {
|
|
90
139
|
// 5. Error Sanitization
|
|
91
140
|
console.error(`[Runtime] DB Error executing ${method}:`, err);
|