dzql 0.1.6 → 0.2.0
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/README.md +8 -1
- package/docs/REFERENCE.md +139 -0
- package/package.json +2 -2
- package/src/client/ws.js +87 -2
- package/src/compiler/codegen/subscribable-codegen.js +396 -0
- package/src/compiler/compiler.js +115 -0
- package/src/compiler/parser/subscribable-parser.js +242 -0
- package/src/database/migrations/009_subscriptions.sql +230 -0
- package/src/server/index.js +90 -1
- package/src/server/subscriptions.js +209 -0
- package/src/server/ws.js +78 -2
package/README.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# DZQL
|
|
2
2
|
|
|
3
|
-
PostgreSQL-powered framework with automatic CRUD operations and real-time WebSocket synchronization.
|
|
3
|
+
PostgreSQL-powered framework with automatic CRUD operations, live query subscriptions, and real-time WebSocket synchronization.
|
|
4
4
|
|
|
5
5
|
## Documentation
|
|
6
6
|
|
|
7
7
|
- **[Getting Started Guide](docs/GETTING_STARTED.md)** - Complete tutorial with working todo app
|
|
8
8
|
- **[API Reference](docs/REFERENCE.md)** - Complete API documentation
|
|
9
|
+
- **[Live Query Subscriptions](../../docs/SUBSCRIPTIONS_QUICK_START.md)** - Real-time denormalized documents (NEW in v0.2.0)
|
|
9
10
|
- **[Compiler Documentation](docs/compiler/)** - Entity compilation guide and coding standards
|
|
10
11
|
- **[Claude Guide](docs/CLAUDE.md)** - Development guide for AI assistants
|
|
11
12
|
- **[Venues Example](../venues/)** - Full working application
|
|
@@ -29,6 +30,12 @@ await ws.connect();
|
|
|
29
30
|
// All 5 operations work automatically
|
|
30
31
|
const user = await ws.api.save.users({ name: 'Alice' });
|
|
31
32
|
const results = await ws.api.search.users({ filters: { name: 'alice' } });
|
|
33
|
+
|
|
34
|
+
// NEW in v0.2.0: Live query subscriptions
|
|
35
|
+
const { data, unsubscribe } = await ws.api.subscribe_venue_detail(
|
|
36
|
+
{ venue_id: 123 },
|
|
37
|
+
(updated) => console.log('Venue changed!', updated)
|
|
38
|
+
);
|
|
32
39
|
```
|
|
33
40
|
|
|
34
41
|
## DZQL Compiler
|
package/docs/REFERENCE.md
CHANGED
|
@@ -12,6 +12,7 @@ Complete API documentation for DZQL framework. For tutorials, see [GETTING_START
|
|
|
12
12
|
- [Custom Functions](#custom-functions)
|
|
13
13
|
- [Authentication](#authentication)
|
|
14
14
|
- [Real-time Events](#real-time-events)
|
|
15
|
+
- [Live Query Subscriptions](#live-query-subscriptions)
|
|
15
16
|
- [Temporal Relationships](#temporal-relationships)
|
|
16
17
|
- [Error Messages](#error-messages)
|
|
17
18
|
|
|
@@ -864,6 +865,144 @@ ws.onBroadcast((method, params) => {
|
|
|
864
865
|
|
|
865
866
|
---
|
|
866
867
|
|
|
868
|
+
## Live Query Subscriptions
|
|
869
|
+
|
|
870
|
+
Subscribe to denormalized documents and receive automatic updates when underlying data changes. Subscriptions use a PostgreSQL-first architecture where all change detection happens in the database.
|
|
871
|
+
|
|
872
|
+
For complete documentation, see **[Live Query Subscriptions Guide](../../../docs/LIVE_QUERY_SUBSCRIPTIONS.md)** and **[Quick Start](../../../docs/SUBSCRIPTIONS_QUICK_START.md)**.
|
|
873
|
+
|
|
874
|
+
### Quick Example
|
|
875
|
+
|
|
876
|
+
```javascript
|
|
877
|
+
// Subscribe to venue with all related data
|
|
878
|
+
const { data, unsubscribe } = await ws.api.subscribe_venue_detail(
|
|
879
|
+
{ venue_id: 123 },
|
|
880
|
+
(updatedVenue) => {
|
|
881
|
+
// Called automatically when venue, org, or sites change
|
|
882
|
+
console.log('Updated:', updatedVenue);
|
|
883
|
+
// updatedVenue = { id: 123, name: '...', org: {...}, sites: [...] }
|
|
884
|
+
}
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
// Initial data available immediately
|
|
888
|
+
console.log('Initial:', data);
|
|
889
|
+
|
|
890
|
+
// Later: cleanup
|
|
891
|
+
await unsubscribe();
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
### Creating a Subscribable
|
|
895
|
+
|
|
896
|
+
Define subscribables in SQL:
|
|
897
|
+
|
|
898
|
+
```sql
|
|
899
|
+
SELECT dzql.register_subscribable(
|
|
900
|
+
'venue_detail', -- Name
|
|
901
|
+
'{"subscribe": ["@org_id->acts_for[org_id=$]{active}.user_id"]}'::jsonb, -- Permissions
|
|
902
|
+
'{"venue_id": "int"}'::jsonb, -- Parameters
|
|
903
|
+
'venues', -- Root table
|
|
904
|
+
'{
|
|
905
|
+
"org": "organisations",
|
|
906
|
+
"sites": {"entity": "sites", "filter": "venue_id=$venue_id"}
|
|
907
|
+
}'::jsonb -- Relations
|
|
908
|
+
);
|
|
909
|
+
```
|
|
910
|
+
|
|
911
|
+
### Compile and Deploy
|
|
912
|
+
|
|
913
|
+
```bash
|
|
914
|
+
# Compile subscribable to PostgreSQL functions
|
|
915
|
+
node packages/dzql/compile-subscribable.js venue.sql | psql $DATABASE_URL
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
This generates three functions:
|
|
919
|
+
- `venue_detail_can_subscribe(user_id, params)` - Permission check
|
|
920
|
+
- `get_venue_detail(params, user_id)` - Query builder
|
|
921
|
+
- `venue_detail_affected_documents(table, op, old, new)` - Change detector
|
|
922
|
+
|
|
923
|
+
### Subscription Lifecycle
|
|
924
|
+
|
|
925
|
+
1. **Subscribe**: Client calls `ws.api.subscribe_<name>(params, callback)`
|
|
926
|
+
2. **Permission Check**: `<name>_can_subscribe()` validates access
|
|
927
|
+
3. **Initial Query**: `get_<name>()` returns denormalized document
|
|
928
|
+
4. **Register**: Server stores subscription in-memory
|
|
929
|
+
5. **Database Change**: Any relevant table modification
|
|
930
|
+
6. **Detect**: `<name>_affected_documents()` identifies affected subscriptions
|
|
931
|
+
7. **Re-query**: `get_<name>()` fetches fresh data
|
|
932
|
+
8. **Update**: Callback invoked with new data
|
|
933
|
+
|
|
934
|
+
### Unsubscribe
|
|
935
|
+
|
|
936
|
+
```javascript
|
|
937
|
+
// Method 1: Use returned unsubscribe function
|
|
938
|
+
const { unsubscribe } = await ws.api.subscribe_venue_detail(...);
|
|
939
|
+
await unsubscribe();
|
|
940
|
+
|
|
941
|
+
// Method 2: Direct unsubscribe call
|
|
942
|
+
await ws.api.unsubscribe_venue_detail({ venue_id: 123 });
|
|
943
|
+
```
|
|
944
|
+
|
|
945
|
+
### Architecture Benefits
|
|
946
|
+
|
|
947
|
+
- **PostgreSQL-First**: All logic executes in database, not application code
|
|
948
|
+
- **Zero Configuration**: Pattern matching on method names - no server changes needed
|
|
949
|
+
- **Type Safe**: Compiled functions validated at deploy time
|
|
950
|
+
- **Efficient**: In-memory registry, PostgreSQL does matching
|
|
951
|
+
- **Secure**: Permission paths enforced at database level
|
|
952
|
+
- **Scalable**: Stateless server, can add instances freely
|
|
953
|
+
|
|
954
|
+
### Common Patterns
|
|
955
|
+
|
|
956
|
+
**Single Table:**
|
|
957
|
+
```sql
|
|
958
|
+
SELECT dzql.register_subscribable(
|
|
959
|
+
'user_settings',
|
|
960
|
+
'{"subscribe": ["@user_id"]}'::jsonb,
|
|
961
|
+
'{"user_id": "int"}'::jsonb,
|
|
962
|
+
'user_settings',
|
|
963
|
+
'{}'::jsonb
|
|
964
|
+
);
|
|
965
|
+
```
|
|
966
|
+
|
|
967
|
+
**With Relations:**
|
|
968
|
+
```sql
|
|
969
|
+
SELECT dzql.register_subscribable(
|
|
970
|
+
'booking_detail',
|
|
971
|
+
'{"subscribe": ["@user_id"]}'::jsonb,
|
|
972
|
+
'{"booking_id": "int"}'::jsonb,
|
|
973
|
+
'bookings',
|
|
974
|
+
'{
|
|
975
|
+
"venue": "venues",
|
|
976
|
+
"customer": "users",
|
|
977
|
+
"items": {"entity": "booking_items", "filter": "booking_id=$booking_id"}
|
|
978
|
+
}'::jsonb
|
|
979
|
+
);
|
|
980
|
+
```
|
|
981
|
+
|
|
982
|
+
**Multiple Permission Paths (OR logic):**
|
|
983
|
+
```sql
|
|
984
|
+
SELECT dzql.register_subscribable(
|
|
985
|
+
'venue_admin',
|
|
986
|
+
'{
|
|
987
|
+
"subscribe": [
|
|
988
|
+
"@owner_id",
|
|
989
|
+
"@org_id->acts_for[org_id=$]{active}.user_id"
|
|
990
|
+
]
|
|
991
|
+
}'::jsonb,
|
|
992
|
+
'{"venue_id": "int"}'::jsonb,
|
|
993
|
+
'venues',
|
|
994
|
+
'{"sites": {"entity": "sites", "filter": "venue_id=$venue_id"}}'::jsonb
|
|
995
|
+
);
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
### See Also
|
|
999
|
+
|
|
1000
|
+
- **[Live Query Subscriptions Guide](../../../docs/LIVE_QUERY_SUBSCRIPTIONS.md)** - Complete reference
|
|
1001
|
+
- **[Quick Start Guide](../../../docs/SUBSCRIPTIONS_QUICK_START.md)** - 5-minute tutorial
|
|
1002
|
+
- **[Permission Paths](#permission--notification-paths)** - Path DSL syntax
|
|
1003
|
+
|
|
1004
|
+
---
|
|
1005
|
+
|
|
867
1006
|
## Temporal Relationships
|
|
868
1007
|
|
|
869
1008
|
Handle time-based relationships with `valid_from`/`valid_to` fields.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dzql",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "PostgreSQL-powered framework with zero boilerplate CRUD operations and real-time WebSocket synchronization",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/server/index.js",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
24
|
"test": "bun test",
|
|
25
|
-
"prepublishOnly": "echo '✅ Publishing DZQL v0.
|
|
25
|
+
"prepublishOnly": "echo '✅ Publishing DZQL v0.2.0...'"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"jose": "^6.1.0",
|
package/src/client/ws.js
CHANGED
|
@@ -59,12 +59,11 @@ class WebSocketManager {
|
|
|
59
59
|
this.pendingRequests = new Map();
|
|
60
60
|
this.broadcastCallbacks = new Set();
|
|
61
61
|
this.sidRequestHandlers = new Set();
|
|
62
|
+
this.subscriptions = new Map(); // subscription_id -> { callback, unsubscribe }
|
|
62
63
|
this.reconnectAttempts = 0;
|
|
63
64
|
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
|
|
64
65
|
this.isShuttingDown = false;
|
|
65
66
|
|
|
66
|
-
// Ad
|
|
67
|
-
|
|
68
67
|
// DZQL nested proxy API - matches server-side db.api pattern
|
|
69
68
|
// Proxy handles both DZQL operations and custom functions
|
|
70
69
|
const dzqlOps = {
|
|
@@ -137,6 +136,18 @@ class WebSocketManager {
|
|
|
137
136
|
if (prop in target) {
|
|
138
137
|
return target[prop];
|
|
139
138
|
}
|
|
139
|
+
// Handle subscribe_* methods specially
|
|
140
|
+
if (prop.startsWith('subscribe_')) {
|
|
141
|
+
return (params = {}, callback) => {
|
|
142
|
+
return this.subscribe(prop, params, callback);
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// Handle unsubscribe_* methods
|
|
146
|
+
if (prop.startsWith('unsubscribe_')) {
|
|
147
|
+
return (params = {}) => {
|
|
148
|
+
return this.unsubscribe(prop, params);
|
|
149
|
+
};
|
|
150
|
+
}
|
|
140
151
|
// All other properties are treated as custom function calls
|
|
141
152
|
return (params = {}) => {
|
|
142
153
|
return this.call(prop, params);
|
|
@@ -314,6 +325,16 @@ class WebSocketManager {
|
|
|
314
325
|
resolve(message.result);
|
|
315
326
|
}
|
|
316
327
|
} else {
|
|
328
|
+
// Handle subscription updates
|
|
329
|
+
if (message.method === "subscription:update") {
|
|
330
|
+
const { subscription_id, data } = message.params;
|
|
331
|
+
const sub = this.subscriptions.get(subscription_id);
|
|
332
|
+
if (sub && sub.callback) {
|
|
333
|
+
sub.callback(data);
|
|
334
|
+
}
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
317
338
|
// Handle broadcasts and SID requests
|
|
318
339
|
|
|
319
340
|
// Check if this is a SID request from server
|
|
@@ -376,6 +397,70 @@ class WebSocketManager {
|
|
|
376
397
|
});
|
|
377
398
|
}
|
|
378
399
|
|
|
400
|
+
/**
|
|
401
|
+
* Subscribe to a live query
|
|
402
|
+
*
|
|
403
|
+
* @param {string} method - Method name (subscribe_<subscribable>)
|
|
404
|
+
* @param {object} params - Subscription parameters
|
|
405
|
+
* @param {function} callback - Callback function for updates
|
|
406
|
+
* @returns {Promise<{data, subscription_id, unsubscribe}>} Initial data and unsubscribe function
|
|
407
|
+
*
|
|
408
|
+
* @example
|
|
409
|
+
* const { data, unsubscribe } = await ws.api.subscribe_venue_detail(
|
|
410
|
+
* { venue_id: 1 },
|
|
411
|
+
* (updated) => console.log('Updated:', updated)
|
|
412
|
+
* );
|
|
413
|
+
*
|
|
414
|
+
* // Use initial data
|
|
415
|
+
* console.log('Initial:', data);
|
|
416
|
+
*
|
|
417
|
+
* // Later: unsubscribe
|
|
418
|
+
* unsubscribe();
|
|
419
|
+
*/
|
|
420
|
+
async subscribe(method, params = {}, callback) {
|
|
421
|
+
if (!callback || typeof callback !== 'function') {
|
|
422
|
+
throw new Error('Subscribe requires a callback function');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Call server to register subscription
|
|
426
|
+
const result = await this.call(method, params);
|
|
427
|
+
const { subscription_id, data } = result;
|
|
428
|
+
|
|
429
|
+
// Create unsubscribe function
|
|
430
|
+
const unsubscribeFn = async () => {
|
|
431
|
+
const unsubMethod = method.replace('subscribe_', 'unsubscribe_');
|
|
432
|
+
await this.call(unsubMethod, params);
|
|
433
|
+
this.subscriptions.delete(subscription_id);
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
// Store callback for updates
|
|
437
|
+
this.subscriptions.set(subscription_id, {
|
|
438
|
+
callback,
|
|
439
|
+
unsubscribe: unsubscribeFn
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// Return initial data and unsubscribe function
|
|
443
|
+
return {
|
|
444
|
+
data,
|
|
445
|
+
subscription_id,
|
|
446
|
+
unsubscribe: unsubscribeFn
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Unsubscribe from a live query
|
|
452
|
+
*
|
|
453
|
+
* @param {string} method - Method name (unsubscribe_<subscribable>)
|
|
454
|
+
* @param {object} params - Subscription parameters
|
|
455
|
+
* @returns {Promise<{success: boolean}>}
|
|
456
|
+
*
|
|
457
|
+
* @example
|
|
458
|
+
* await ws.api.unsubscribe_venue_detail({ venue_id: 1 });
|
|
459
|
+
*/
|
|
460
|
+
async unsubscribe(method, params = {}) {
|
|
461
|
+
return await this.call(method, params);
|
|
462
|
+
}
|
|
463
|
+
|
|
379
464
|
/**
|
|
380
465
|
* Register callback for real-time broadcast events
|
|
381
466
|
*
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscribable Code Generator
|
|
3
|
+
* Generates PostgreSQL functions for live query subscriptions
|
|
4
|
+
*
|
|
5
|
+
* For each subscribable, generates:
|
|
6
|
+
* 1. get_<name>(params, user_id) - Query function that builds the document
|
|
7
|
+
* 2. <name>_affected_documents(table, op, old, new) - Determines which subscription instances are affected
|
|
8
|
+
* 3. <name>_can_subscribe(user_id, params) - Access control check
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { PathParser } from '../parser/path-parser.js';
|
|
12
|
+
|
|
13
|
+
export class SubscribableCodegen {
|
|
14
|
+
constructor(subscribable) {
|
|
15
|
+
this.name = subscribable.name;
|
|
16
|
+
this.permissionPaths = subscribable.permissionPaths || {};
|
|
17
|
+
this.paramSchema = subscribable.paramSchema || {};
|
|
18
|
+
this.rootEntity = subscribable.rootEntity;
|
|
19
|
+
this.relations = subscribable.relations || {};
|
|
20
|
+
this.parser = new PathParser();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Generate all functions for this subscribable
|
|
25
|
+
* @returns {string} SQL for all subscribable functions
|
|
26
|
+
*/
|
|
27
|
+
generate() {
|
|
28
|
+
const sections = [];
|
|
29
|
+
|
|
30
|
+
// Header comment
|
|
31
|
+
sections.push(this._generateHeader());
|
|
32
|
+
|
|
33
|
+
// 1. Access control function
|
|
34
|
+
sections.push(this._generateAccessControlFunction());
|
|
35
|
+
|
|
36
|
+
// 2. Query function (builds the document)
|
|
37
|
+
sections.push(this._generateQueryFunction());
|
|
38
|
+
|
|
39
|
+
// 3. Affected documents function (determines which subscriptions to update)
|
|
40
|
+
sections.push(this._generateAffectedDocumentsFunction());
|
|
41
|
+
|
|
42
|
+
return sections.join('\n\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Generate header comment
|
|
47
|
+
* @private
|
|
48
|
+
*/
|
|
49
|
+
_generateHeader() {
|
|
50
|
+
return `-- ============================================================================
|
|
51
|
+
-- Subscribable: ${this.name}
|
|
52
|
+
-- Root Entity: ${this.rootEntity}
|
|
53
|
+
-- Generated: ${new Date().toISOString()}
|
|
54
|
+
-- ============================================================================`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Generate access control function
|
|
59
|
+
* @private
|
|
60
|
+
*/
|
|
61
|
+
_generateAccessControlFunction() {
|
|
62
|
+
let subscribePaths = this.permissionPaths.subscribe || [];
|
|
63
|
+
|
|
64
|
+
// Ensure it's an array
|
|
65
|
+
if (!Array.isArray(subscribePaths)) {
|
|
66
|
+
subscribePaths = [subscribePaths];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// If no paths, it's public
|
|
70
|
+
if (subscribePaths.length === 0) {
|
|
71
|
+
return `CREATE OR REPLACE FUNCTION ${this.name}_can_subscribe(
|
|
72
|
+
p_user_id INT,
|
|
73
|
+
p_params JSONB
|
|
74
|
+
) RETURNS BOOLEAN AS $$
|
|
75
|
+
BEGIN
|
|
76
|
+
RETURN TRUE; -- Public access
|
|
77
|
+
END;
|
|
78
|
+
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Generate permission check logic
|
|
82
|
+
const checks = subscribePaths.map(path => {
|
|
83
|
+
const ast = this.parser.parse(path);
|
|
84
|
+
return this._generatePathCheck(ast, 'p_params', 'p_user_id');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const checkSQL = checks.join(' OR\n ');
|
|
88
|
+
|
|
89
|
+
return `CREATE OR REPLACE FUNCTION ${this.name}_can_subscribe(
|
|
90
|
+
p_user_id INT,
|
|
91
|
+
p_params JSONB
|
|
92
|
+
) RETURNS BOOLEAN AS $$
|
|
93
|
+
BEGIN
|
|
94
|
+
RETURN (
|
|
95
|
+
${checkSQL}
|
|
96
|
+
);
|
|
97
|
+
END;
|
|
98
|
+
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Generate path check SQL from AST
|
|
103
|
+
* @private
|
|
104
|
+
*/
|
|
105
|
+
_generatePathCheck(ast, recordVar, userIdVar) {
|
|
106
|
+
// Handle direct field reference: @owner_id
|
|
107
|
+
if (ast.type === 'field_ref') {
|
|
108
|
+
return `(${recordVar}->>'${ast.field}')::int = ${userIdVar}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Handle traversal with steps: @org_id->acts_for[org_id=$]{active}.user_id
|
|
112
|
+
if (ast.type === 'traversal' && ast.steps) {
|
|
113
|
+
const fieldRef = ast.steps[0]; // First step is the field reference
|
|
114
|
+
const tableRef = ast.steps[1]; // Second step is the table reference
|
|
115
|
+
|
|
116
|
+
if (!fieldRef || !tableRef || tableRef.type !== 'table_ref') {
|
|
117
|
+
return 'FALSE';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const startField = fieldRef.field;
|
|
121
|
+
const targetTable = tableRef.table;
|
|
122
|
+
const targetField = tableRef.targetField;
|
|
123
|
+
|
|
124
|
+
const startValue = `(${recordVar}->>'${startField}')::int`;
|
|
125
|
+
|
|
126
|
+
// Build WHERE clause
|
|
127
|
+
const whereClauses = [];
|
|
128
|
+
|
|
129
|
+
// Add filter conditions from the table_ref
|
|
130
|
+
if (tableRef.filter && tableRef.filter.length > 0) {
|
|
131
|
+
for (const filterCondition of tableRef.filter) {
|
|
132
|
+
const field = filterCondition.field;
|
|
133
|
+
if (filterCondition.value.type === 'param') {
|
|
134
|
+
// Parameter reference: org_id=$
|
|
135
|
+
whereClauses.push(`${targetTable}.${field} = ${startValue}`);
|
|
136
|
+
} else {
|
|
137
|
+
// Literal value
|
|
138
|
+
whereClauses.push(`${targetTable}.${field} = '${filterCondition.value}'`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Add temporal marker if present
|
|
144
|
+
if (tableRef.temporal) {
|
|
145
|
+
whereClauses.push(`${targetTable}.valid_to IS NULL`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return `EXISTS (
|
|
149
|
+
SELECT 1 FROM ${targetTable}
|
|
150
|
+
WHERE ${whereClauses.join('\n AND ')}
|
|
151
|
+
AND ${targetTable}.${targetField} = ${userIdVar}
|
|
152
|
+
)`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return 'FALSE';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Generate filter SQL
|
|
160
|
+
* @private
|
|
161
|
+
*/
|
|
162
|
+
_generateFilterSQL(filter, tableAlias) {
|
|
163
|
+
const conditions = [];
|
|
164
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
165
|
+
if (value === '$') {
|
|
166
|
+
// Placeholder - will be replaced with actual value
|
|
167
|
+
conditions.push(`${tableAlias}.${key} = ${tableAlias}.${key}`);
|
|
168
|
+
} else {
|
|
169
|
+
conditions.push(`${tableAlias}.${key} = '${value}'`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return conditions.join(' AND ');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Generate query function that builds the document
|
|
177
|
+
* @private
|
|
178
|
+
*/
|
|
179
|
+
_generateQueryFunction() {
|
|
180
|
+
const params = Object.keys(this.paramSchema);
|
|
181
|
+
const paramDeclarations = params.map(p => ` v_${p} ${this.paramSchema[p]};`).join('\n');
|
|
182
|
+
const paramExtractions = params.map(p =>
|
|
183
|
+
` v_${p} := (p_params->>'${p}')::${this.paramSchema[p]};`
|
|
184
|
+
).join('\n');
|
|
185
|
+
|
|
186
|
+
// Build root WHERE clause based on params
|
|
187
|
+
const rootFilter = this._generateRootFilter();
|
|
188
|
+
|
|
189
|
+
// Build relation subqueries
|
|
190
|
+
const relationSelects = this._generateRelationSelects();
|
|
191
|
+
|
|
192
|
+
return `CREATE OR REPLACE FUNCTION get_${this.name}(
|
|
193
|
+
p_params JSONB,
|
|
194
|
+
p_user_id INT
|
|
195
|
+
) RETURNS JSONB AS $$
|
|
196
|
+
DECLARE
|
|
197
|
+
${paramDeclarations}
|
|
198
|
+
v_result JSONB;
|
|
199
|
+
BEGIN
|
|
200
|
+
-- Extract parameters
|
|
201
|
+
${paramExtractions}
|
|
202
|
+
|
|
203
|
+
-- Check access control
|
|
204
|
+
IF NOT ${this.name}_can_subscribe(p_user_id, p_params) THEN
|
|
205
|
+
RAISE EXCEPTION 'Permission denied';
|
|
206
|
+
END IF;
|
|
207
|
+
|
|
208
|
+
-- Build document with root and all relations
|
|
209
|
+
SELECT jsonb_build_object(
|
|
210
|
+
'${this.rootEntity}', row_to_json(root.*)${relationSelects}
|
|
211
|
+
)
|
|
212
|
+
INTO v_result
|
|
213
|
+
FROM ${this.rootEntity} root
|
|
214
|
+
WHERE ${rootFilter};
|
|
215
|
+
|
|
216
|
+
RETURN v_result;
|
|
217
|
+
END;
|
|
218
|
+
$$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Generate root filter based on params
|
|
223
|
+
* @private
|
|
224
|
+
*/
|
|
225
|
+
_generateRootFilter() {
|
|
226
|
+
const params = Object.keys(this.paramSchema);
|
|
227
|
+
|
|
228
|
+
// Assume first param is the root entity ID
|
|
229
|
+
// TODO: Make this more flexible based on param naming conventions
|
|
230
|
+
if (params.length > 0) {
|
|
231
|
+
const firstParam = params[0];
|
|
232
|
+
// Convention: venue_id -> id, org_id -> id, etc.
|
|
233
|
+
return `root.id = v_${firstParam}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return 'TRUE';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Generate relation subqueries
|
|
241
|
+
* @private
|
|
242
|
+
*/
|
|
243
|
+
_generateRelationSelects() {
|
|
244
|
+
if (Object.keys(this.relations).length === 0) {
|
|
245
|
+
return '';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const selects = Object.entries(this.relations).map(([relName, relConfig]) => {
|
|
249
|
+
const relEntity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
|
|
250
|
+
const relFilter = typeof relConfig === 'object' ? relConfig.filter : null;
|
|
251
|
+
const relIncludes = typeof relConfig === 'object' ? relConfig.include : null;
|
|
252
|
+
|
|
253
|
+
// Build filter condition
|
|
254
|
+
let filterSQL = this._generateRelationFilter(relFilter, relEntity);
|
|
255
|
+
|
|
256
|
+
// Build nested includes if any
|
|
257
|
+
let nestedSelect = 'row_to_json(rel.*)';
|
|
258
|
+
if (relIncludes) {
|
|
259
|
+
const nestedFields = Object.entries(relIncludes).map(([nestedName, nestedEntity]) => {
|
|
260
|
+
return `'${nestedName}', (
|
|
261
|
+
SELECT jsonb_agg(row_to_json(nested.*))
|
|
262
|
+
FROM ${nestedEntity} nested
|
|
263
|
+
WHERE nested.${relEntity}_id = rel.id
|
|
264
|
+
)`;
|
|
265
|
+
}).join(',\n ');
|
|
266
|
+
|
|
267
|
+
nestedSelect = `jsonb_build_object(
|
|
268
|
+
'${relEntity}', row_to_json(rel.*),
|
|
269
|
+
${nestedFields}
|
|
270
|
+
)`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return `,
|
|
274
|
+
'${relName}', (
|
|
275
|
+
SELECT jsonb_agg(${nestedSelect})
|
|
276
|
+
FROM ${relEntity} rel
|
|
277
|
+
WHERE ${filterSQL}
|
|
278
|
+
)`;
|
|
279
|
+
}).join('');
|
|
280
|
+
|
|
281
|
+
return selects;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Generate filter for relation subquery
|
|
286
|
+
* @private
|
|
287
|
+
*/
|
|
288
|
+
_generateRelationFilter(filter, relEntity) {
|
|
289
|
+
if (!filter) {
|
|
290
|
+
// Default: foreign key to root
|
|
291
|
+
return `rel.${this.rootEntity}_id = root.id`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Parse filter expression like "venue_id=$venue_id"
|
|
295
|
+
// Replace $param with v_param variable
|
|
296
|
+
return filter.replace(/\$(\w+)/g, 'v_$1');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Generate affected documents function
|
|
301
|
+
* @private
|
|
302
|
+
*/
|
|
303
|
+
_generateAffectedDocumentsFunction() {
|
|
304
|
+
const cases = [];
|
|
305
|
+
|
|
306
|
+
// Case 1: Root entity changed
|
|
307
|
+
cases.push(this._generateRootAffectedCase());
|
|
308
|
+
|
|
309
|
+
// Case 2: Related entities changed
|
|
310
|
+
for (const [relName, relConfig] of Object.entries(this.relations)) {
|
|
311
|
+
cases.push(this._generateRelationAffectedCase(relName, relConfig));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const casesSQL = cases.join('\n\n ');
|
|
315
|
+
|
|
316
|
+
return `CREATE OR REPLACE FUNCTION ${this.name}_affected_documents(
|
|
317
|
+
p_table_name TEXT,
|
|
318
|
+
p_op TEXT,
|
|
319
|
+
p_old JSONB,
|
|
320
|
+
p_new JSONB
|
|
321
|
+
) RETURNS JSONB[] AS $$
|
|
322
|
+
DECLARE
|
|
323
|
+
v_affected JSONB[];
|
|
324
|
+
BEGIN
|
|
325
|
+
CASE p_table_name
|
|
326
|
+
${casesSQL}
|
|
327
|
+
|
|
328
|
+
ELSE
|
|
329
|
+
v_affected := ARRAY[]::JSONB[];
|
|
330
|
+
END CASE;
|
|
331
|
+
|
|
332
|
+
RETURN v_affected;
|
|
333
|
+
END;
|
|
334
|
+
$$ LANGUAGE plpgsql IMMUTABLE;`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Generate case for root entity changes
|
|
339
|
+
* @private
|
|
340
|
+
*/
|
|
341
|
+
_generateRootAffectedCase() {
|
|
342
|
+
const params = Object.keys(this.paramSchema);
|
|
343
|
+
const firstParam = params[0] || 'id';
|
|
344
|
+
|
|
345
|
+
return `-- Root entity (${this.rootEntity}) changed
|
|
346
|
+
WHEN '${this.rootEntity}' THEN
|
|
347
|
+
v_affected := ARRAY[
|
|
348
|
+
jsonb_build_object('${firstParam}', COALESCE((p_new->>'id')::int, (p_old->>'id')::int))
|
|
349
|
+
];`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Generate case for related entity changes
|
|
354
|
+
* @private
|
|
355
|
+
*/
|
|
356
|
+
_generateRelationAffectedCase(relName, relConfig) {
|
|
357
|
+
const relEntity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
|
|
358
|
+
const relFK = typeof relConfig === 'object' && relConfig.foreignKey
|
|
359
|
+
? relConfig.foreignKey
|
|
360
|
+
: `${this.rootEntity}_id`;
|
|
361
|
+
|
|
362
|
+
const params = Object.keys(this.paramSchema);
|
|
363
|
+
const firstParam = params[0] || 'id';
|
|
364
|
+
|
|
365
|
+
// Check if this is a nested relation (has parent FK)
|
|
366
|
+
const nestedIncludes = typeof relConfig === 'object' ? relConfig.include : null;
|
|
367
|
+
|
|
368
|
+
if (nestedIncludes) {
|
|
369
|
+
// Nested relation: need to traverse up to root
|
|
370
|
+
return `-- Nested relation (${relEntity}) changed
|
|
371
|
+
WHEN '${relEntity}' THEN
|
|
372
|
+
-- Find parent and then root
|
|
373
|
+
SELECT ARRAY_AGG(jsonb_build_object('${firstParam}', parent.${this.rootEntity}_id))
|
|
374
|
+
INTO v_affected
|
|
375
|
+
FROM ${relEntity} rel
|
|
376
|
+
JOIN ${Object.keys(nestedIncludes)[0]} parent ON parent.id = rel.${Object.keys(nestedIncludes)[0]}_id
|
|
377
|
+
WHERE rel.id = COALESCE((p_new->>'id')::int, (p_old->>'id')::int);`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return `-- Related entity (${relEntity}) changed
|
|
381
|
+
WHEN '${relEntity}' THEN
|
|
382
|
+
v_affected := ARRAY[
|
|
383
|
+
jsonb_build_object('${firstParam}', COALESCE((p_new->>'${relFK}')::int, (p_old->>'${relFK}')::int))
|
|
384
|
+
];`;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Generate subscribable functions from config
|
|
390
|
+
* @param {Object} subscribable - Subscribable configuration
|
|
391
|
+
* @returns {string} Generated SQL
|
|
392
|
+
*/
|
|
393
|
+
export function generateSubscribable(subscribable) {
|
|
394
|
+
const codegen = new SubscribableCodegen(subscribable);
|
|
395
|
+
return codegen.generate();
|
|
396
|
+
}
|