dzql 0.6.37 → 0.7.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.
@@ -1,118 +1,150 @@
1
1
  /**
2
2
  * Path Validator
3
3
  *
4
- * Validates all permission and notification paths in a domain configuration.
5
- * Used by the CLI `validate-paths` command.
4
+ * Checks every permission, notification and canSubscribe path in a domain
5
+ * against the domain itself: the syntax parses, every table is an entity,
6
+ * every column exists, and values compared with ids are integer keys.
7
+ * compileDomain refuses to build a domain with any error here, and the CLI
8
+ * `validate-paths` command reports them path by path.
6
9
  */
7
10
 
8
- import type { DomainConfig, EntityConfig } from "../../shared/ir.js";
9
- import { parsePath } from "./paths.js";
11
+ import type { DomainConfig } from "../../shared/ir.js";
12
+ import { generateIR } from "./ir.js";
13
+ import { parsePath, isIntegerType } from "./paths.js";
10
14
 
11
15
  export interface PathValidationResult {
12
16
  entity: string;
13
- pathType: 'permissions.view' | 'permissions.create' | 'permissions.update' | 'permissions.delete' | 'notifications';
17
+ pathType: 'permissions.view' | 'permissions.create' | 'permissions.update' | 'permissions.delete' | 'notifications' | 'canSubscribe';
14
18
  pathIndex: number;
15
19
  path: string;
16
20
  valid: boolean;
17
21
  error?: string;
18
22
  }
19
23
 
24
+ interface EntityLike {
25
+ columns: Array<{ name: string; type: string }>;
26
+ }
27
+
28
+ type PathKind = 'permission' | 'notification' | 'subscribe-list';
29
+
20
30
  /**
21
- * Validate all paths in a domain configuration
31
+ * Check one path against the domain. Returns the problems found (empty if none).
32
+ *
33
+ * @param entity - the entity whose row the path starts from
34
+ * @param fieldMap - renames start fields (a subscribable's key param is the root's id)
22
35
  */
