dzql 0.4.4 → 0.4.6

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.
@@ -6,17 +6,11 @@ The DZQL Compiler transforms declarative entity definitions into optimized Postg
6
6
 
7
7
  - **[Quickstart Guide](QUICKSTART.md)** - Get started with the compiler in 5 minutes
8
8
 
9
- ## Guides
9
+ ## Reference
10
10
 
11
11
  - **[Advanced Filters](ADVANCED_FILTERS.md)** - Complex search operators and patterns
12
12
  - **[Coding Standards](CODING_STANDARDS.md)** - Best practices for DZQL code
13
-
14
- ## Reference
15
-
16
- - **[Comparison](COMPARISON.md)** - How DZQL compares to other approaches
17
- - **[Session Summary](SESSION_SUMMARY.md)** - Development session documentation
18
- - **[Summary](SUMMARY.md)** - Compiler overview and architecture
19
- - **[Overnight Build](OVERNIGHT_BUILD.md)** - Batch compilation process
13
+ - **[Comparison](COMPARISON.md)** - Runtime vs compiled side-by-side
20
14
 
21
15
  ## Using the Compiler
22
16
 
@@ -0,0 +1,38 @@
1
+ # DZQL Examples
2
+
3
+ SQL examples demonstrating DZQL entity registration patterns.
4
+
5
+ ## Files
6
+
7
+ ### blog.sql
8
+ A complete blog application with:
9
+ - Multiple entities (users, posts, comments, tags)
10
+ - Many-to-many relationships (posts ↔ tags)
11
+ - Soft delete
12
+ - FK includes
13
+ - Permission paths
14
+ - Notification paths
15
+
16
+ ### venue-detail-simple.sql
17
+ Basic subscribable definition for venue data.
18
+
19
+ ### venue-detail-subscribable.sql
20
+ Full subscribable with relations and permission paths. Demonstrates:
21
+ - Root entity with FK includes
22
+ - Child entity filtering
23
+ - Permission path syntax
24
+
25
+ ## Usage
26
+
27
+ These files are meant to be run after DZQL core migrations. See the [Tutorial](../getting-started/tutorial.md) for complete setup instructions.
28
+
29
+ ```bash
30
+ # After setting up your database with DZQL migrations
31
+ psql $DATABASE_URL < examples/blog.sql
32
+ ```
33
+
34
+ ## See Also
35
+
36
+ - [API Reference](../reference/api.md) - Entity registration parameters
37
+ - [Many-to-Many Guide](../guides/many-to-many.md) - M2M configuration
38
+ - [Subscriptions Guide](../guides/subscriptions.md) - Subscribable patterns
@@ -0,0 +1,160 @@
1
+ -- ============================================================================
2
+ -- Blog Application Example
3
+ -- ============================================================================
4
+ --
5
+ -- This example demonstrates:
6
+ -- - Multiple related entities (users, posts, comments, tags)
7
+ -- - Many-to-many relationships (posts <-> tags via post_tags junction)
8
+ -- - Soft delete (posts.deleted_at)
9
+ -- - FK includes (dereferencing author, post)
10
+ -- - Permission paths (author can edit/delete own content)
11
+ -- - Notification paths (notify post author when comments added)
12
+ --
13
+ -- To use this example:
14
+ -- 1. Create tables first (see CREATE TABLE statements below)
15
+ -- 2. Run the dzql.register_entity() calls to enable CRUD operations
16
+ -- 3. Use the generated API: save_posts, get_posts, search_posts, etc.
17
+ --
18
+ -- For a complete working example with Docker, tests, and frontend:
19
+ -- See packages/blog/ in the DZQL repository
20
+ --
21
+ -- ============================================================================
22
+
23
+ -- Create tables
24
+ CREATE TABLE IF NOT EXISTS users (
25
+ id SERIAL PRIMARY KEY,
26
+ name VARCHAR(255) NOT NULL,
27
+ email VARCHAR(255) UNIQUE NOT NULL,
28
+ password_hash TEXT NOT NULL,
29
+ created_at TIMESTAMP DEFAULT NOW()
30
+ );
31
+
32
+ CREATE TABLE IF NOT EXISTS posts (
33
+ id SERIAL PRIMARY KEY,
34
+ title VARCHAR(500) NOT NULL,
35
+ content TEXT NOT NULL,
36
+ summary TEXT,
37
+ author_id INT REFERENCES users(id),
38
+ created_at TIMESTAMP DEFAULT NOW(),
39
+ deleted_at TIMESTAMP
40
+ );
41
+
42
+ CREATE TABLE IF NOT EXISTS comments (
43
+ id SERIAL PRIMARY KEY,
44
+ content TEXT NOT NULL,
45
+ post_id INT REFERENCES posts(id),
46
+ author_id INT REFERENCES users(id),
47
+ created_at TIMESTAMP DEFAULT NOW()
48
+ );
49
+
50
+ -- Tags table for categorizing posts
51
+ CREATE TABLE IF NOT EXISTS tags (
52
+ id SERIAL PRIMARY KEY,
53
+ name VARCHAR(50) UNIQUE NOT NULL,
54
+ color VARCHAR(7) DEFAULT '#3788d8'
55
+ );
56
+
57
+ -- Junction table for post-tag many-to-many relationship
58
+ CREATE TABLE IF NOT EXISTS post_tags (
59
+ post_id INT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
60
+ tag_id INT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
61
+ PRIMARY KEY (post_id, tag_id)
62
+ );
63
+
64
+ CREATE INDEX IF NOT EXISTS idx_post_tags_post_id ON post_tags(post_id);
65
+ CREATE INDEX IF NOT EXISTS idx_post_tags_tag_id ON post_tags(tag_id);
66
+
67
+ -- ============================================================================
68
+ -- DZQL Entity Registrations
69
+ -- ============================================================================
70
+
71
+ -- Users entity - blog authors
72
+ select dzql.register_entity(
73
+ 'users',
74
+ 'name',
75
+ array['name', 'email'],
76
+ '{}', -- no FK includes
77
+ false, -- hard delete
78
+ '{}', -- no reverse FK
79
+ '{}', -- no notifications
80
+ jsonb_build_object(
81
+ 'view', array[]::text[], -- Anyone can view users
82
+ 'create', array[]::text[], -- Anyone can register
83
+ 'update', array['@id'], -- Only update own profile
84
+ 'delete', array['@id'] -- Only delete own account
85
+ )
86
+ );
87
+
88
+ -- Register tags entity first
89
+ select dzql.register_entity(
90
+ 'tags',
91
+ 'name',
92
+ array['name'],
93
+ '{}', -- no FK includes
94
+ false, -- hard delete
95
+ '{}', -- no temporal
96
+ '{}', -- no notifications
97
+ jsonb_build_object(
98
+ 'view', array[]::text[], -- Anyone can view tags
99
+ 'create', array[]::text[], -- Anyone can create tags
100
+ 'update', array[]::text[], -- Anyone can update tags
101
+ 'delete', array[]::text[] -- Anyone can delete tags
102
+ )
103
+ );
104
+
105
+ -- Posts entity - blog posts with M2M tags
106
+ select dzql.register_entity(
107
+ 'posts',
108
+ 'title',
109
+ array['title', 'content', 'summary'],
110
+ jsonb_build_object(
111
+ 'author', 'users' -- FK to users
112
+ ),
113
+ true, -- soft delete (deleted_at)
114
+ '{}', -- no temporal
115
+ '{}', -- no notification paths
116
+ jsonb_build_object(
117
+ 'view', array[]::text[], -- Anyone can view posts
118
+ 'create', array[]::text[], -- Anyone can create posts
119
+ 'update', array['@author_id'], -- Only author can update
120
+ 'delete', array['@author_id'] -- Only author can delete
121
+ ),
122
+ jsonb_build_object(
123
+ 'many_to_many', jsonb_build_object(
124
+ 'tags', jsonb_build_object(
125
+ 'junction_table', 'post_tags',
126
+ 'local_key', 'post_id',
127
+ 'foreign_key', 'tag_id',
128
+ 'target_entity', 'tags',
129
+ 'id_field', 'tag_ids',
130
+ 'expand', true -- Include full tag objects in response
131
+ )
132
+ )
133
+ ) -- graph_rules with M2M
134
+ );
135
+
136
+ -- Comments entity - blog comments
137
+ select dzql.register_entity(
138
+ 'comments',
139
+ 'content',
140
+ array['content'],
141
+ jsonb_build_object(
142
+ 'post', 'posts',
143
+ 'author', 'users'
144
+ ),
145
+ false, -- hard delete
146
+ '{}', -- no reverse FK
147
+ jsonb_build_object(
148
+ 'post_author', array['@post_id->posts.author_id'],
149
+ 'commenters', array['@post_id']
150
+ ),
151
+ jsonb_build_object(
152
+ 'view', array[]::text[], -- Anyone can view comments
153
+ 'create', array[]::text[], -- Anyone can comment
154
+ 'update', array['@author_id'], -- Only author can update
155
+ 'delete', array[
156
+ '@author_id', -- Author can delete
157
+ '@post_id->posts.author_id' -- Post author can delete
158
+ ]
159
+ )
160
+ );
@@ -0,0 +1,8 @@
1
+ -- Simplified test subscribable for initial testing
2
+ SELECT dzql.register_subscribable(
3
+ 'venue_detail',
4
+ '{"subscribe": ["@org_id->acts_for[org_id=$]{active}.user_id"]}'::jsonb,
5
+ '{"venue_id": "int"}'::jsonb,
6
+ 'venues',
7
+ '{"org": "organisations", "sites": {"entity": "sites", "filter": "venue_id=$venue_id"}}'::jsonb
8
+ );
@@ -0,0 +1,45 @@
1
+ -- Example: Venue Detail Subscribable
2
+ -- This subscribable builds a denormalized document containing:
3
+ -- - The venue record
4
+ -- - The organization (FK expanded)
5
+ -- - All sites belonging to the venue
6
+ -- - All packages with their allocations
7
+
8
+ SELECT dzql.register_subscribable(
9
+ 'venue_detail',
10
+
11
+ -- Permission: who can subscribe?
12
+ -- Users who act_for the venue's organization (active roles only)
13
+ jsonb_build_object(
14
+ 'subscribe', ARRAY['@org_id->acts_for[org_id=$]{active}.user_id']
15
+ ),
16
+
17
+ -- Parameters: subscription key
18
+ jsonb_build_object(
19
+ 'venue_id', 'int'
20
+ ),
21
+
22
+ -- Root entity
23
+ 'venues',
24
+
25
+ -- Relations to include in document
26
+ jsonb_build_object(
27
+ -- FK expansion: organisation
28
+ 'org', 'organisations',
29
+
30
+ -- Child collection: sites
31
+ 'sites', jsonb_build_object(
32
+ 'entity', 'sites',
33
+ 'filter', 'venue_id=$venue_id'
34
+ ),
35
+
36
+ -- Child collection: packages with nested allocations
37
+ 'packages', jsonb_build_object(
38
+ 'entity', 'packages',
39
+ 'filter', 'venue_id=$venue_id',
40
+ 'include', jsonb_build_object(
41
+ 'allocations', 'allocations'
42
+ )
43
+ )
44
+ )
45
+ );
@@ -28,7 +28,7 @@ Live Query Subscriptions (Pattern 1 from vision.md) enable clients to subscribe
28
28
  Create a SQL file with your subscribable definition:
