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,922 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { V2TestDatabase } from "./setup.js";
|
|
3
|
+
import { generateIR } from "../../src/cli/compiler/ir.js";
|
|
4
|
+
import { generateSearchFunction, generateSaveFunction, generateGetFunction } from "../../src/cli/codegen/sql.js";
|
|
5
|
+
import { compilePermission } from "../../src/cli/compiler/permissions.js";
|
|
6
|
+
import { handleRequest } from "../../src/runtime/server.js";
|
|
7
|
+
import { registerJsFunction, clearJsFunctions } from "../../src/runtime/js_functions.js";
|
|
8
|
+
import { loadManifest } from "../../src/runtime/manifest_loader.js";
|
|
9
|
+
|
|
10
|
+
// Import venues domain for IR generation
|
|
11
|
+
import { entities } from "../../examples/venues.js";
|
|
12
|
+
const venuesDomain = { entities, subscribables: {} };
|
|
13
|
+
|
|
14
|
+
describe("Feature Tests: Search Filters, Deep Paths, M2M", () => {
|
|
15
|
+
let db: V2TestDatabase;
|
|
16
|
+
let sql: any;
|
|
17
|
+
let ir: any;
|
|
18
|
+
|
|
19
|
+
beforeAll(async () => {
|
|
20
|
+
db = new V2TestDatabase();
|
|
21
|
+
sql = await db.setup();
|
|
22
|
+
ir = generateIR(venuesDomain);
|
|
23
|
+
|
|
24
|
+
// Apply core schema
|
|
25
|
+
await sql`CREATE SCHEMA IF NOT EXISTS dzql_v2`;
|
|
26
|
+
await sql`CREATE EXTENSION IF NOT EXISTS pgcrypto`;
|
|
27
|
+
await sql`CREATE SEQUENCE IF NOT EXISTS dzql_v2.commit_seq`;
|
|
28
|
+
await sql`
|
|
29
|
+
CREATE TABLE IF NOT EXISTS dzql_v2.events (
|
|
30
|
+
id bigserial PRIMARY KEY,
|
|
31
|
+
commit_id bigint NOT NULL,
|
|
32
|
+
table_name text NOT NULL,
|
|
33
|
+
op text NOT NULL,
|
|
34
|
+
pk jsonb NOT NULL,
|
|
35
|
+
data jsonb,
|
|
36
|
+
old_data jsonb,
|
|
37
|
+
user_id int,
|
|
38
|
+
created_at timestamptz DEFAULT now()
|
|
39
|
+
)
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
// Create test tables
|
|
43
|
+
await sql`
|
|
44
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
45
|
+
id serial PRIMARY KEY,
|
|
46
|
+
name text NOT NULL,
|
|
47
|
+
email text UNIQUE NOT NULL,
|
|
48
|
+
password_hash text NOT NULL,
|
|
49
|
+
created_at timestamptz DEFAULT now()
|
|
50
|
+
)
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
await sql`
|
|
54
|
+
CREATE TABLE IF NOT EXISTS organisations (
|
|
55
|
+
id serial PRIMARY KEY,
|
|
56
|
+
name text UNIQUE NOT NULL,
|
|
57
|
+
description text
|
|
58
|
+
)
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
await sql`
|
|
62
|
+
CREATE TABLE IF NOT EXISTS acts_for (
|
|
63
|
+
user_id int NOT NULL REFERENCES users(id),
|
|
64
|
+
org_id int NOT NULL REFERENCES organisations(id) ON DELETE CASCADE,
|
|
65
|
+
valid_from date NOT NULL DEFAULT current_date,
|
|
66
|
+
valid_to date,
|
|
67
|
+
active boolean DEFAULT true,
|
|
68
|
+
PRIMARY KEY (user_id, org_id, valid_from)
|
|
69
|
+
)
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
await sql`
|
|
73
|
+
CREATE TABLE IF NOT EXISTS venues (
|
|
74
|
+
id serial PRIMARY KEY,
|
|
75
|
+
org_id int NOT NULL REFERENCES organisations(id) ON DELETE CASCADE,
|
|
76
|
+
name text UNIQUE NOT NULL,
|
|
77
|
+
address text NOT NULL,
|
|
78
|
+
description text
|
|
79
|
+
)
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
await sql`
|
|
83
|
+
CREATE TABLE IF NOT EXISTS sites (
|
|
84
|
+
id serial PRIMARY KEY,
|
|
85
|
+
venue_id int NOT NULL REFERENCES venues(id),
|
|
86
|
+
name text NOT NULL,
|
|
87
|
+
description text
|
|
88
|
+
)
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
await sql`
|
|
92
|
+
CREATE TABLE IF NOT EXISTS tags (
|
|
93
|
+
id serial PRIMARY KEY,
|
|
94
|
+
name text NOT NULL UNIQUE,
|
|
95
|
+
color text,
|
|
96
|
+
description text
|
|
97
|
+
)
|
|
98
|
+
`;
|
|
99
|
+
|
|
100
|
+
await sql`
|
|
101
|
+
CREATE TABLE IF NOT EXISTS brands (
|
|
102
|
+
id serial PRIMARY KEY,
|
|
103
|
+
org_id int NOT NULL REFERENCES organisations(id) ON DELETE CASCADE,
|
|
104
|
+
name text NOT NULL,
|
|
105
|
+
description text,
|
|
106
|
+
UNIQUE(org_id, name)
|
|
107
|
+
)
|
|
108
|
+
`;
|
|
109
|
+
|
|
110
|
+
await sql`
|
|
111
|
+
CREATE TABLE IF NOT EXISTS brand_tags (
|
|
112
|
+
brand_id int NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
|
113
|
+
tag_id int NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
|
114
|
+
PRIMARY KEY (brand_id, tag_id)
|
|
115
|
+
)
|
|
116
|
+
`;
|
|
117
|
+
|
|
118
|
+
// Seed test data
|
|
119
|
+
await sql`INSERT INTO users (name, email, password_hash) VALUES ('Test User', 'test@example.com', 'hash')`;
|
|
120
|
+
await sql`INSERT INTO organisations (name) VALUES ('Org A'), ('Org B'), ('Org C')`;
|
|
121
|
+
await sql`INSERT INTO acts_for (user_id, org_id, active) VALUES (1, 1, true), (1, 2, true)`;
|
|
122
|
+
await sql`INSERT INTO venues (org_id, name, address) VALUES (1, 'Venue A', '123 St'), (1, 'Venue B', '456 Ave'), (2, 'Venue C', '789 Blvd')`;
|
|
123
|
+
await sql`INSERT INTO sites (venue_id, name) VALUES (1, 'Site 1'), (1, 'Site 2'), (2, 'Site 3')`;
|
|
124
|
+
await sql`INSERT INTO tags (name, color) VALUES ('Tag1', 'red'), ('Tag2', 'blue'), ('Tag3', 'green')`;
|
|
125
|
+
await sql`INSERT INTO brands (org_id, name) VALUES (1, 'Brand A')`;
|
|
126
|
+
await sql`INSERT INTO brand_tags (brand_id, tag_id) VALUES (1, 1), (1, 2)`;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
afterAll(async () => {
|
|
130
|
+
await db.teardown();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ============================================================
|
|
134
|
+
// SEARCH FILTERS TESTS
|
|
135
|
+
// ============================================================
|
|
136
|
+
|
|
137
|
+
describe("Search Filters", () => {
|
|
138
|
+
beforeAll(async () => {
|
|
139
|
+
// Generate and apply search function for venues
|
|
140
|
+
const venuesSearchSQL = generateSearchFunction("venues", ir.entities.venues);
|
|
141
|
+
await sql.unsafe(venuesSearchSQL);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("simple filter (exact match)", async () => {
|
|
145
|
+
const result = await sql`SELECT dzql_v2.search_venues(1, '{"filters": {"org_id": 1}}'::jsonb)`;
|
|
146
|
+
const venues = result[0].search_venues;
|
|
147
|
+
expect(venues.length).toBe(2);
|
|
148
|
+
expect(venues.every((v: any) => v.org_id === 1)).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("'in' operator with integer array", async () => {
|
|
152
|
+
const result = await sql`SELECT dzql_v2.search_venues(1, '{"filters": {"id": {"in": [1, 2]}}}'::jsonb)`;
|
|
153
|
+
const venues = result[0].search_venues;
|
|
154
|
+
expect(venues.length).toBe(2);
|
|
155
|
+
expect(venues.map((v: any) => v.id).sort()).toEqual([1, 2]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("'in' operator with FK column", async () => {
|
|
159
|
+
const result = await sql`SELECT dzql_v2.search_venues(1, '{"filters": {"org_id": {"in": [1, 2]}}}'::jsonb)`;
|
|
160
|
+
const venues = result[0].search_venues;
|
|
161
|
+
expect(venues.length).toBe(3);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("'not_in' operator", async () => {
|
|
165
|
+
const result = await sql`SELECT dzql_v2.search_venues(1, '{"filters": {"id": {"not_in": [1, 2]}}}'::jsonb)`;
|
|
166
|
+
const venues = result[0].search_venues;
|
|
167
|
+
expect(venues.length).toBe(1);
|
|
168
|
+
expect(venues[0].id).toBe(3);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("'ilike' operator for text search", async () => {
|
|
172
|
+
const result = await sql`SELECT dzql_v2.search_venues(1, '{"filters": {"name": {"ilike": "%venue%"}}}'::jsonb)`;
|
|
173
|
+
const venues = result[0].search_venues;
|
|
174
|
+
expect(venues.length).toBe(3);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("'gt' and 'lt' operators", async () => {
|
|
178
|
+
const result = await sql`SELECT dzql_v2.search_venues(1, '{"filters": {"id": {"gt": 1, "lt": 3}}}'::jsonb)`;
|
|
179
|
+
const venues = result[0].search_venues;
|
|
180
|
+
expect(venues.length).toBe(1);
|
|
181
|
+
expect(venues[0].id).toBe(2);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("sorting and pagination", async () => {
|
|
185
|
+
const result = await sql`SELECT dzql_v2.search_venues(1, '{"sort_field": "name", "sort_order": "desc", "limit": 2}'::jsonb)`;
|
|
186
|
+
const venues = result[0].search_venues;
|
|
187
|
+
expect(venues.length).toBe(2);
|
|
188
|
+
expect(venues[0].name).toBe("Venue C");
|
|
189
|
+
expect(venues[1].name).toBe("Venue B");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ============================================================
|
|
194
|
+
// DEEP PERMISSION PATHS TESTS
|
|
195
|
+
// ============================================================
|
|
196
|
+
|
|
197
|
+
describe("Deep Permission Paths", () => {
|
|
198
|
+
|
|
199
|
+
test("single-hop path compiles correctly", () => {
|
|
200
|
+
const rule = "@org_id->acts_for[org_id=$]{active}.user_id";
|
|
201
|
+
const sql = compilePermission("venues", rule, null, "p_data");
|
|
202
|
+
|
|
203
|
+
expect(sql).toContain("EXISTS");
|
|
204
|
+
expect(sql).toContain("acts_for");
|
|
205
|
+
expect(sql).toContain("acts_for.org_id = (p_data->>'org_id')::int");
|
|
206
|
+
expect(sql).toContain("acts_for.active = true");
|
|
207
|
+
expect(sql).toContain("acts_for.user_id = p_user_id");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("two-hop path compiles with nested subquery", () => {
|
|
211
|
+
const rule = "@venue_id->venues.org_id->acts_for[org_id=$]{active}.user_id";
|
|
212
|
+
const sql = compilePermission("sites", rule, null, "p_data");
|
|
213
|
+
|
|
214
|
+
expect(sql).toContain("EXISTS");
|
|
215
|
+
expect(sql).toContain("(SELECT org_id FROM venues WHERE id = (p_data->>'venue_id')::int)");
|
|
216
|
+
expect(sql).toContain("acts_for.active = true");
|
|
217
|
+
expect(sql).toContain("acts_for.user_id = p_user_id");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("three-hop path compiles with double nested subquery", () => {
|
|
221
|
+
const rule = "@site_id->sites.venue_id->venues.org_id->acts_for[org_id=$]{active}.user_id";
|
|
222
|
+
const sql = compilePermission("allocations", rule, null, "p_data");
|
|
223
|
+
|
|
224
|
+
expect(sql).toContain("EXISTS");
|
|
225
|
+
expect(sql).toContain("(SELECT venue_id FROM sites WHERE id = (p_data->>'site_id')::int)");
|
|
226
|
+
expect(sql).toContain("(SELECT org_id FROM venues WHERE id =");
|
|
227
|
+
expect(sql).toContain("acts_for.user_id = p_user_id");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("table-first pattern compiles correctly", () => {
|
|
231
|
+
const rule = "contractor_rights[package_id=@package_id]{active}.contractor_org_id->acts_for[org_id=$]{active}.user_id";
|
|
232
|
+
const sql = compilePermission("allocations", rule, null, "p_data");
|
|
233
|
+
|
|
234
|
+
expect(sql).toContain("EXISTS");
|
|
235
|
+
expect(sql).toContain("SELECT contractor_org_id FROM contractor_rights");
|
|
236
|
+
expect(sql).toContain("contractor_rights.package_id = (p_data->>'package_id')::int");
|
|
237
|
+
expect(sql).toContain("contractor_rights.active = true");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("table column access (non-JSONB) compiles correctly", () => {
|
|
241
|
+
const rule = "@org_id->acts_for[org_id=$]{active}.user_id";
|
|
242
|
+
const sql = compilePermission("venues", rule, null, "venues");
|
|
243
|
+
|
|
244
|
+
// Should use table.column syntax, not jsonb
|
|
245
|
+
expect(sql).toContain("acts_for.org_id = venues.org_id");
|
|
246
|
+
expect(sql).not.toContain("->>");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("deep path works in actual SQL execution", async () => {
|
|
250
|
+
// Apply sites functions with deep permission path
|
|
251
|
+
const sitesIR = ir.entities.sites;
|
|
252
|
+
const sitesGetSQL = generateGetFunction("sites", sitesIR);
|
|
253
|
+
await sql.unsafe(sitesGetSQL);
|
|
254
|
+
|
|
255
|
+
// User 1 has access to org 1, which owns venue 1, which has sites 1 and 2
|
|
256
|
+
const result = await sql`SELECT dzql_v2.get_sites(1, '{"id": 1}'::jsonb)`;
|
|
257
|
+
// The permission check uses deep path: @venue_id->venues.org_id->acts_for
|
|
258
|
+
// Since we're using TRUE for view perms in the generated SQL, this should succeed
|
|
259
|
+
expect(result[0].get_sites).toBeDefined();
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// ============================================================
|
|
264
|
+
// M2M RELATIONSHIPS TESTS
|
|
265
|
+
// ============================================================
|
|
266
|
+
|
|
267
|
+
describe("M2M Relationships", () => {
|
|
268
|
+
beforeAll(async () => {
|
|
269
|
+
// Generate and apply brands functions with M2M
|
|
270
|
+
const brandsIR = ir.entities.brands;
|
|
271
|
+
const brandsSaveSQL = generateSaveFunction("brands", brandsIR);
|
|
272
|
+
const brandsGetSQL = generateGetFunction("brands", brandsIR);
|
|
273
|
+
const brandsSearchSQL = generateSearchFunction("brands", brandsIR);
|
|
274
|
+
|
|
275
|
+
await sql.unsafe(brandsSaveSQL);
|
|
276
|
+
await sql.unsafe(brandsGetSQL);
|
|
277
|
+
await sql.unsafe(brandsSearchSQL);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("M2M expansion in GET includes tag_ids array", async () => {
|
|
281
|
+
const result = await sql`SELECT dzql_v2.get_brands(1, '{"id": 1}'::jsonb)`;
|
|
282
|
+
const brand = result[0].get_brands;
|
|
283
|
+
|
|
284
|
+
expect(brand).toBeDefined();
|
|
285
|
+
expect(brand.tag_ids).toBeDefined();
|
|
286
|
+
expect(Array.isArray(brand.tag_ids)).toBe(true);
|
|
287
|
+
expect(brand.tag_ids.sort()).toEqual([1, 2]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("M2M sync on SAVE adds new relationships", async () => {
|
|
291
|
+
// Create new brand with tags
|
|
292
|
+
const result = await sql`SELECT dzql_v2.save_brands(1, '{"org_id": 1, "name": "New Brand", "tag_ids": [2, 3]}'::jsonb)`;
|
|
293
|
+
const brand = result[0].save_brands;
|
|
294
|
+
|
|
295
|
+
expect(brand.tag_ids).toBeDefined();
|
|
296
|
+
expect(brand.tag_ids.sort()).toEqual([2, 3]);
|
|
297
|
+
|
|
298
|
+
// Verify junction table
|
|
299
|
+
const junctions = await sql`SELECT tag_id FROM brand_tags WHERE brand_id = ${brand.id} ORDER BY tag_id`;
|
|
300
|
+
expect(junctions.map((j: any) => j.tag_id)).toEqual([2, 3]);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("M2M sync on SAVE updates relationships", async () => {
|
|
304
|
+
// Get brand 1, change tags from [1, 2] to [1, 3]
|
|
305
|
+
// Note: org_id must be included for permission check on update
|
|
306
|
+
const result = await sql`SELECT dzql_v2.save_brands(1, '{"id": 1, "org_id": 1, "tag_ids": [1, 3]}'::jsonb)`;
|
|
307
|
+
const brand = result[0].save_brands;
|
|
308
|
+
|
|
309
|
+
expect(brand.tag_ids.sort()).toEqual([1, 3]);
|
|
310
|
+
|
|
311
|
+
// Verify junction table - tag 2 should be removed, tag 3 added
|
|
312
|
+
const junctions = await sql`SELECT tag_id FROM brand_tags WHERE brand_id = 1 ORDER BY tag_id`;
|
|
313
|
+
expect(junctions.map((j: any) => j.tag_id)).toEqual([1, 3]);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("M2M sync with empty array removes all relationships", async () => {
|
|
317
|
+
// Create a brand with tags, then clear them
|
|
318
|
+
await sql`INSERT INTO brands (org_id, name) VALUES (1, 'Temp Brand') RETURNING id`;
|
|
319
|
+
const tempBrand = await sql`SELECT id FROM brands WHERE name = 'Temp Brand'`;
|
|
320
|
+
const tempId = tempBrand[0].id;
|
|
321
|
+
await sql`INSERT INTO brand_tags (brand_id, tag_id) VALUES (${tempId}, 1), (${tempId}, 2)`;
|
|
322
|
+
|
|
323
|
+
// Clear tags - org_id required for permission check
|
|
324
|
+
const payload = JSON.stringify({ id: tempId, org_id: 1, tag_ids: [] });
|
|
325
|
+
const result = await sql.unsafe(`SELECT dzql_v2.save_brands(1, '${payload}'::jsonb)`);
|
|
326
|
+
const brand = result[0].save_brands;
|
|
327
|
+
|
|
328
|
+
expect(brand.tag_ids).toEqual([]);
|
|
329
|
+
|
|
330
|
+
// Verify junction table is empty
|
|
331
|
+
const junctions = await sql`SELECT tag_id FROM brand_tags WHERE brand_id = ${tempId}`;
|
|
332
|
+
expect(junctions.length).toBe(0);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("M2M expansion in SEARCH includes tag_ids for each result", async () => {
|
|
336
|
+
const result = await sql`SELECT dzql_v2.search_brands(1, '{"filters": {"org_id": 1}}'::jsonb)`;
|
|
337
|
+
const brands = result[0].search_brands;
|
|
338
|
+
|
|
339
|
+
expect(brands.length).toBeGreaterThan(0);
|
|
340
|
+
brands.forEach((brand: any) => {
|
|
341
|
+
expect(brand.tag_ids).toBeDefined();
|
|
342
|
+
expect(Array.isArray(brand.tag_ids)).toBe(true);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// ============================================================
|
|
348
|
+
// SOFT DELETE TESTS
|
|
349
|
+
// ============================================================
|
|
350
|
+
|
|
351
|
+
describe("Soft Delete", () => {
|
|
352
|
+
beforeAll(async () => {
|
|
353
|
+
// Create products table with soft delete support
|
|
354
|
+
await sql`
|
|
355
|
+
CREATE TABLE IF NOT EXISTS products (
|
|
356
|
+
id serial PRIMARY KEY,
|
|
357
|
+
org_id int NOT NULL REFERENCES organisations(id) ON DELETE CASCADE,
|
|
358
|
+
name text NOT NULL,
|
|
359
|
+
description text,
|
|
360
|
+
price decimal(10, 2) NOT NULL DEFAULT 0.00,
|
|
361
|
+
created_by int REFERENCES users(id),
|
|
362
|
+
created_at timestamptz,
|
|
363
|
+
deleted_at timestamptz
|
|
364
|
+
)
|
|
365
|
+
`;
|
|
366
|
+
|
|
367
|
+
// Generate and apply products functions with soft delete
|
|
368
|
+
const productsIR = ir.entities.products;
|
|
369
|
+
const productsSaveSQL = generateSaveFunction("products", productsIR);
|
|
370
|
+
const productsGetSQL = generateGetFunction("products", productsIR);
|
|
371
|
+
const productsSearchSQL = generateSearchFunction("products", productsIR);
|
|
372
|
+
|
|
373
|
+
await sql.unsafe(productsSaveSQL);
|
|
374
|
+
await sql.unsafe(productsGetSQL);
|
|
375
|
+
await sql.unsafe(productsSearchSQL);
|
|
376
|
+
|
|
377
|
+
// Need to create delete function separately since it's not in generateEntitySQL individually
|
|
378
|
+
const { generateDeleteFunction } = await import("../../src/cli/codegen/sql.js");
|
|
379
|
+
const productsDeleteSQL = generateDeleteFunction("products", productsIR);
|
|
380
|
+
await sql.unsafe(productsDeleteSQL);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("Soft delete sets deleted_at timestamp instead of removing row", async () => {
|
|
384
|
+
// Create a product
|
|
385
|
+
const created = await sql`
|
|
386
|
+
SELECT dzql_v2.save_products(1, ${sql.json({
|
|
387
|
+
org_id: 1,
|
|
388
|
+
name: "To Be Deleted",
|
|
389
|
+
price: 9.99
|
|
390
|
+
})}) as product
|
|
391
|
+
`;
|
|
392
|
+
const productId = created[0].product.id;
|
|
393
|
+
|
|
394
|
+
// Delete it
|
|
395
|
+
await sql`SELECT dzql_v2.delete_products(1, ${sql.json({ id: productId })})`;
|
|
396
|
+
|
|
397
|
+
// Check database - row should still exist with deleted_at set
|
|
398
|
+
const check = await sql`SELECT * FROM products WHERE id = ${productId}`;
|
|
399
|
+
expect(check.length).toBe(1);
|
|
400
|
+
expect(check[0].deleted_at).not.toBeNull();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("Soft deleted records excluded from SEARCH", async () => {
|
|
404
|
+
// Create two products
|
|
405
|
+
const active = await sql`
|
|
406
|
+
SELECT dzql_v2.save_products(1, ${sql.json({
|
|
407
|
+
org_id: 1,
|
|
408
|
+
name: "Active Product",
|
|
409
|
+
price: 19.99
|
|
410
|
+
})}) as product
|
|
411
|
+
`;
|
|
412
|
+
|
|
413
|
+
const toDelete = await sql`
|
|
414
|
+
SELECT dzql_v2.save_products(1, ${sql.json({
|
|
415
|
+
org_id: 1,
|
|
416
|
+
name: "Product To Delete",
|
|
417
|
+
price: 29.99
|
|
418
|
+
})}) as product
|
|
419
|
+
`;
|
|
420
|
+
const deletedId = toDelete[0].product.id;
|
|
421
|
+
|
|
422
|
+
// Delete one
|
|
423
|
+
await sql`SELECT dzql_v2.delete_products(1, ${sql.json({ id: deletedId })})`;
|
|
424
|
+
|
|
425
|
+
// Search should not return deleted
|
|
426
|
+
const search = await sql`SELECT dzql_v2.search_products(1, '{"filters": {"org_id": 1}}'::jsonb)`;
|
|
427
|
+
const products = search[0].search_products;
|
|
428
|
+
const productIds = products.map((p: any) => p.id);
|
|
429
|
+
expect(productIds).not.toContain(deletedId);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("Can still GET soft deleted record by ID (for audit)", async () => {
|
|
433
|
+
const created = await sql`
|
|
434
|
+
SELECT dzql_v2.save_products(1, ${sql.json({
|
|
435
|
+
org_id: 1,
|
|
436
|
+
name: "Audit Product",
|
|
437
|
+
price: 39.99
|
|
438
|
+
})}) as product
|
|
439
|
+
`;
|
|
440
|
+
const productId = created[0].product.id;
|
|
441
|
+
|
|
442
|
+
// Delete it
|
|
443
|
+
await sql`SELECT dzql_v2.delete_products(1, ${sql.json({ id: productId })})`;
|
|
444
|
+
|
|
445
|
+
// Should still be able to get it
|
|
446
|
+
const fetched = await sql`SELECT dzql_v2.get_products(1, ${sql.json({ id: productId })})`;
|
|
447
|
+
expect(fetched[0].get_products).not.toBeNull();
|
|
448
|
+
expect(fetched[0].get_products.deleted_at).not.toBeNull();
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// ============================================================
|
|
453
|
+
// FIELD DEFAULTS TESTS
|
|
454
|
+
// ============================================================
|
|
455
|
+
|
|
456
|
+
describe("Field Defaults", () => {
|
|
457
|
+
test("@user_id default resolves to current user", async () => {
|
|
458
|
+
const product = await sql`
|
|
459
|
+
SELECT dzql_v2.save_products(1, ${sql.json({
|
|
460
|
+
org_id: 1,
|
|
461
|
+
name: "Default User Product",
|
|
462
|
+
price: 49.99
|
|
463
|
+
})}) as product
|
|
464
|
+
`;
|
|
465
|
+
|
|
466
|
+
expect(product[0].product.created_by).toBe(1);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("@now default resolves to current timestamp", async () => {
|
|
470
|
+
const before = new Date();
|
|
471
|
+
|
|
472
|
+
const product = await sql`
|
|
473
|
+
SELECT dzql_v2.save_products(1, ${sql.json({
|
|
474
|
+
org_id: 1,
|
|
475
|
+
name: "Default Timestamp Product",
|
|
476
|
+
price: 59.99
|
|
477
|
+
})}) as product
|
|
478
|
+
`;
|
|
479
|
+
|
|
480
|
+
const after = new Date();
|
|
481
|
+
const createdAt = new Date(product[0].product.created_at);
|
|
482
|
+
|
|
483
|
+
expect(createdAt.getTime()).toBeGreaterThanOrEqual(before.getTime() - 1000);
|
|
484
|
+
expect(createdAt.getTime()).toBeLessThanOrEqual(after.getTime() + 1000);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("Explicit values override defaults", async () => {
|
|
488
|
+
// Default for created_by is @user_id (which would be p_user_id = 1)
|
|
489
|
+
// If we explicitly provide the same value, it should work
|
|
490
|
+
// Better test: Use a different timestamp than @now would generate
|
|
491
|
+
const specificTime = '2020-01-01T00:00:00Z';
|
|
492
|
+
|
|
493
|
+
const product = await sql`
|
|
494
|
+
SELECT dzql_v2.save_products(1, ${sql.json({
|
|
495
|
+
org_id: 1,
|
|
496
|
+
name: "Override Defaults Product",
|
|
497
|
+
price: 69.99,
|
|
498
|
+
created_at: specificTime
|
|
499
|
+
})}) as product
|
|
500
|
+
`;
|
|
501
|
+
|
|
502
|
+
// Explicit timestamp overrides the @now default
|
|
503
|
+
const createdAt = new Date(product[0].product.created_at);
|
|
504
|
+
expect(createdAt.getFullYear()).toBe(2020);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("Defaults NOT applied on UPDATE", async () => {
|
|
508
|
+
// Create with defaults
|
|
509
|
+
const created = await sql`
|
|
510
|
+
SELECT dzql_v2.save_products(1, ${sql.json({
|
|
511
|
+
org_id: 1,
|
|
512
|
+
name: "Update Test Product",
|
|
513
|
+
price: 79.99
|
|
514
|
+
})}) as product
|
|
515
|
+
`;
|
|
516
|
+
const productId = created[0].product.id;
|
|
517
|
+
const originalCreatedAt = created[0].product.created_at;
|
|
518
|
+
|
|
519
|
+
// Wait a bit
|
|
520
|
+
await new Promise(r => setTimeout(r, 100));
|
|
521
|
+
|
|
522
|
+
// Update (created_at should NOT change) - org_id needed for permission check
|
|
523
|
+
const updated = await sql`
|
|
524
|
+
SELECT dzql_v2.save_products(1, ${sql.json({
|
|
525
|
+
id: productId,
|
|
526
|
+
org_id: 1,
|
|
527
|
+
name: "Updated Product Name"
|
|
528
|
+
})}) as product
|
|
529
|
+
`;
|
|
530
|
+
|
|
531
|
+
expect(updated[0].product.created_at).toBe(originalCreatedAt);
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// ============================================================
|
|
536
|
+
// COMPOSITE PRIMARY KEY TESTS
|
|
537
|
+
// ============================================================
|
|
538
|
+
|
|
539
|
+
describe("Composite Primary Keys", () => {
|
|
540
|
+
beforeAll(async () => {
|
|
541
|
+
// acts_for table already exists with composite PK (user_id, org_id, valid_from)
|
|
542
|
+
// Generate and apply acts_for functions
|
|
543
|
+
const actsForIR = ir.entities.acts_for;
|
|
544
|
+
const actsForSaveSQL = generateSaveFunction("acts_for", actsForIR);
|
|
545
|
+
const actsForGetSQL = generateGetFunction("acts_for", actsForIR);
|
|
546
|
+
const actsForSearchSQL = generateSearchFunction("acts_for", actsForIR);
|
|
547
|
+
|
|
548
|
+
await sql.unsafe(actsForSaveSQL);
|
|
549
|
+
await sql.unsafe(actsForGetSQL);
|
|
550
|
+
await sql.unsafe(actsForSearchSQL);
|
|
551
|
+
|
|
552
|
+
const { generateDeleteFunction } = await import("../../src/cli/codegen/sql.js");
|
|
553
|
+
const actsForDeleteSQL = generateDeleteFunction("acts_for", actsForIR);
|
|
554
|
+
await sql.unsafe(actsForDeleteSQL);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("SAVE with composite PK inserts new record", async () => {
|
|
558
|
+
const result = await sql`
|
|
559
|
+
SELECT dzql_v2.save_acts_for(1, ${sql.json({
|
|
560
|
+
user_id: 1,
|
|
561
|
+
org_id: 3,
|
|
562
|
+
valid_from: '2025-01-01',
|
|
563
|
+
active: true
|
|
564
|
+
})}) as record
|
|
565
|
+
`;
|
|
566
|
+
|
|
567
|
+
expect(result[0].record).toBeDefined();
|
|
568
|
+
expect(result[0].record.user_id).toBe(1);
|
|
569
|
+
expect(result[0].record.org_id).toBe(3);
|
|
570
|
+
expect(result[0].record.active).toBe(true);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test("SAVE with composite PK updates existing record", async () => {
|
|
574
|
+
// First insert
|
|
575
|
+
await sql`
|
|
576
|
+
SELECT dzql_v2.save_acts_for(1, ${sql.json({
|
|
577
|
+
user_id: 1,
|
|
578
|
+
org_id: 3,
|
|
579
|
+
valid_from: '2025-02-01',
|
|
580
|
+
active: true
|
|
581
|
+
})})
|
|
582
|
+
`;
|
|
583
|
+
|
|
584
|
+
// Update using same composite PK
|
|
585
|
+
const result = await sql`
|
|
586
|
+
SELECT dzql_v2.save_acts_for(1, ${sql.json({
|
|
587
|
+
user_id: 1,
|
|
588
|
+
org_id: 3,
|
|
589
|
+
valid_from: '2025-02-01',
|
|
590
|
+
active: false
|
|
591
|
+
})}) as record
|
|
592
|
+
`;
|
|
593
|
+
|
|
594
|
+
expect(result[0].record.active).toBe(false);
|
|
595
|
+
|
|
596
|
+
// Verify only one record exists
|
|
597
|
+
const count = await sql`
|
|
598
|
+
SELECT COUNT(*) as cnt FROM acts_for
|
|
599
|
+
WHERE user_id = 1 AND org_id = 3 AND valid_from = '2025-02-01'
|
|
600
|
+
`;
|
|
601
|
+
expect(parseInt(count[0].cnt)).toBe(1);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
test("GET with composite PK retrieves correct record", async () => {
|
|
605
|
+
// Insert a specific record
|
|
606
|
+
await sql`
|
|
607
|
+
SELECT dzql_v2.save_acts_for(1, ${sql.json({
|
|
608
|
+
user_id: 1,
|
|
609
|
+
org_id: 3,
|
|
610
|
+
valid_from: '2025-03-01',
|
|
611
|
+
active: true
|
|
612
|
+
})})
|
|
613
|
+
`;
|
|
614
|
+
|
|
615
|
+
const result = await sql`
|
|
616
|
+
SELECT dzql_v2.get_acts_for(1, ${sql.json({
|
|
617
|
+
user_id: 1,
|
|
618
|
+
org_id: 3,
|
|
619
|
+
valid_from: '2025-03-01'
|
|
620
|
+
})}) as record
|
|
621
|
+
`;
|
|
622
|
+
|
|
623
|
+
expect(result[0].record).toBeDefined();
|
|
624
|
+
expect(result[0].record.user_id).toBe(1);
|
|
625
|
+
expect(result[0].record.org_id).toBe(3);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test("DELETE with composite PK removes correct record", async () => {
|
|
629
|
+
// Insert
|
|
630
|
+
await sql`
|
|
631
|
+
SELECT dzql_v2.save_acts_for(1, ${sql.json({
|
|
632
|
+
user_id: 1,
|
|
633
|
+
org_id: 3,
|
|
634
|
+
valid_from: '2025-04-01',
|
|
635
|
+
active: true
|
|
636
|
+
})})
|
|
637
|
+
`;
|
|
638
|
+
|
|
639
|
+
// Delete
|
|
640
|
+
await sql`
|
|
641
|
+
SELECT dzql_v2.delete_acts_for(1, ${sql.json({
|
|
642
|
+
user_id: 1,
|
|
643
|
+
org_id: 3,
|
|
644
|
+
valid_from: '2025-04-01'
|
|
645
|
+
})})
|
|
646
|
+
`;
|
|
647
|
+
|
|
648
|
+
// Verify deleted
|
|
649
|
+
const count = await sql`
|
|
650
|
+
SELECT COUNT(*) as cnt FROM acts_for
|
|
651
|
+
WHERE user_id = 1 AND org_id = 3 AND valid_from = '2025-04-01'
|
|
652
|
+
`;
|
|
653
|
+
expect(parseInt(count[0].cnt)).toBe(0);
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
// ============================================================
|
|
658
|
+
// CUSTOM FUNCTIONS TESTS
|
|
659
|
+
// ============================================================
|
|
660
|
+
|
|
661
|
+
describe("Custom Functions", () => {
|
|
662
|
+
test("Custom function is included in IR", () => {
|
|
663
|
+
// Domain with custom function
|
|
664
|
+
const domainWithCustomFn = {
|
|
665
|
+
entities: {},
|
|
666
|
+
subscribables: {},
|
|
667
|
+
customFunctions: [
|
|
668
|
+
{
|
|
669
|
+
name: 'calculate_org_stats',
|
|
670
|
+
sql: `
|
|
671
|
+
CREATE OR REPLACE FUNCTION dzql_v2.calculate_org_stats(p_user_id int, p_params jsonb)
|
|
672
|
+
RETURNS jsonb LANGUAGE plpgsql AS $$
|
|
673
|
+
DECLARE
|
|
674
|
+
v_org_id int;
|
|
675
|
+
v_venue_count int;
|
|
676
|
+
v_site_count int;
|
|
677
|
+
BEGIN
|
|
678
|
+
v_org_id := (p_params->>'org_id')::int;
|
|
679
|
+
|
|
680
|
+
SELECT COUNT(*) INTO v_venue_count
|
|
681
|
+
FROM venues WHERE org_id = v_org_id;
|
|
682
|
+
|
|
683
|
+
SELECT COUNT(*) INTO v_site_count
|
|
684
|
+
FROM sites s
|
|
685
|
+
JOIN venues v ON s.venue_id = v.id
|
|
686
|
+
WHERE v.org_id = v_org_id;
|
|
687
|
+
|
|
688
|
+
RETURN jsonb_build_object(
|
|
689
|
+
'org_id', v_org_id,
|
|
690
|
+
'venue_count', v_venue_count,
|
|
691
|
+
'site_count', v_site_count
|
|
692
|
+
);
|
|
693
|
+
END;
|
|
694
|
+
$$;
|
|
695
|
+
`,
|
|
696
|
+
args: ['p_user_id', 'p_params']
|
|
697
|
+
}
|
|
698
|
+
]
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
const customIR = generateIR(domainWithCustomFn);
|
|
702
|
+
|
|
703
|
+
expect(customIR.customFunctions).toBeDefined();
|
|
704
|
+
expect(customIR.customFunctions.length).toBe(1);
|
|
705
|
+
expect(customIR.customFunctions[0].name).toBe('calculate_org_stats');
|
|
706
|
+
expect(customIR.customFunctions[0].args).toEqual(['p_user_id', 'p_params']);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
test("Custom function is included in manifest allowlist", async () => {
|
|
710
|
+
const { generateManifest } = await import("../../src/cli/codegen/manifest.js");
|
|
711
|
+
|
|
712
|
+
const domainWithCustomFn = {
|
|
713
|
+
entities: {},
|
|
714
|
+
subscribables: {},
|
|
715
|
+
customFunctions: [
|
|
716
|
+
{
|
|
717
|
+
name: 'my_custom_func',
|
|
718
|
+
sql: 'SELECT 1',
|
|
719
|
+
args: ['p_user_id', 'p_params']
|
|
720
|
+
}
|
|
721
|
+
]
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
const customIR = generateIR(domainWithCustomFn);
|
|
725
|
+
const manifest = generateManifest(customIR);
|
|
726
|
+
|
|
727
|
+
expect(manifest.functions['my_custom_func']).toBeDefined();
|
|
728
|
+
expect(manifest.functions['my_custom_func'].schema).toBe('dzql_v2');
|
|
729
|
+
expect(manifest.functions['my_custom_func'].name).toBe('my_custom_func');
|
|
730
|
+
expect(manifest.functions['my_custom_func'].args).toEqual(['p_user_id', 'p_params']);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
test("Custom function can be applied and called", async () => {
|
|
734
|
+
// Create a custom function that calculates org statistics
|
|
735
|
+
const customFunctionSQL = `
|
|
736
|
+
CREATE OR REPLACE FUNCTION dzql_v2.calculate_org_stats(p_user_id int, p_params jsonb)
|
|
737
|
+
RETURNS jsonb LANGUAGE plpgsql AS $$
|
|
738
|
+
DECLARE
|
|
739
|
+
v_org_id int;
|
|
740
|
+
v_venue_count int;
|
|
741
|
+
v_site_count int;
|
|
742
|
+
BEGIN
|
|
743
|
+
v_org_id := (p_params->>'org_id')::int;
|
|
744
|
+
|
|
745
|
+
SELECT COUNT(*) INTO v_venue_count
|
|
746
|
+
FROM venues WHERE org_id = v_org_id;
|
|
747
|
+
|
|
748
|
+
SELECT COUNT(*) INTO v_site_count
|
|
749
|
+
FROM sites s
|
|
750
|
+
JOIN venues v ON s.venue_id = v.id
|
|
751
|
+
WHERE v.org_id = v_org_id;
|
|
752
|
+
|
|
753
|
+
RETURN jsonb_build_object(
|
|
754
|
+
'org_id', v_org_id,
|
|
755
|
+
'venue_count', v_venue_count,
|
|
756
|
+
'site_count', v_site_count
|
|
757
|
+
);
|
|
758
|
+
END;
|
|
759
|
+
$$;
|
|
760
|
+
`;
|
|
761
|
+
|
|
762
|
+
// Apply the function
|
|
763
|
+
await sql.unsafe(customFunctionSQL);
|
|
764
|
+
|
|
765
|
+
// Call it - org 1 has 2 venues and 3 sites (from test setup)
|
|
766
|
+
const result = await sql`
|
|
767
|
+
SELECT dzql_v2.calculate_org_stats(1, '{"org_id": 1}'::jsonb) as stats
|
|
768
|
+
`;
|
|
769
|
+
|
|
770
|
+
const stats = result[0].stats;
|
|
771
|
+
expect(stats.org_id).toBe(1);
|
|
772
|
+
expect(stats.venue_count).toBe(2); // Venue A, Venue B
|
|
773
|
+
expect(stats.site_count).toBe(3); // Site 1, Site 2, Site 3
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test("Custom function with default args uses standard signature", () => {
|
|
777
|
+
const domainWithDefaultArgs = {
|
|
778
|
+
entities: {},
|
|
779
|
+
subscribables: {},
|
|
780
|
+
customFunctions: [
|
|
781
|
+
{
|
|
782
|
+
name: 'simple_func',
|
|
783
|
+
sql: 'SELECT 1'
|
|
784
|
+
// No args specified - should default to ['p_user_id', 'p_params']
|
|
785
|
+
}
|
|
786
|
+
]
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
const customIR = generateIR(domainWithDefaultArgs);
|
|
790
|
+
expect(customIR.customFunctions[0].args).toEqual(['p_user_id', 'p_params']);
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
// ============================================================
|
|
795
|
+
// JAVASCRIPT CUSTOM FUNCTIONS TESTS
|
|
796
|
+
// ============================================================
|
|
797
|
+
|
|
798
|
+
describe("JavaScript Custom Functions", () => {
|
|
799
|
+
beforeAll(() => {
|
|
800
|
+
// Clear any previously registered functions
|
|
801
|
+
clearJsFunctions();
|
|
802
|
+
|
|
803
|
+
// Load a minimal manifest for the runtime
|
|
804
|
+
loadManifest({
|
|
805
|
+
version: '2.0.0',
|
|
806
|
+
functions: {
|
|
807
|
+
// Add a SQL function to test that JS takes precedence
|
|
808
|
+
test_sql_func: {
|
|
809
|
+
schema: 'dzql_v2',
|
|
810
|
+
name: 'test_sql_func',
|
|
811
|
+
args: ['p_user_id', 'p_params'],
|
|
812
|
+
returnType: 'jsonb'
|
|
813
|
+
}
|
|
814
|
+
},
|
|
815
|
+
entities: {},
|
|
816
|
+
subscribables: {}
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
afterAll(() => {
|
|
821
|
+
clearJsFunctions();
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
test("JS function can be registered and called", async () => {
|
|
825
|
+
// Register a simple JS function
|
|
826
|
+
registerJsFunction('my_js_func', async (ctx) => {
|
|
827
|
+
return {
|
|
828
|
+
message: 'Hello from JS!',
|
|
829
|
+
userId: ctx.userId,
|
|
830
|
+
params: ctx.params
|
|
831
|
+
};
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
// Create a mock db client
|
|
835
|
+
const mockDb = {
|
|
836
|
+
query: async () => []
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
// Call via handleRequest
|
|
840
|
+
const result = await handleRequest(mockDb, 'my_js_func', { foo: 'bar' }, 42);
|
|
841
|
+
|
|
842
|
+
expect(result.message).toBe('Hello from JS!');
|
|
843
|
+
expect(result.userId).toBe(42);
|
|
844
|
+
expect(result.params.foo).toBe('bar');
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
test("JS function can query the database", async () => {
|
|
848
|
+
// Register a JS function that queries the database
|
|
849
|
+
registerJsFunction('count_venues_js', async (ctx) => {
|
|
850
|
+
const rows = await ctx.db.query(
|
|
851
|
+
'SELECT COUNT(*) as cnt FROM venues WHERE org_id = $1',
|
|
852
|
+
[ctx.params.org_id]
|
|
853
|
+
);
|
|
854
|
+
return {
|
|
855
|
+
org_id: ctx.params.org_id,
|
|
856
|
+
venue_count: parseInt(rows[0].cnt)
|
|
857
|
+
};
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
// Use the real test database
|
|
861
|
+
const dbClient = {
|
|
862
|
+
query: async (text: string, params: any[]) => {
|
|
863
|
+
return await sql.unsafe(text, params);
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
// Call via handleRequest - org 1 has 2 venues from test setup
|
|
868
|
+
const result = await handleRequest(dbClient, 'count_venues_js', { org_id: 1 }, 1);
|
|
869
|
+
|
|
870
|
+
expect(result.org_id).toBe(1);
|
|
871
|
+
expect(result.venue_count).toBe(2);
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
test("JS function takes precedence over SQL function with same name", async () => {
|
|
875
|
+
// Register a JS function with a name that's also in the manifest
|
|
876
|
+
registerJsFunction('test_sql_func', async (ctx) => {
|
|
877
|
+
return { source: 'javascript', userId: ctx.userId };
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
const mockDb = {
|
|
881
|
+
query: async () => {
|
|
882
|
+
// This should NOT be called - JS takes precedence
|
|
883
|
+
throw new Error('SQL function should not be called');
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
const result = await handleRequest(mockDb, 'test_sql_func', {}, 1);
|
|
888
|
+
expect(result.source).toBe('javascript');
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
test("JS function can throw errors", async () => {
|
|
892
|
+
registerJsFunction('error_func', async () => {
|
|
893
|
+
throw new Error('Custom error from JS');
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
const mockDb = { query: async () => [] };
|
|
897
|
+
|
|
898
|
+
await expect(handleRequest(mockDb, 'error_func', {}, 1))
|
|
899
|
+
.rejects.toThrow('Custom error from JS');
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
test("JS function receives correct context", async () => {
|
|
903
|
+
let capturedContext: any = null;
|
|
904
|
+
|
|
905
|
+
registerJsFunction('capture_context', async (ctx) => {
|
|
906
|
+
capturedContext = {
|
|
907
|
+
userId: ctx.userId,
|
|
908
|
+
params: ctx.params,
|
|
909
|
+
hasDbQuery: typeof ctx.db.query === 'function'
|
|
910
|
+
};
|
|
911
|
+
return { ok: true };
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
const mockDb = { query: async () => [] };
|
|
915
|
+
await handleRequest(mockDb, 'capture_context', { test: 123 }, 99);
|
|
916
|
+
|
|
917
|
+
expect(capturedContext.userId).toBe(99);
|
|
918
|
+
expect(capturedContext.params.test).toBe(123);
|
|
919
|
+
expect(capturedContext.hasDbQuery).toBe(true);
|
|
920
|
+
});
|
|
921
|
+
});
|
|
922
|
+
});
|