23
- export function validatePaths(domain: DomainConfig): PathValidationResult[] {
24
- const results: PathValidationResult[] = [];
36
+ export function checkPath(
37
+ rule: string,
38
+ entityName: string,
39
+ entities: Record<string, EntityLike>,
40
+ kind: PathKind = 'permission',
41
+ fieldMap: Record<string, string> = {}
42
+ ): string[] {
43
+ const parsed = parsePath(rule);
44
+ if (!parsed.isValid) return [parsed.error ?? 'invalid path'];
45
+
46
+ if (parsed.literal) {
47
+ return kind === 'notification' ? [`a notification path must lead to users, not ${parsed.literal}`] : [];
48
+ }
49
+ if (kind === 'subscribe-list') {
50
+ return ['a list subscribable has no root row to follow a path from; use TRUE, FALSE or AUTHENTICATED'];
51
+ }
25
52
 
26
- for (const [entityName, entity] of Object.entries(domain.entities)) {
27
- // Validate permission paths
28
- const permissions = entity.permissions || {};
29
-
30
- for (const permType of ['view', 'create', 'update', 'delete'] as const) {
31
- const paths = permissions[permType] || [];
32
- for (let i = 0; i < paths.length; i++) {
33
- const path = paths[i];
34
- const result = validateSinglePath(path, entityName, domain);
35
- results.push({
36
- entity: entityName,
37
- pathType: `permissions.${permType}`,
38
- pathIndex: i,
39
- path,
40
- valid: result.valid,
41
- error: result.error
42
- });
43
- }
44
- }
53
+ const problems: string[] = [];
54
+ const columnOf = (table: string, column: string) =>
55
+ entities[table]?.columns.find(c => c.name === column);
45
56
 
46
- // Validate notification paths
47
- const notifications = (entity as any).notifications || {};
48
- for (const [notifyName, paths] of Object.entries(notifications)) {
49
- if (!Array.isArray(paths)) continue;
50
- for (let i = 0; i < paths.length; i++) {
51
- const path = paths[i] as string;
52
- const result = validateSinglePath(path, entityName, domain);
53
- results.push({
54
- entity: entityName,
55
- pathType: 'notifications',
56
- pathIndex: i,
57
- path,
58
- valid: result.valid,
59
- error: result.error ? `${notifyName}: ${result.error}` : undefined
60
- });
61
- }
57
+ const requireColumn = (table: string, column: string, integer: boolean) => {
58
+ const col = columnOf(table, column);
59
+ if (!col) {
60
+ problems.push(`no column '${column}' on ${table}`);
61
+ } else if (integer && !isIntegerType(col.type)) {
62
+ problems.push(`'${column}' is ${col.type.split(/\s/)[0]}, not an integer key`);
62
63
  }
63
- }
64
-
65
- // Validate subscribable canSubscribe paths
66
- for (const [subName, sub] of Object.entries(domain.subscribables || {})) {
67
- const paths = sub.canSubscribe || [];
68
- for (let i = 0; i < paths.length; i++) {
69
- const path = paths[i];
70
- const result = validateSinglePath(path, subName, domain);
71
- results.push({
72
- entity: subName,
73
- pathType: 'permissions.view',
74
- pathIndex: i,
75
- path,
76
- valid: result.valid,
77
- error: result.error
78
- });
64
+ };
65
+ // A field of the row being checked (its start field, or an @field in a filter)
66
+ const requireOwnField = (field: string) => requireColumn(entityName, fieldMap[field] ?? field, true);
67
+
68
+ if (!parsed.tableFirst) requireOwnField(parsed.startField!);
69
+
70
+ const steps = parsed.tableFirst ? parsed.hops : parsed.hops.slice(1);
71
+ for (const hop of steps) {
72
+ const table = hop.table!;
73
+ if (!entities[table]) {
74
+ problems.push(`unknown table '${table}'`);
75
+ continue;
79
76
  }
77
+ // A hop without [filters] joins the previous value on its id.
78
+ if (!hop.filters?.length) requireColumn(table, 'id', true);
79
+ for (const f of hop.filters ?? []) {
80
+ requireColumn(table, f.field, f.value === '$');
81
+ if (f.value.startsWith('@')) requireOwnField(f.value.slice(1));
82
+ }
83
+ for (const c of hop.conditions ?? []) requireColumn(table, c.field, false);
84
+ // The value passed on joins the next hop's key; the last one is compared with the user.
85
+ requireColumn(table, hop.outputField!, true);
80
86
  }
81
87
 
82
- return results;
88
+ return problems;
83
89
  }
84
90
 
85
91
  /**
86
- * Validate a single path expression
92
+ * Validate every path in a compiled domain. Each error names where the path is
93
+ * written, what is wrong, and the path itself.
87
94
  */
88
- function validateSinglePath(
89
- path: string,
90
- entityName: string,
91
- domain: DomainConfig
92
- ): { valid: boolean; error?: string } {
93
- // Parse the path
94
- const parsed = parsePath(path);
95
-
96
- if (!parsed.isValid) {
97
- return { valid: false, error: parsed.error };
98
- }
99
-
100
- // Optionally validate that referenced tables exist
101
- const errors: string[] = [];
95
+ export function validateDomainPaths(ir: { entities: Record<string, any>; subscribables: Record<string, any> }): string[] {
96
+ return collectResults(ir).filter(r => !r.valid).map(r => r.error!);
97
+ }
102
98
 
103
- for (const hop of parsed.hops) {
104
- if (hop.table && hop.type !== 'field') {
105
- if (!domain.entities[hop.table]) {
106
- errors.push(`Unknown table '${hop.table}'`);
107
- }
99
+ function collectResults(ir: { entities: Record<string, any>; subscribables: Record<string, any> }): PathValidationResult[] {
100
+ const results: PathValidationResult[] = [];
101
+ const record = (entity: string, pathType: PathValidationResult['pathType'], where: string, pathIndex: number, path: string, problems: string[]) => {
102
+ results.push({
103
+ entity,
104
+ pathType,
105
+ pathIndex,
106
+ path,
107
+ valid: problems.length === 0,
108
+ error: problems.length ? `${where}: ${problems.join('; ')} — in '${path}'` : undefined,
109
+ });
110
+ };
111
+
112
+ for (const [entityName, entity] of Object.entries<any>(ir.entities)) {
113
+ for (const action of ['view', 'create', 'update', 'delete'] as const) {
114
+ (entity.permissions?.[action] ?? []).forEach((path: string, i: number) => {
115
+ record(entityName, `permissions.${action}`, `${entityName}.permissions.${action}`, i, path,
116
+ checkPath(path, entityName, ir.entities));
117
+ });
118
+ }
119
+ for (const [notifyName, paths] of Object.entries<any>(entity.notifications ?? {})) {
120
+ if (!Array.isArray(paths)) continue;
121
+ paths.forEach((path: string, i: number) => {
122
+ record(entityName, 'notifications', `${entityName}.notifications.${notifyName}`, i, path,
123
+ checkPath(path, entityName, ir.entities, 'notification'));
124
+ });
108
125
  }
109
126
  }
110
127
 
111
- if (errors.length > 0) {
112
- return { valid: false, error: errors.join('; ') };
128
+ for (const [subName, sub] of Object.entries<any>(ir.subscribables)) {
129
+ const rootKey: string = sub.root?.key ?? '';
130
+ const isList = !rootKey;
131
+ const fieldMap = rootKey && !rootKey.startsWith('@') ? { [rootKey]: 'id' } : {};
132
+ (sub.canSubscribe ?? []).forEach((path: string, i: number) => {
133
+ const problems = !isList && !ir.entities[sub.root?.entity]
134
+ ? [`unknown root entity '${sub.root?.entity}'`]
135
+ : checkPath(path, sub.root.entity, ir.entities, isList ? 'subscribe-list' : 'permission', fieldMap);
136
+ record(subName, 'canSubscribe', `${subName}.canSubscribe`, i, path, problems);
137
+ });
113
138
  }
114
139
 
115
- return { valid: true };
140
+ return results;
141
+ }
142
+
143
+ /**
144
+ * Validate all paths in a domain configuration (for the CLI `validate-paths` command)
145
+ */
146
+ export function validatePaths(domain: DomainConfig): PathValidationResult[] {
147
+ return collectResults(generateIR(domain));
116
148
  }
117
149
 
118
150
  /**