29
29
 
30
30
  ```sql
31
- -- examples/subscribables/venue_detail.sql
31
+ -- examples/venue-detail-subscribable.sql
32
32
  SELECT dzql.register_subscribable(
33
33
  'venue_detail',
34
34
  '{"subscribe": ["@org_id->acts_for[org_id=$]{active}.user_id"]}'::jsonb,
@@ -56,7 +56,7 @@ SELECT dzql.register_subscribable(
56
56
  ```bash
57
57
  # Compile subscribable to SQL functions
58
58
  bun packages/dzql/src/compiler/cli/compile-subscribable.js \
59
- examples/subscribables/venue_detail.sql \
59
+ examples/venue-detail-subscribable.sql \
60
60
  > /tmp/venue_detail.sql
61
61
 
62
62
  # Deploy to database
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
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",
@@ -19,6 +19,7 @@
19
19
  "src/**/*.js",
20
20
  "src/database/migrations/**/*.sql",
21
21
  "docs/**/*.md",
22
+ "docs/examples/*.sql",
22
23
  "README.md",
23
24
  "LICENSE"
24
25
  ],
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Auth Code Generator
3
+ * Generates PostgreSQL functions for user authentication
4
+ * Only generated when the entity is named 'users'
5
+ */
6
+
7
+ export class AuthCodegen {
8
+ constructor(entity) {
9
+ this.entity = entity;
10
+ this.tableName = entity.tableName;
11
+ }
12
+
13
+ /**
14
+ * Check if this entity should have auth functions generated
15
+ * @returns {boolean}
16
+ */
17
+ shouldGenerate() {
18
+ return this.tableName === 'users';
19
+ }
20
+
21
+ /**
22
+ * Generate all auth functions
23
+ * @returns {string} SQL for auth functions
24
+ */
25
+ generateAll() {
26
+ if (!this.shouldGenerate()) {
27
+ return '';
28
+ }
29
+
30
+ return [
31
+ this._generateProfileFunction(),
32
+ this._generateRegisterFunction(),
33
+ this._generateLoginFunction()
34
+ ].join('\n\n');
35
+ }
36
+
37
+ /**
38
+ * Generate _profile function
39
+ * Returns all user columns except sensitive fields
40
+ * @private
41
+ */
42
+ _generateProfileFunction() {
43
+ return `-- ============================================================================
44
+ -- Auth: _profile function for ${this.tableName}
45
+ -- Returns user record minus sensitive fields
46
+ -- ============================================================================
47
+ CREATE OR REPLACE FUNCTION _profile(p_user_id INT)
48
+ RETURNS JSONB
49
+ LANGUAGE SQL
50
+ SECURITY DEFINER
51
+ AS $$
52
+ SELECT jsonb_build_object('user_id', u.id) || (to_jsonb(u.*) - 'id' - 'password_hash' - 'password' - 'secret' - 'token')
53
+ FROM ${this.tableName} u
54
+ WHERE id = p_user_id;
55
+ $$;`;
56
+ }
57
+
58
+ /**
59
+ * Generate register_user function
60
+ * Supports optional extra fields via JSON parameter
61
+ * @private
62
+ */
63
+ _generateRegisterFunction() {
64
+ return `-- ============================================================================
65
+ -- Auth: register_user function for ${this.tableName}
66
+ -- p_extra: optional JSON object with additional fields to set on the user record
67
+ -- Example: register_user('test@example.com', 'password', '{"name": "Test User"}')
68
+ -- ============================================================================
69
+ CREATE OR REPLACE FUNCTION register_user(p_email TEXT, p_password TEXT, p_extra JSONB DEFAULT '{}')
70
+ RETURNS JSONB
71
+ LANGUAGE plpgsql
72
+ SECURITY DEFINER
73
+ AS $$
74
+ DECLARE
75
+ v_user_id INT;
76
+ v_salt TEXT;
77
+ v_hash TEXT;
78
+ v_insert_data JSONB;
79
+ BEGIN
80
+ -- Generate salt and hash password
81
+ v_salt := gen_salt('bf', 10);
82
+ v_hash := crypt(p_password, v_salt);
83
+
84
+ -- Build insert data: extra fields + email + password_hash (extra cannot override core fields)
85
+ v_insert_data := (p_extra - 'id' - 'email' - 'password_hash' - 'password')
86
+ || jsonb_build_object('email', p_email, 'password_hash', v_hash);
87
+
88
+ -- Dynamic INSERT from JSONB (same pattern as compiled save functions)
89
+ EXECUTE (
90
+ SELECT format(
91
+ 'INSERT INTO ${this.tableName} (%s) VALUES (%s) RETURNING id',
92
+ string_agg(quote_ident(key), ', '),
93
+ string_agg(quote_nullable(value), ', ')
94
+ )
95
+ FROM jsonb_each_text(v_insert_data) kv(key, value)
96
+ ) INTO v_user_id;
97
+
98
+ RETURN _profile(v_user_id);
99
+ EXCEPTION
100
+ WHEN unique_violation THEN
101
+ RAISE EXCEPTION 'Email already exists' USING errcode = '23505';
102
+ END $$;`;
103
+ }
104
+
105
+ /**
106
+ * Generate login_user function
107
+ * @private
108
+ */
109
+ _generateLoginFunction() {
110
+ return `-- ============================================================================
111
+ -- Auth: login_user function for ${this.tableName}
112
+ -- ============================================================================
113
+ CREATE OR REPLACE FUNCTION login_user(p_email TEXT, p_password TEXT)
114
+ RETURNS JSONB
115
+ LANGUAGE plpgsql
116
+ SECURITY DEFINER
117
+ AS $$
118
+ DECLARE
119
+ v_user_record RECORD;
120
+ BEGIN
121
+ SELECT id, email, password_hash
122
+ INTO v_user_record
123
+ FROM ${this.tableName}
124
+ WHERE email = p_email;
125
+
126
+ IF NOT FOUND THEN
127
+ RAISE EXCEPTION 'Invalid credentials' USING errcode = '28000';
128
+ END IF;
129
+
130
+ IF NOT (v_user_record.password_hash = crypt(p_password, v_user_record.password_hash)) THEN
131
+ RAISE EXCEPTION 'Invalid credentials' USING errcode = '28000';
132
+ END IF;
133
+
134
+ RETURN _profile(v_user_record.id);
135
+ END $$;`;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Generate auth functions for an entity (only if it's the users table)
141
+ * @param {Object} entity - Entity configuration
142
+ * @returns {string} SQL for auth functions (empty string if not users table)
143
+ */
144
+ export function generateAuthFunctions(entity) {
145
+ const codegen = new AuthCodegen(entity);
146
+ return codegen.generateAll();
147
+ }
@@ -10,6 +10,7 @@ import { generateOperations } from './codegen/operation-codegen.js';
10
10
  import { generateNotificationFunction } from './codegen/notification-codegen.js';
11
11
  import { generateGraphRuleFunctions } from './codegen/graph-rules-codegen.js';
12
12
  import { generateSubscribable } from './codegen/subscribable-codegen.js';
13
+ import { generateAuthFunctions } from './codegen/auth-codegen.js';
13
14
  import crypto from 'crypto';
14
15
 
15
16
  export class DZQLCompiler {
@@ -53,6 +54,12 @@ export class DZQLCompiler {
53
54
  const operationSQL = generateOperations(normalizedEntity);
54
55
  sections.push(operationSQL);
55
56
 
57
+ // Auth functions (only for users table)
58
+ const authSQL = generateAuthFunctions(normalizedEntity);
59
+ if (authSQL) {
60
+ sections.push(authSQL);
61
+ }
62
+
56
63
  // Notification path resolution (if needed)
57
64
  if (normalizedEntity.notificationPaths &&
58
65
  Object.keys(normalizedEntity.notificationPaths).length > 0) {
@@ -5,37 +5,51 @@
5
5
  create extension if not exists pgcrypto;
6
6
 
7
7
  -- === Users Table ===
8
+ -- Core auth table with optional name field
9
+ -- Applications can add additional columns as needed
10
+ -- Note: created_at is tracked via the action log, not here
8
11
  create table if not exists users (
9
12
  id serial primary key,
13
+ name text,
10
14
  email text unique not null,
11
- name text not null,
12
- password_hash text not null,
13
- created_at timestamptz default now()
15
+ password_hash text not null
14
16
  );
15
17
 
16
18
  -- === Auth Functions ===
17
19
 
18
20
  -- Register new user
19
- create or replace function register_user(p_email text, p_password text)
21
+ -- p_extra: optional JSON object with additional fields to set on the user record
22
+ -- Example: register_user('test@example.com', 'password', '{"name": "Test User"}')
23
+ create or replace function register_user(p_email text, p_password text, p_extra jsonb default '{}')
20
24
  returns jsonb
21
25
  language plpgsql
22
26
  security definer
23
27
  as $$
24
28
  declare
25
- user_id int;
26
- salt text;
27
- hash text;
29
+ v_user_id int;
30
+ v_salt text;
31
+ v_hash text;
32
+ v_insert_data jsonb;
28
33
  begin
29
34
  -- Generate salt and hash password
30
- salt := gen_salt('bf', 10);
31
- hash := crypt(p_password, salt);
35
+ v_salt := gen_salt('bf', 10);
36
+ v_hash := crypt(p_password, v_salt);
32
37
 
33
- -- Insert user
34
- insert into users (email, name, password_hash)
35
- values (p_email, split_part(p_email, '@', 1), hash)
36
- returning id into user_id;
38
+ -- Build insert data: extra fields + email + password_hash (extra cannot override core fields)
39
+ v_insert_data := (p_extra - 'id' - 'email' - 'password_hash' - 'password')
40
+ || jsonb_build_object('email', p_email, 'password_hash', v_hash);
37
41
 
38
- return _profile(user_id);
42
+ -- Dynamic INSERT from JSONB (same pattern as compiled save functions)
43
+ execute (
44
+ select format(
45
+ 'INSERT INTO users (%s) VALUES (%s) RETURNING id',
46
+ string_agg(quote_ident(key), ', '),
47
+ string_agg(quote_nullable(value), ', ')
48
+ )
49
+ from jsonb_each_text(v_insert_data) kv(key, value)
50
+ ) into v_user_id;
51
+
52
+ return _profile(v_user_id);
39
53
  exception
40
54
  when unique_violation then
41
55
  raise exception 'Email already exists' using errcode = '23505';
@@ -50,7 +64,7 @@ as $$
50
64
  declare
51
65
  user_record record;
52
66
  begin
53
- select id, email, name, password_hash
67
+ select id, email, password_hash
54
68
  into user_record
55
69
  from users
56
70
  where email = p_email;
@@ -67,17 +81,14 @@ begin
67
81
  end $$;
68
82
 
69
83
  -- Get user profile (private function)
84
+ -- Returns all user columns except sensitive fields (password_hash, password, secret, token)
85
+ -- This allows the users table to have any additional columns without modifying this function
70
86
  create or replace function _profile(p_user_id int)
71
87
  returns jsonb
72
88
  language sql
73
89
  security definer
74
90
  as $$
75
- select jsonb_build_object(
76
- 'user_id', id,
77
- 'email', email,
78
- 'name', name,
79
- 'created_at', created_at
80
- )
81
- from users
91
+ select jsonb_build_object('user_id', u.id) || (to_jsonb(u.*) - 'id' - 'password_hash' - 'password' - 'secret' - 'token')
92
+ from users u
82
93
  where id = p_user_id;
83
94
  $$;