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.
- package/.env.sample +28 -0
- package/compose.yml +28 -0
- package/dist/client/index.ts +1 -0
- package/dist/client/stores/useMyProfileStore.ts +114 -0
- package/dist/client/stores/useOrgDashboardStore.ts +131 -0
- package/dist/client/stores/useVenueDetailStore.ts +117 -0
- package/dist/client/ws.ts +716 -0
- package/dist/db/migrations/000_core.sql +92 -0
- package/dist/db/migrations/20251229T212912022Z_schema.sql +3020 -0
- package/dist/db/migrations/20251229T212912022Z_subscribables.sql +371 -0
- package/dist/runtime/manifest.json +1562 -0
- package/docs/README.md +309 -36
- package/docs/feature-requests/applyPatch-bug-report.md +85 -0
- package/docs/feature-requests/connection-ready-profile.md +57 -0
- package/docs/feature-requests/hidden-bug-report.md +111 -0
- package/docs/feature-requests/hidden-fields-subscribables.md +34 -0
- package/docs/feature-requests/subscribable-param-key-bug.md +38 -0
- package/docs/feature-requests/todo.md +146 -0
- package/docs/for_ai.md +653 -0
- package/docs/project-setup.md +456 -0
- package/examples/blog.ts +50 -0
- package/examples/invalid.ts +18 -0
- package/examples/venues.js +485 -0
- package/package.json +23 -60
- package/src/cli/codegen/client.ts +99 -0
- package/src/cli/codegen/manifest.ts +95 -0
- package/src/cli/codegen/pinia.ts +174 -0
- package/src/cli/codegen/realtime.ts +58 -0
- package/src/cli/codegen/sql.ts +698 -0
- package/src/cli/codegen/subscribable_sql.ts +547 -0
- package/src/cli/codegen/subscribable_store.ts +184 -0
- package/src/cli/codegen/types.ts +142 -0
- package/src/cli/compiler/analyzer.ts +52 -0
- package/src/cli/compiler/graph_rules.ts +251 -0
- package/src/cli/compiler/ir.ts +233 -0
- package/src/cli/compiler/loader.ts +132 -0
- package/src/cli/compiler/permissions.ts +227 -0
- package/src/cli/index.ts +166 -0
- package/src/client/index.ts +1 -0
- package/src/client/ws.ts +286 -0
- package/src/runtime/auth.ts +39 -0
- package/src/runtime/db.ts +33 -0
- package/src/runtime/errors.ts +51 -0
- package/src/runtime/index.ts +98 -0
- package/src/runtime/js_functions.ts +63 -0
- package/src/runtime/manifest_loader.ts +29 -0
- package/src/runtime/namespace.ts +483 -0
- package/src/runtime/server.ts +87 -0
- package/src/runtime/ws.ts +197 -0
- package/src/shared/ir.ts +197 -0
- package/tests/client.test.ts +38 -0
- package/tests/codegen.test.ts +71 -0
- package/tests/compiler.test.ts +45 -0
- package/tests/graph_rules.test.ts +173 -0
- package/tests/integration/db.test.ts +174 -0
- package/tests/integration/e2e.test.ts +65 -0
- package/tests/integration/features.test.ts +922 -0
- package/tests/integration/full_stack.test.ts +262 -0
- package/tests/integration/setup.ts +45 -0
- package/tests/ir.test.ts +32 -0
- package/tests/namespace.test.ts +395 -0
- package/tests/permissions.test.ts +55 -0
- package/tests/pinia.test.ts +48 -0
- package/tests/realtime.test.ts +22 -0
- package/tests/runtime.test.ts +80 -0
- package/tests/subscribable_gen.test.ts +72 -0
- package/tests/subscribable_reactivity.test.ts +258 -0
- package/tests/venues_gen.test.ts +25 -0
- package/tsconfig.json +20 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/README.md +0 -90
- package/bin/cli.js +0 -727
- package/docs/compiler/ADVANCED_FILTERS.md +0 -183
- package/docs/compiler/CODING_STANDARDS.md +0 -415
- package/docs/compiler/COMPARISON.md +0 -673
- package/docs/compiler/QUICKSTART.md +0 -326
- package/docs/compiler/README.md +0 -134
- package/docs/examples/README.md +0 -38
- package/docs/examples/blog.sql +0 -160
- package/docs/examples/venue-detail-simple.sql +0 -8
- package/docs/examples/venue-detail-subscribable.sql +0 -45
- package/docs/for-ai/claude-guide.md +0 -1210
- package/docs/getting-started/quickstart.md +0 -125
- package/docs/getting-started/subscriptions-quick-start.md +0 -203
- package/docs/getting-started/tutorial.md +0 -1104
- package/docs/guides/atomic-updates.md +0 -299
- package/docs/guides/client-stores.md +0 -730
- package/docs/guides/composite-primary-keys.md +0 -158
- package/docs/guides/custom-functions.md +0 -362
- package/docs/guides/drop-semantics.md +0 -554
- package/docs/guides/field-defaults.md +0 -240
- package/docs/guides/interpreter-vs-compiler.md +0 -237
- package/docs/guides/many-to-many.md +0 -929
- package/docs/guides/subscriptions.md +0 -537
- package/docs/reference/api.md +0 -1373
- package/docs/reference/client.md +0 -224
- package/src/client/stores/index.js +0 -8
- package/src/client/stores/useAppStore.js +0 -285
- package/src/client/stores/useWsStore.js +0 -289
- package/src/client/ws.js +0 -762
- package/src/compiler/cli/compile-example.js +0 -33
- package/src/compiler/cli/compile-subscribable.js +0 -43
- package/src/compiler/cli/debug-compile.js +0 -44
- package/src/compiler/cli/debug-parse.js +0 -26
- package/src/compiler/cli/debug-path-parser.js +0 -18
- package/src/compiler/cli/debug-subscribable-parser.js +0 -21
- package/src/compiler/cli/index.js +0 -174
- package/src/compiler/codegen/auth-codegen.js +0 -153
- package/src/compiler/codegen/drop-semantics-codegen.js +0 -553
- package/src/compiler/codegen/graph-rules-codegen.js +0 -450
- package/src/compiler/codegen/notification-codegen.js +0 -232
- package/src/compiler/codegen/operation-codegen.js +0 -1382
- package/src/compiler/codegen/permission-codegen.js +0 -318
- package/src/compiler/codegen/subscribable-codegen.js +0 -827
- package/src/compiler/compiler.js +0 -371
- package/src/compiler/index.js +0 -11
- package/src/compiler/parser/entity-parser.js +0 -440
- package/src/compiler/parser/path-parser.js +0 -290
- package/src/compiler/parser/subscribable-parser.js +0 -244
- package/src/database/dzql-core.sql +0 -161
- package/src/database/migrations/001_schema.sql +0 -60
- package/src/database/migrations/002_functions.sql +0 -890
- package/src/database/migrations/003_operations.sql +0 -1135
- package/src/database/migrations/004_search.sql +0 -581
- package/src/database/migrations/005_entities.sql +0 -730
- package/src/database/migrations/006_auth.sql +0 -94
- package/src/database/migrations/007_events.sql +0 -133
- package/src/database/migrations/008_hello.sql +0 -18
- package/src/database/migrations/008a_meta.sql +0 -172
- package/src/database/migrations/009_subscriptions.sql +0 -240
- package/src/database/migrations/010_atomic_updates.sql +0 -157
- package/src/database/migrations/010_fix_m2m_events.sql +0 -94
- package/src/index.js +0 -40
- package/src/server/api.js +0 -9
- package/src/server/db.js +0 -442
- package/src/server/index.js +0 -317
- package/src/server/logger.js +0 -259
- package/src/server/mcp.js +0 -594
- package/src/server/meta-route.js +0 -251
- package/src/server/namespace.js +0 -292
- package/src/server/subscriptions.js +0 -351
- package/src/server/ws.js +0 -573
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
import { compilePermission } from "../compiler/permissions.js";
|
|
2
|
+
import { compileGraphRules } from "../compiler/graph_rules.js";
|
|
3
|
+
import type { EntityIR, ManyToManyIR } from "../../shared/ir.js";
|
|
4
|
+
|
|
5
|
+
/** Column info from EntityIR */
|
|
6
|
+
interface ColumnInfo {
|
|
7
|
+
name: string;
|
|
8
|
+
type: string;
|
|
9
|
+
isArray: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generate a jsonb_build_object expression that excludes hidden fields.
|
|
14
|
+
* If no hidden fields, returns to_jsonb(alias.*) for efficiency.
|
|
15
|
+
* @param alias - Table alias (e.g., 'venues', 't', 'root')
|
|
16
|
+
* @param columns - All columns from entityIR
|
|
17
|
+
* @param hidden - Array of hidden field names
|
|
18
|
+
*/
|
|
19
|
+
function buildVisibleJsonb(alias: string, columns: ColumnInfo[], hidden: string[] = []): string {
|
|
20
|
+
if (!hidden || hidden.length === 0) {
|
|
21
|
+
return `to_jsonb(${alias}.*)`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const visibleCols = columns.filter(c => !hidden.includes(c.name));
|
|
25
|
+
const pairs = visibleCols.map(c => `'${c.name}', ${alias}.${c.name}`).join(', ');
|
|
26
|
+
return `jsonb_build_object(${pairs})`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function generateCoreSQL() {
|
|
30
|
+
return `
|
|
31
|
+
-- DZQL V2 Core Schema
|
|
32
|
+
CREATE SCHEMA IF NOT EXISTS dzql_v2;
|
|
33
|
+
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
34
|
+
|
|
35
|
+
-- Migrations Table
|
|
36
|
+
CREATE TABLE IF NOT EXISTS dzql_v2.migrations (
|
|
37
|
+
id text PRIMARY KEY,
|
|
38
|
+
applied_at timestamptz DEFAULT now(),
|
|
39
|
+
checksum text NOT NULL,
|
|
40
|
+
name text NOT NULL
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
-- Events Table (Normalized Row Events)
|
|
44
|
+
CREATE TABLE IF NOT EXISTS dzql_v2.events (
|
|
45
|
+
id bigserial PRIMARY KEY,
|
|
46
|
+
commit_id bigint NOT NULL,
|
|
47
|
+
table_name text NOT NULL,
|
|
48
|
+
op text NOT NULL,
|
|
49
|
+
pk jsonb NOT NULL,
|
|
50
|
+
data jsonb,
|
|
51
|
+
old_data jsonb,
|
|
52
|
+
user_id int,
|
|
53
|
+
created_at timestamptz DEFAULT now()
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
-- Commit Sequence
|
|
57
|
+
CREATE SEQUENCE IF NOT EXISTS dzql_v2.commit_seq;
|
|
58
|
+
|
|
59
|
+
-- === AUTH FUNCTIONS ===
|
|
60
|
+
|
|
61
|
+
-- Register User
|
|
62
|
+
CREATE OR REPLACE FUNCTION dzql_v2.register_user(p_params jsonb)
|
|
63
|
+
RETURNS jsonb
|
|
64
|
+
LANGUAGE plpgsql
|
|
65
|
+
SECURITY DEFINER
|
|
66
|
+
SET search_path = dzql_v2, public
|
|
67
|
+
AS $$
|
|
68
|
+
DECLARE
|
|
69
|
+
v_user_id int;
|
|
70
|
+
v_email text;
|
|
71
|
+
v_password text;
|
|
72
|
+
v_name text;
|
|
73
|
+
v_options jsonb;
|
|
74
|
+
BEGIN
|
|
75
|
+
v_email := p_params->>'email';
|
|
76
|
+
v_password := p_params->>'password';
|
|
77
|
+
v_name := COALESCE(p_params->>'name', v_email);
|
|
78
|
+
v_options := COALESCE(p_params->'options', '{}'::jsonb);
|
|
79
|
+
|
|
80
|
+
IF v_email IS NULL OR v_password IS NULL THEN
|
|
81
|
+
RAISE EXCEPTION 'validation_error: email and password required';
|
|
82
|
+
END IF;
|
|
83
|
+
|
|
84
|
+
INSERT INTO users (email, password_hash, name)
|
|
85
|
+
VALUES (v_email, crypt(v_password, gen_salt('bf')), v_name)
|
|
86
|
+
RETURNING id INTO v_user_id;
|
|
87
|
+
|
|
88
|
+
-- TODO: Handle v_options if needed (e.g. creating orgs)
|
|
89
|
+
|
|
90
|
+
-- Return minimal profile (Token generation happens in Runtime layer)
|
|
91
|
+
RETURN jsonb_build_object(
|
|
92
|
+
'user_id', v_user_id,
|
|
93
|
+
'email', v_email,
|
|
94
|
+
'name', v_name
|
|
95
|
+
);
|
|
96
|
+
END;
|
|
97
|
+
$$;
|
|
98
|
+
|
|
99
|
+
-- Login User
|
|
100
|
+
CREATE OR REPLACE FUNCTION dzql_v2.login_user(p_params jsonb)
|
|
101
|
+
RETURNS jsonb
|
|
102
|
+
LANGUAGE plpgsql
|
|
103
|
+
SECURITY DEFINER
|
|
104
|
+
SET search_path = dzql_v2, public
|
|
105
|
+
AS $$
|
|
106
|
+
DECLARE
|
|
107
|
+
v_user record;
|
|
108
|
+
BEGIN
|
|
109
|
+
SELECT * INTO v_user FROM users WHERE email = p_params->>'email';
|
|
110
|
+
|
|
111
|
+
IF v_user IS NULL OR v_user.password_hash != crypt(p_params->>'password', v_user.password_hash) THEN
|
|
112
|
+
RAISE EXCEPTION 'permission_denied: invalid credentials';
|
|
113
|
+
END IF;
|
|
114
|
+
|
|
115
|
+
RETURN jsonb_build_object(
|
|
116
|
+
'user_id', v_user.id,
|
|
117
|
+
'email', v_user.email,
|
|
118
|
+
'name', v_user.name
|
|
119
|
+
);
|
|
120
|
+
END;
|
|
121
|
+
$$;
|
|
122
|
+
`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function generateSchemaSQL(name: string, entityIR: EntityIR): string {
|
|
126
|
+
const columns = entityIR.columns.map((c: ColumnInfo) => {
|
|
127
|
+
return `${c.name} ${c.type}`;
|
|
128
|
+
}).join(',\n ');
|
|
129
|
+
|
|
130
|
+
return `
|
|
131
|
+
CREATE TABLE IF NOT EXISTS ${name} (
|
|
132
|
+
${columns}
|
|
133
|
+
);
|
|
134
|
+
`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// === SAVE FUNCTION (Upsert) ===
|
|
138
|
+
export function generateSaveFunction(name: string, entityIR: EntityIR): string {
|
|
139
|
+
const cols = entityIR.columns.map((c: ColumnInfo) => c.name);
|
|
140
|
+
const pkFields = entityIR.primaryKey.length > 0 ? entityIR.primaryKey : ['id'];
|
|
141
|
+
const pk = pkFields[0]; // For backwards compatibility with single PK
|
|
142
|
+
const isCompositePK = pkFields.length > 1;
|
|
143
|
+
const fieldDefaults = entityIR.fieldDefaults || {};
|
|
144
|
+
const hidden = entityIR.hidden || [];
|
|
145
|
+
|
|
146
|
+
// Build INSERT columns/values
|
|
147
|
+
// Exclude serial columns from INSERT (let DB handle sequence)
|
|
148
|
+
const insertCols = entityIR.columns.filter((c: ColumnInfo) => {
|
|
149
|
+
const isSerial = c.type.toLowerCase().includes('serial');
|
|
150
|
+
return !isSerial;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const colList = insertCols.map((c: ColumnInfo) => c.name).join(', ');
|
|
154
|
+
|
|
155
|
+
// Build value list with field defaults support
|
|
156
|
+
const valList = insertCols.map((c: ColumnInfo) => {
|
|
157
|
+
let cast = '';
|
|
158
|
+
if (c.type.includes('int') || c.type.includes('serial')) cast = '::int';
|
|
159
|
+
else if (c.type.includes('timestamp')) cast = '::timestamptz';
|
|
160
|
+
else if (c.type.includes('date')) cast = '::date';
|
|
161
|
+
else if (c.type.includes('bool')) cast = '::boolean';
|
|
162
|
+
else if (c.type.includes('decimal') || c.type.includes('numeric')) cast = '::numeric';
|
|
163
|
+
|
|
164
|
+
const defaultValue = fieldDefaults[c.name];
|
|
165
|
+
if (defaultValue) {
|
|
166
|
+
// Apply field default if not provided in p_data
|
|
167
|
+
let defaultExpr: string;
|
|
168
|
+
if (defaultValue === '@user_id') {
|
|
169
|
+
defaultExpr = 'p_user_id';
|
|
170
|
+
} else if (defaultValue === '@now') {
|
|
171
|
+
defaultExpr = 'now()';
|
|
172
|
+
} else if (defaultValue === '@today') {
|
|
173
|
+
defaultExpr = 'current_date';
|
|
174
|
+
} else {
|
|
175
|
+
// Literal value
|
|
176
|
+
defaultExpr = `'${defaultValue}'${cast}`;
|
|
177
|
+
}
|
|
178
|
+
return `COALESCE((p_data->>'${c.name}')${cast}, ${defaultExpr})`;
|
|
179
|
+
}
|
|
180
|
+
return `(p_data->>'${c.name}')${cast}`;
|
|
181
|
+
}).join(', ');
|
|
182
|
+
|
|
183
|
+
// Build UPDATE SET clause (Partial Update) - exclude all PK fields
|
|
184
|
+
const updateSetClause = entityIR.columns
|
|
185
|
+
.filter((c: ColumnInfo) => !pkFields.includes(c.name))
|
|
186
|
+
.map((c: ColumnInfo) => {
|
|
187
|
+
let cast = '';
|
|
188
|
+
if (c.type.includes('int') || c.type.includes('serial')) cast = '::int';
|
|
189
|
+
else if (c.type.includes('timestamp')) cast = '::timestamptz';
|
|
190
|
+
else if (c.type.includes('date')) cast = '::date';
|
|
191
|
+
else if (c.type.includes('bool')) cast = '::boolean';
|
|
192
|
+
else if (c.type.includes('decimal') || c.type.includes('numeric')) cast = '::numeric';
|
|
193
|
+
|
|
194
|
+
return `${c.name} = CASE WHEN (p_data ? '${c.name}') THEN (p_data->>'${c.name}')${cast} ELSE ${c.name} END`;
|
|
195
|
+
})
|
|
196
|
+
.join(',\n ');
|
|
197
|
+
|
|
198
|
+
// Build composite PK handling
|
|
199
|
+
const pkExistsCheck = pkFields.map(f => {
|
|
200
|
+
const col = entityIR.columns.find((c: ColumnInfo) => c.name === f);
|
|
201
|
+
let cast = '::int';
|
|
202
|
+
if (col) {
|
|
203
|
+
if (col.type.includes('text') || col.type.includes('varchar')) cast = '';
|
|
204
|
+
else if (col.type.includes('date')) cast = '::date';
|
|
205
|
+
else if (col.type.includes('timestamp')) cast = '::timestamptz';
|
|
206
|
+
}
|
|
207
|
+
return `${f} = (p_data->>'${f}')${cast}`;
|
|
208
|
+
}).join(' AND ');
|
|
209
|
+
|
|
210
|
+
const pkWhereClause = pkExistsCheck;
|
|
211
|
+
|
|
212
|
+
// Build PK JSONB object for events (use -> to preserve type, not ->> which extracts as text)
|
|
213
|
+
const pkJsonbExpr = pkFields.length === 1
|
|
214
|
+
? `jsonb_build_object('${pk}', v_result->'${pk}')`
|
|
215
|
+
: `jsonb_build_object(${pkFields.map(f => `'${f}', v_result->'${f}'`).join(', ')})`;
|
|
216
|
+
|
|
217
|
+
// Check if all PK fields are present
|
|
218
|
+
const pkNullCheck = pkFields.map(f => `(p_data->>'${f}') IS NOT NULL`).join(' AND ');
|
|
219
|
+
|
|
220
|
+
// Permissions & Graph Rules
|
|
221
|
+
const createPerm = entityIR.permissions?.create?.[0]
|
|
222
|
+
? compilePermission(name, entityIR.permissions.create[0], null, 'p_data')
|
|
223
|
+
: 'TRUE';
|
|
224
|
+
|
|
225
|
+
const updatePerm = entityIR.permissions?.update?.[0]
|
|
226
|
+
? compilePermission(name, entityIR.permissions.update[0], null, 'p_data')
|
|
227
|
+
: 'TRUE';
|
|
228
|
+
|
|
229
|
+
const onCreateRules = entityIR.graphRules?.onCreate
|
|
230
|
+
? compileGraphRules(name, 'create', entityIR.graphRules.onCreate)
|
|
231
|
+
: '';
|
|
232
|
+
|
|
233
|
+
const onUpdateRules = entityIR.graphRules?.onUpdate
|
|
234
|
+
? compileGraphRules(name, 'update', entityIR.graphRules.onUpdate)
|
|
235
|
+
: '';
|
|
236
|
+
|
|
237
|
+
// M2M Support
|
|
238
|
+
const m2m: Record<string, ManyToManyIR> = entityIR.manyToMany || {};
|
|
239
|
+
const m2mKeys = Object.keys(m2m);
|
|
240
|
+
|
|
241
|
+
// M2M variable declarations
|
|
242
|
+
const m2mVarDeclarations = m2mKeys.map(key => {
|
|
243
|
+
const config: ManyToManyIR = m2m[key];
|
|
244
|
+
return ` v_${config.idField} INT[];`;
|
|
245
|
+
}).join('\n');
|
|
246
|
+
|
|
247
|
+
// M2M extraction (remove from p_data before INSERT/UPDATE)
|
|
248
|
+
const m2mExtraction = m2mKeys.map(key => {
|
|
249
|
+
const config: ManyToManyIR = m2m[key];
|
|
250
|
+
return `
|
|
251
|
+
-- M2M: Extract ${key} IDs
|
|
252
|
+
IF p_data ? '${config.idField}' THEN
|
|
253
|
+
v_${config.idField} := ARRAY(SELECT jsonb_array_elements_text(p_data->'${config.idField}')::int);
|
|
254
|
+
p_data := p_data - '${config.idField}';
|
|
255
|
+
END IF;`;
|
|
256
|
+
}).join('\n');
|
|
257
|
+
|
|
258
|
+
// M2M sync (after INSERT/UPDATE) - uses first PK field for M2M local key
|
|
259
|
+
// Note: M2M typically uses a single local key (the entity's ID), not composite PK
|
|
260
|
+
const m2mSync = m2mKeys.map(key => {
|
|
261
|
+
const config: ManyToManyIR = m2m[key];
|
|
262
|
+
return `
|
|
263
|
+
-- M2M Sync: ${key} (junction: ${config.junctionTable})
|
|
264
|
+
IF v_${config.idField} IS NOT NULL THEN
|
|
265
|
+
-- Delete relationships not in new list
|
|
266
|
+
DELETE FROM ${config.junctionTable}
|
|
267
|
+
WHERE ${config.localKey} = (v_result->>'${pk}')::int
|
|
268
|
+
AND (${config.foreignKey} <> ALL(v_${config.idField}) OR v_${config.idField} = '{}');
|
|
269
|
+
|
|
270
|
+
-- Insert new relationships (idempotent)
|
|
271
|
+
IF array_length(v_${config.idField}, 1) > 0 THEN
|
|
272
|
+
INSERT INTO ${config.junctionTable} (${config.localKey}, ${config.foreignKey})
|
|
273
|
+
SELECT (v_result->>'${pk}')::int, unnest(v_${config.idField})
|
|
274
|
+
ON CONFLICT (${config.localKey}, ${config.foreignKey}) DO NOTHING;
|
|
275
|
+
END IF;
|
|
276
|
+
END IF;`;
|
|
277
|
+
}).join('\n');
|
|
278
|
+
|
|
279
|
+
// M2M expansion (add to output)
|
|
280
|
+
const m2mExpansion = m2mKeys.map(key => {
|
|
281
|
+
const config: ManyToManyIR = m2m[key];
|
|
282
|
+
let sql = `
|
|
283
|
+
-- M2M: Add ${config.idField} to output
|
|
284
|
+
v_result := v_result || jsonb_build_object('${config.idField}',
|
|
285
|
+
(SELECT COALESCE(jsonb_agg(${config.foreignKey} ORDER BY ${config.foreignKey}), '[]'::jsonb)
|
|
286
|
+
FROM ${config.junctionTable} WHERE ${config.localKey} = (v_result->>'${pk}')::int));`;
|
|
287
|
+
|
|
288
|
+
if (config.expand) {
|
|
289
|
+
sql += `
|
|
290
|
+
-- M2M: Add expanded ${key} to output
|
|
291
|
+
v_result := v_result || jsonb_build_object('${key}',
|
|
292
|
+
(SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
|
|
293
|
+
FROM ${config.junctionTable} jt
|
|
294
|
+
JOIN ${config.targetEntity} t ON t.id = jt.${config.foreignKey}
|
|
295
|
+
WHERE jt.${config.localKey} = (v_result->>'${pk}')::int));`;
|
|
296
|
+
}
|
|
297
|
+
return sql;
|
|
298
|
+
}).join('\n');
|
|
299
|
+
|
|
300
|
+
return `
|
|
301
|
+
CREATE OR REPLACE FUNCTION dzql_v2.save_${name}(p_user_id int, p_data jsonb)
|
|
302
|
+
RETURNS jsonb
|
|
303
|
+
LANGUAGE plpgsql
|
|
304
|
+
SECURITY DEFINER
|
|
305
|
+
SET search_path = dzql_v2, public
|
|
306
|
+
AS $$
|
|
307
|
+
DECLARE
|
|
308
|
+
v_result jsonb;
|
|
309
|
+
v_old_data jsonb;
|
|
310
|
+
v_commit_id bigint;
|
|
311
|
+
v_op text;
|
|
312
|
+
${m2mVarDeclarations}
|
|
313
|
+
BEGIN
|
|
314
|
+
v_commit_id := nextval('dzql_v2.commit_seq');
|
|
315
|
+
${m2mExtraction}
|
|
316
|
+
|
|
317
|
+
-- Determine Operation & Check Permissions (supports composite PK)
|
|
318
|
+
IF (${pkNullCheck}) AND EXISTS(SELECT 1 FROM ${name} WHERE ${pkWhereClause}) THEN
|
|
319
|
+
v_op := 'update';
|
|
320
|
+
|
|
321
|
+
-- Fetch old data for update rules/events
|
|
322
|
+
SELECT to_jsonb(${name}.*) INTO v_old_data FROM ${name} WHERE ${pkWhereClause};
|
|
323
|
+
|
|
324
|
+
IF NOT (${updatePerm}) THEN
|
|
325
|
+
RAISE EXCEPTION 'permission_denied';
|
|
326
|
+
END IF;
|
|
327
|
+
|
|
328
|
+
-- Perform Partial Update
|
|
329
|
+
UPDATE ${name} SET
|
|
330
|
+
${updateSetClause}
|
|
331
|
+
WHERE ${pkWhereClause}
|
|
332
|
+
RETURNING to_jsonb(${name}.*) INTO v_result;
|
|
333
|
+
|
|
334
|
+
${onUpdateRules}
|
|
335
|
+
|
|
336
|
+
ELSE
|
|
337
|
+
v_op := 'insert';
|
|
338
|
+
IF NOT (${createPerm}) THEN
|
|
339
|
+
RAISE EXCEPTION 'permission_denied';
|
|
340
|
+
END IF;
|
|
341
|
+
|
|
342
|
+
-- Perform Insert
|
|
343
|
+
INSERT INTO ${name} (${colList})
|
|
344
|
+
VALUES (${valList})
|
|
345
|
+
RETURNING to_jsonb(${name}.*) INTO v_result;
|
|
346
|
+
|
|
347
|
+
${onCreateRules}
|
|
348
|
+
END IF;
|
|
349
|
+
${m2mSync}
|
|
350
|
+
${m2mExpansion}
|
|
351
|
+
|
|
352
|
+
-- Emit Event
|
|
353
|
+
INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
|
|
354
|
+
VALUES (
|
|
355
|
+
v_commit_id,
|
|
356
|
+
'${name}',
|
|
357
|
+
v_op,
|
|
358
|
+
${pkJsonbExpr},
|
|
359
|
+
v_result,
|
|
360
|
+
v_old_data, -- NULL for insert
|
|
361
|
+
p_user_id
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
-- Notify Runtime
|
|
365
|
+
PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
|
|
366
|
+
|
|
367
|
+
-- Remove hidden fields before returning to client
|
|
368
|
+
RETURN ${hidden.length > 0 ? `v_result - ARRAY[${hidden.map(f => `'${f}'`).join(', ')}]` : 'v_result'};
|
|
369
|
+
END;
|
|
370
|
+
$$;
|
|
371
|
+
`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// === DELETE FUNCTION (Cascade or Soft Delete) ===
|
|
375
|
+
export function generateDeleteFunction(name: string, entityIR: EntityIR): string {
|
|
376
|
+
const pkFields = entityIR.primaryKey.length > 0 ? entityIR.primaryKey : ['id'];
|
|
377
|
+
const pk = pkFields[0];
|
|
378
|
+
const softDelete = entityIR.softDelete || false;
|
|
379
|
+
const hidden = entityIR.hidden || [];
|
|
380
|
+
|
|
381
|
+
// Build composite PK handling
|
|
382
|
+
const pkWhereClause = pkFields.map(f => {
|
|
383
|
+
const col = entityIR.columns.find((c: ColumnInfo) => c.name === f);
|
|
384
|
+
let cast = '::int';
|
|
385
|
+
if (col) {
|
|
386
|
+
if (col.type.includes('text') || col.type.includes('varchar')) cast = '';
|
|
387
|
+
else if (col.type.includes('date')) cast = '::date';
|
|
388
|
+
else if (col.type.includes('timestamp')) cast = '::timestamptz';
|
|
389
|
+
}
|
|
390
|
+
return `${f} = (p_pk->>'${f}')${cast}`;
|
|
391
|
+
}).join(' AND ');
|
|
392
|
+
|
|
393
|
+
// Build PK JSONB object for events (use -> to preserve type, not ->> which extracts as text)
|
|
394
|
+
const pkJsonbExpr = pkFields.length === 1
|
|
395
|
+
? `jsonb_build_object('${pk}', v_old_data->'${pk}')`
|
|
396
|
+
: `jsonb_build_object(${pkFields.map(f => `'${f}', v_old_data->'${f}'`).join(', ')})`;
|
|
397
|
+
|
|
398
|
+
// Permissions (Check against v_old_data)
|
|
399
|
+
const deletePerm = entityIR.permissions?.delete?.[0]
|
|
400
|
+
? compilePermission(name, entityIR.permissions.delete[0], null, 'v_old_data')
|
|
401
|
+
: 'TRUE';
|
|
402
|
+
|
|
403
|
+
const onDeleteRules = entityIR.graphRules?.onDelete
|
|
404
|
+
? compileGraphRules(name, 'delete', entityIR.graphRules.onDelete)
|
|
405
|
+
: '';
|
|
406
|
+
|
|
407
|
+
// Soft delete: UPDATE SET deleted_at = now() instead of DELETE
|
|
408
|
+
const deleteOperation = softDelete
|
|
409
|
+
? `UPDATE ${name} SET deleted_at = now() WHERE ${pkWhereClause}`
|
|
410
|
+
: `DELETE FROM ${name} WHERE ${pkWhereClause}`;
|
|
411
|
+
|
|
412
|
+
return `
|
|
413
|
+
CREATE OR REPLACE FUNCTION dzql_v2.delete_${name}(p_user_id int, p_pk jsonb)
|
|
414
|
+
RETURNS jsonb
|
|
415
|
+
LANGUAGE plpgsql
|
|
416
|
+
SECURITY DEFINER
|
|
417
|
+
SET search_path = dzql_v2, public
|
|
418
|
+
AS $$
|
|
419
|
+
DECLARE
|
|
420
|
+
v_old_data jsonb;
|
|
421
|
+
v_commit_id bigint;
|
|
422
|
+
BEGIN
|
|
423
|
+
v_commit_id := nextval('dzql_v2.commit_seq');
|
|
424
|
+
|
|
425
|
+
-- Fetch old data FIRST for permission check
|
|
426
|
+
SELECT to_jsonb(${name}.*) INTO v_old_data FROM ${name} WHERE ${pkWhereClause};
|
|
427
|
+
|
|
428
|
+
IF v_old_data IS NULL THEN
|
|
429
|
+
RAISE EXCEPTION 'not_found';
|
|
430
|
+
END IF;
|
|
431
|
+
|
|
432
|
+
-- Permission Check (Delete)
|
|
433
|
+
IF NOT (${deletePerm}) THEN
|
|
434
|
+
RAISE EXCEPTION 'permission_denied';
|
|
435
|
+
END IF;
|
|
436
|
+
|
|
437
|
+
-- Graph Rules (Pre-delete cascades)
|
|
438
|
+
${onDeleteRules}
|
|
439
|
+
|
|
440
|
+
-- Perform ${softDelete ? 'Soft ' : ''}Delete
|
|
441
|
+
${deleteOperation};
|
|
442
|
+
|
|
443
|
+
-- Emit Event (always 'delete' operation for client-side removal)
|
|
444
|
+
INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
|
|
445
|
+
VALUES (
|
|
446
|
+
v_commit_id,
|
|
447
|
+
'${name}',
|
|
448
|
+
'delete',
|
|
449
|
+
${pkJsonbExpr},
|
|
450
|
+
v_old_data, -- Include full data for subscription resolution
|
|
451
|
+
v_old_data,
|
|
452
|
+
p_user_id
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
-- Notify Runtime
|
|
456
|
+
PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
|
|
457
|
+
|
|
458
|
+
-- Remove hidden fields before returning to client
|
|
459
|
+
RETURN ${hidden.length > 0 ? `v_old_data - ARRAY[${hidden.map(f => `'${f}'`).join(', ')}]` : 'v_old_data'};
|
|
460
|
+
END;
|
|
461
|
+
$$;
|
|
462
|
+
`;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// === GET FUNCTION ===
|
|
466
|
+
export function generateGetFunction(name: string, entityIR: EntityIR): string {
|
|
467
|
+
const pkFields = entityIR.primaryKey.length > 0 ? entityIR.primaryKey : ['id'];
|
|
468
|
+
const pk = pkFields[0];
|
|
469
|
+
const hidden = entityIR.hidden || [];
|
|
470
|
+
|
|
471
|
+
// Build composite PK handling
|
|
472
|
+
const pkWhereClause = pkFields.map(f => {
|
|
473
|
+
const col = entityIR.columns.find((c: ColumnInfo) => c.name === f);
|
|
474
|
+
let cast = '::int';
|
|
475
|
+
if (col) {
|
|
476
|
+
if (col.type.includes('text') || col.type.includes('varchar')) cast = '';
|
|
477
|
+
else if (col.type.includes('date')) cast = '::date';
|
|
478
|
+
else if (col.type.includes('timestamp')) cast = '::timestamptz';
|
|
479
|
+
}
|
|
480
|
+
return `${f} = (p_pk->>'${f}')${cast}`;
|
|
481
|
+
}).join(' AND ');
|
|
482
|
+
|
|
483
|
+
const viewPerm = entityIR.permissions?.view?.length > 0
|
|
484
|
+
? entityIR.permissions.view.map((rule: string) => compilePermission(name, rule, null, name)).join(' OR ')
|
|
485
|
+
: 'TRUE';
|
|
486
|
+
|
|
487
|
+
// Build SELECT expression excluding hidden fields
|
|
488
|
+
const selectExpr = buildVisibleJsonb(name, entityIR.columns, hidden);
|
|
489
|
+
|
|
490
|
+
// M2M expansion for GET
|
|
491
|
+
const m2m: Record<string, ManyToManyIR> = entityIR.manyToMany || {};
|
|
492
|
+
const m2mKeys = Object.keys(m2m);
|
|
493
|
+
|
|
494
|
+
const m2mExpansion = m2mKeys.map(key => {
|
|
495
|
+
const config: ManyToManyIR = m2m[key];
|
|
496
|
+
let sql = `
|
|
497
|
+
-- M2M: Add ${config.idField} to result
|
|
498
|
+
v_result := v_result || jsonb_build_object('${config.idField}',
|
|
499
|
+
(SELECT COALESCE(jsonb_agg(${config.foreignKey} ORDER BY ${config.foreignKey}), '[]'::jsonb)
|
|
500
|
+
FROM ${config.junctionTable} WHERE ${config.localKey} = (v_result->>'${pk}')::int));`;
|
|
501
|
+
|
|
502
|
+
if (config.expand) {
|
|
503
|
+
sql += `
|
|
504
|
+
-- M2M: Add expanded ${key} to result
|
|
505
|
+
v_result := v_result || jsonb_build_object('${key}',
|
|
506
|
+
(SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
|
|
507
|
+
FROM ${config.junctionTable} jt
|
|
508
|
+
JOIN ${config.targetEntity} t ON t.id = jt.${config.foreignKey}
|
|
509
|
+
WHERE jt.${config.localKey} = (v_result->>'${pk}')::int));`;
|
|
510
|
+
}
|
|
511
|
+
return sql;
|
|
512
|
+
}).join('\n');
|
|
513
|
+
|
|
514
|
+
return `
|
|
515
|
+
CREATE OR REPLACE FUNCTION dzql_v2.get_${name}(p_user_id int, p_pk jsonb)
|
|
516
|
+
RETURNS jsonb
|
|
517
|
+
LANGUAGE plpgsql
|
|
518
|
+
SECURITY DEFINER
|
|
519
|
+
SET search_path = dzql_v2, public
|
|
520
|
+
AS $$
|
|
521
|
+
DECLARE
|
|
522
|
+
v_result jsonb;
|
|
523
|
+
BEGIN
|
|
524
|
+
SELECT ${selectExpr} INTO v_result
|
|
525
|
+
FROM ${name}
|
|
526
|
+
WHERE ${pkWhereClause}
|
|
527
|
+
AND (${viewPerm});
|
|
528
|
+
|
|
529
|
+
IF v_result IS NULL THEN
|
|
530
|
+
RETURN NULL;
|
|
531
|
+
END IF;
|
|
532
|
+
${m2mExpansion}
|
|
533
|
+
|
|
534
|
+
RETURN v_result;
|
|
535
|
+
END;
|
|
536
|
+
$$;
|
|
537
|
+
`;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// === SEARCH FUNCTION ===
|
|
541
|
+
export function generateSearchFunction(name: string, entityIR: EntityIR): string {
|
|
542
|
+
const pk = entityIR.primaryKey[0] || 'id';
|
|
543
|
+
const softDelete = entityIR.softDelete || false;
|
|
544
|
+
const hidden = entityIR.hidden || [];
|
|
545
|
+
|
|
546
|
+
const viewPerm = entityIR.permissions?.view?.length > 0
|
|
547
|
+
? entityIR.permissions.view.map((rule: string) => compilePermission(name, rule, null, name)).join(' OR ')
|
|
548
|
+
: 'TRUE';
|
|
549
|
+
|
|
550
|
+
// Soft delete filter - exclude deleted records from search
|
|
551
|
+
const softDeleteFilter = softDelete ? ' AND deleted_at IS NULL' : '';
|
|
552
|
+
|
|
553
|
+
// Get the label field for default sorting
|
|
554
|
+
const labelField = entityIR.labelField || 'id';
|
|
555
|
+
|
|
556
|
+
// M2M expansion for SEARCH using LATERAL joins
|
|
557
|
+
const m2m: Record<string, ManyToManyIR> = entityIR.manyToMany || {};
|
|
558
|
+
const m2mKeys = Object.keys(m2m);
|
|
559
|
+
|
|
560
|
+
// Build LATERAL joins for each M2M relationship
|
|
561
|
+
const m2mLateralJoins = m2mKeys.map(key => {
|
|
562
|
+
const config: ManyToManyIR = m2m[key];
|
|
563
|
+
let sql = `
|
|
564
|
+
LEFT JOIN LATERAL (
|
|
565
|
+
SELECT COALESCE(jsonb_agg(${config.foreignKey} ORDER BY ${config.foreignKey}), ''[]''::jsonb) as ${config.idField}
|
|
566
|
+
FROM ${config.junctionTable}
|
|
567
|
+
WHERE ${config.localKey} = t.${pk}
|
|
568
|
+
) m2m_${config.idField} ON true`;
|
|
569
|
+
|
|
570
|
+
if (config.expand) {
|
|
571
|
+
sql += `
|
|
572
|
+
LEFT JOIN LATERAL (
|
|
573
|
+
SELECT COALESCE(jsonb_agg(to_jsonb(target.*) ORDER BY target.id), ''[]''::jsonb) as ${key}
|
|
574
|
+
FROM ${config.junctionTable} jt
|
|
575
|
+
JOIN ${config.targetEntity} target ON target.id = jt.${config.foreignKey}
|
|
576
|
+
WHERE jt.${config.localKey} = t.${pk}
|
|
577
|
+
) m2m_${key} ON true`;
|
|
578
|
+
}
|
|
579
|
+
return sql;
|
|
580
|
+
}).join('');
|
|
581
|
+
|
|
582
|
+
// Build base SELECT expression excluding hidden fields (escape single quotes for dynamic SQL)
|
|
583
|
+
const baseSelectExpr = buildVisibleJsonb('t', entityIR.columns, hidden).replace(/'/g, "''");
|
|
584
|
+
|
|
585
|
+
// Build SELECT expression that merges M2M fields
|
|
586
|
+
const m2mSelectMerge = m2mKeys.map(key => {
|
|
587
|
+
const config = m2m[key];
|
|
588
|
+
let merge = ` || jsonb_build_object(''${config.idField}'', m2m_${config.idField}.${config.idField})`;
|
|
589
|
+
if (config.expand) {
|
|
590
|
+
merge += ` || jsonb_build_object(''${key}'', m2m_${key}.${key})`;
|
|
591
|
+
}
|
|
592
|
+
return merge;
|
|
593
|
+
}).join('');
|
|
594
|
+
|
|
595
|
+
const selectExpr = m2mKeys.length > 0
|
|
596
|
+
? `${baseSelectExpr}${m2mSelectMerge}`
|
|
597
|
+
: baseSelectExpr;
|
|
598
|
+
|
|
599
|
+
return `
|
|
600
|
+
CREATE OR REPLACE FUNCTION dzql_v2.search_${name}(p_user_id int, p_query jsonb)
|
|
601
|
+
RETURNS jsonb
|
|
602
|
+
LANGUAGE plpgsql
|
|
603
|
+
SECURITY DEFINER
|
|
604
|
+
SET search_path = dzql_v2, public
|
|
605
|
+
AS $$
|
|
606
|
+
DECLARE
|
|
607
|
+
v_results jsonb;
|
|
608
|
+
v_filters jsonb;
|
|
609
|
+
v_sort_field text;
|
|
610
|
+
v_sort_order text;
|
|
611
|
+
v_where_clause text := '';
|
|
612
|
+
v_field text;
|
|
613
|
+
v_filter jsonb;
|
|
614
|
+
v_operator text;
|
|
615
|
+
v_value jsonb;
|
|
616
|
+
BEGIN
|
|
617
|
+
-- Extract query parameters
|
|
618
|
+
v_filters := COALESCE(p_query->'filters', '{}'::jsonb);
|
|
619
|
+
v_sort_field := COALESCE(p_query->>'sort_field', '${labelField}');
|
|
620
|
+
v_sort_order := COALESCE(p_query->>'sort_order', 'asc');
|
|
621
|
+
|
|
622
|
+
-- Build WHERE clause from filters
|
|
623
|
+
FOR v_field, v_filter IN SELECT * FROM jsonb_each(v_filters)
|
|
624
|
+
LOOP
|
|
625
|
+
-- Handle simple value (exact match)
|
|
626
|
+
IF jsonb_typeof(v_filter) IN ('string', 'number', 'boolean') THEN
|
|
627
|
+
v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_filter #>> '{}');
|
|
628
|
+
ELSE
|
|
629
|
+
-- Handle operator-based filters
|
|
630
|
+
FOR v_operator, v_value IN SELECT * FROM jsonb_each(v_filter)
|
|
631
|
+
LOOP
|
|
632
|
+
CASE v_operator
|
|
633
|
+
WHEN 'eq' THEN
|
|
634
|
+
v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_value #>> '{}');
|
|
635
|
+
WHEN 'ne' THEN
|
|
636
|
+
v_where_clause := v_where_clause || format(' AND %I::TEXT != %L', v_field, v_value #>> '{}');
|
|
637
|
+
WHEN 'gt' THEN
|
|
638
|
+
v_where_clause := v_where_clause || format(' AND %I > %L', v_field, v_value #>> '{}');
|
|
639
|
+
WHEN 'gte' THEN
|
|
640
|
+
v_where_clause := v_where_clause || format(' AND %I >= %L', v_field, v_value #>> '{}');
|
|
641
|
+
WHEN 'lt' THEN
|
|
642
|
+
v_where_clause := v_where_clause || format(' AND %I < %L', v_field, v_value #>> '{}');
|
|
643
|
+
WHEN 'lte' THEN
|
|
644
|
+
v_where_clause := v_where_clause || format(' AND %I <= %L', v_field, v_value #>> '{}');
|
|
645
|
+
WHEN 'in' THEN
|
|
646
|
+
v_where_clause := v_where_clause || format(' AND %I::TEXT = ANY(%L)', v_field,
|
|
647
|
+
(SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
|
|
648
|
+
WHEN 'not_in' THEN
|
|
649
|
+
v_where_clause := v_where_clause || format(' AND %I::TEXT != ALL(%L)', v_field,
|
|
650
|
+
(SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
|
|
651
|
+
WHEN 'like' THEN
|
|
652
|
+
v_where_clause := v_where_clause || format(' AND %I LIKE %L', v_field, v_value #>> '{}');
|
|
653
|
+
WHEN 'ilike' THEN
|
|
654
|
+
v_where_clause := v_where_clause || format(' AND %I ILIKE %L', v_field, v_value #>> '{}');
|
|
655
|
+
WHEN 'is_null' THEN
|
|
656
|
+
IF (v_value::text = 'true') THEN
|
|
657
|
+
v_where_clause := v_where_clause || format(' AND %I IS NULL', v_field);
|
|
658
|
+
END IF;
|
|
659
|
+
WHEN 'not_null' THEN
|
|
660
|
+
IF (v_value::text = 'true') THEN
|
|
661
|
+
v_where_clause := v_where_clause || format(' AND %I IS NOT NULL', v_field);
|
|
662
|
+
END IF;
|
|
663
|
+
ELSE
|
|
664
|
+
-- Unknown operator, skip
|
|
665
|
+
END CASE;
|
|
666
|
+
END LOOP;
|
|
667
|
+
END IF;
|
|
668
|
+
END LOOP;
|
|
669
|
+
|
|
670
|
+
-- Execute dynamic query (sort inside subquery for correct LIMIT behavior)
|
|
671
|
+
EXECUTE format('
|
|
672
|
+
SELECT COALESCE(jsonb_agg(${selectExpr}), ''[]''::jsonb)
|
|
673
|
+
FROM (
|
|
674
|
+
SELECT * FROM ${name}
|
|
675
|
+
WHERE (${viewPerm})${softDeleteFilter} %s
|
|
676
|
+
ORDER BY %I %s
|
|
677
|
+
LIMIT %L OFFSET %L
|
|
678
|
+
) t${m2mLateralJoins}
|
|
679
|
+
', v_where_clause, v_sort_field, v_sort_order,
|
|
680
|
+
COALESCE((p_query->>'limit')::int, 10),
|
|
681
|
+
COALESCE((p_query->>'offset')::int, 0))
|
|
682
|
+
INTO v_results;
|
|
683
|
+
|
|
684
|
+
RETURN v_results;
|
|
685
|
+
END;
|
|
686
|
+
$$;
|
|
687
|
+
`;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// === AGGREGATE GENERATOR ===
|
|
691
|
+
export function generateEntitySQL(name: string, entityIR: EntityIR): string {
|
|
692
|
+
return [
|
|
693
|
+
generateSaveFunction(name, entityIR),
|
|
694
|
+
generateDeleteFunction(name, entityIR),
|
|
695
|
+
generateGetFunction(name, entityIR),
|
|
696
|
+
generateSearchFunction(name, entityIR)
|
|
697
|
+
].join('\n');
|
|
698
|
+
}
|