@inixiative/json-rules 1.1.2 → 1.3.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 CHANGED
@@ -1,9 +1,12 @@
1
1
  # @inixiative/json-rules
2
2
 
3
- [![npm version](https://badge.fury.io/js/@inixiative%2Fjson-rules.svg)](https://www.npmjs.com/package/@inixiative/json-rules)
4
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
3
+ A TypeScript-first JSON rules library for:
5
4
 
6
- A powerful, type-safe JSON-based rules engine for TypeScript/JavaScript applications. Define complex validation and business logic rules using simple JSON structures.
5
+ - runtime validation with custom error messages
6
+ - Prisma query planning
7
+ - PostgreSQL `WHERE` generation
8
+
9
+ The same rule AST can be evaluated against in-memory data with `check()`, converted into a Prisma query plan with `toPrisma()`, or compiled into SQL with `toSql()`.
7
10
 
8
11
  ## Installation
9
12
 
@@ -15,326 +18,449 @@ yarn add @inixiative/json-rules
15
18
  bun add @inixiative/json-rules
16
19
  ```
17
20
 
18
- ## Features
19
-
20
- - 🎯 **Type-safe**: Full TypeScript support with strict type checking
21
- - 🔧 **Flexible**: 22 standard operators, 8 array operators, and 8 date operators
22
- - 🌳 **Composable**: Nest rules with logical operators (all/any) and conditional logic (if-then-else)
23
- - 📊 **Array validation**: Rich array validation with element-wise conditions
24
- - 📅 **Date handling**: Comprehensive date comparison with timezone support
25
- - 🔍 **Path-based access**: Reference values from anywhere in your data structure
26
- - 💬 **Custom errors**: Every rule supports custom error messages
27
-
28
- ## Installation
29
-
30
- ```bash
31
- npm install json-rules
32
- # or
33
- yarn add json-rules
34
- # or
35
- bun add json-rules
36
- ```
37
-
38
21
  ## Quick Start
39
22
 
40
- ```typescript
41
- import { check, Operator } from 'json-rules';
23
+ ```ts
24
+ import { check, Operator } from '@inixiative/json-rules';
42
25
 
43
- // Simple rule
44
26
  const rule = {
45
27
  field: 'age',
46
- operator: Operator.greaterThanEqual,
28
+ operator: Operator.greaterThanEquals,
47
29
  value: 18,
48
- error: 'Must be 18 or older'
30
+ error: 'Must be 18 or older',
49
31
  };
50
32
 
51
- const result = check(rule, { age: 21 }); // returns true
52
- const result2 = check(rule, { age: 16 }); // returns "Must be 18 or older"
33
+ check(rule, { age: 21 }); // true
34
+ check(rule, { age: 16 }); // "Must be 18 or older"
53
35
  ```
54
36
 
37
+ ## What It Supports
38
+
39
+ - scalar comparisons
40
+ - nested logical conditions with `all` / `any`
41
+ - `if` / `then` / `else`
42
+ - array validation against nested object elements
43
+ - array aggregates — `sum` and `avg` across numeric arrays or relation lists
44
+ - date comparisons with timezone-aware runtime evaluation
45
+ - relative value references via `path`
46
+ - custom error messages on every rule
47
+ - compilation to Prisma and PostgreSQL for supported subsets
48
+
55
49
  ## Operators
56
50
 
57
- ### Standard Operators (22)
58
-
59
- #### Comparison
60
- - `equal` - Exact equality check
61
- - `notEqual` - Not equal check
62
- - `lessThan` - Less than comparison
63
- - `lessThanEqual` - Less than or equal
64
- - `greaterThan` - Greater than comparison
65
- - `greaterThanEqual` - Greater than or equal
66
-
67
- #### Range
68
- - `between` - Value within range (inclusive)
69
- - `notBetween` - Value outside range
70
-
71
- #### Membership
72
- - `in` - Value in array
73
- - `notIn` - Value not in array
74
- - `contains` - Array/string contains value
75
- - `notContains` - Array/string doesn't contain value
76
-
77
- #### String
78
- - `startsWith` - String starts with value
79
- - `endsWith` - String ends with value
80
-
81
- #### Pattern
82
- - `match` - Regex pattern match
83
- - `notMatch` - Regex pattern doesn't match
84
-
85
- #### Existence
86
- - `isEmpty` - Check if value is empty (null, undefined, "", [], {})
87
- - `notEmpty` - Check if value is not empty
88
- - `exists` - Field exists (not undefined)
89
- - `notExists` - Field doesn't exist (undefined)
90
-
91
- ### Array Operators (8)
92
-
93
- - `all` - All elements match condition
94
- - `any` - At least one element matches
95
- - `none` - No elements match
96
- - `atLeast` - At least X elements match
97
- - `atMost` - At most X elements match
98
- - `exactly` - Exactly X elements match
99
- - `empty` - Array is empty
100
- - `notEmpty` - Array has elements
101
-
102
- ### Date Operators (8)
103
-
104
- - `before` - Date is before comparison date
105
- - `after` - Date is after comparison date
106
- - `onOrBefore` - Date is on or before
107
- - `onOrAfter` - Date is on or after
108
- - `between` - Date is between two dates
109
- - `notBetween` - Date is outside range
110
- - `dayIn` - Day of week is in list
111
- - `dayNotIn` - Day of week is not in list
112
-
113
- #### Timezone Handling
114
-
115
- Date comparisons are timezone-aware:
116
-
117
- 1. **When condition value has no timezone** (e.g., `'2025-01-20'`), it's interpreted in the field's timezone:
118
- ```typescript
119
- // Field: Jan 20 10:00 AM Sydney time
120
- { eventDate: '2025-01-20T10:00:00+11:00' }
121
- // Condition: on or after Jan 20 (interpreted as Jan 20 in Sydney)
122
- { dateOperator: 'onOrAfter', value: '2025-01-20' } // ✓ passes
123
- ```
124
-
125
- 2. **When condition value has timezone** (e.g., `'2025-01-20T00:00:00Z'`), it's used as-is:
126
- ```typescript
127
- // Condition: after midnight UTC specifically
128
- { dateOperator: 'after', value: '2025-01-20T00:00:00Z' }
129
- ```
130
-
131
- 3. **Fields without timezone** are treated as local time (UTC offset 0)
132
-
133
- ## Rule Types
134
-
135
- ### Basic Rule
136
-
137
- ```typescript
51
+ ### Field Operators
52
+
53
+ - `equals`
54
+ - `notEquals`
55
+ - `lessThan`
56
+ - `lessThanEquals`
57
+ - `greaterThan`
58
+ - `greaterThanEquals`
59
+ - `contains`
60
+ - `notContains`
61
+ - `in`
62
+ - `notIn`
63
+ - `matches`
64
+ - `notMatches`
65
+ - `between`
66
+ - `notBetween`
67
+ - `isEmpty`
68
+ - `notEmpty`
69
+ - `exists`
70
+ - `notExists`
71
+ - `startsWith`
72
+ - `endsWith`
73
+
74
+ ### Array Operators
75
+
76
+ - `all`
77
+ - `any`
78
+ - `none`
79
+ - `atLeast`
80
+ - `atMost`
81
+ - `exactly`
82
+ - `empty`
83
+ - `notEmpty`
84
+
85
+ ### Aggregate Operators
86
+
87
+ Used in `aggregate.mode`:
88
+
89
+ - `sum`
90
+ - `avg`
91
+
92
+ Supported comparison operators for aggregate rules: `equals`, `notEquals`, `lessThan`, `lessThanEquals`, `greaterThan`, `greaterThanEquals`, `between`, `notBetween`.
93
+
94
+ ### Date Operators
95
+
96
+ - `before`
97
+ - `after`
98
+ - `onOrBefore`
99
+ - `onOrAfter`
100
+ - `between`
101
+ - `notBetween`
102
+ - `dayIn`
103
+ - `dayNotIn`
104
+
105
+ ## Rule Shapes
106
+
107
+ ### Field Rule
108
+
109
+ ```ts
138
110
  {
139
111
  field: 'status',
140
- operator: Operator.equal,
112
+ operator: Operator.equals,
141
113
  value: 'active'
142
114
  }
143
115
  ```
144
116
 
145
- ### Logical Operators
117
+ ### Logical Rules
146
118
 
147
- ```typescript
148
- // All conditions must pass (AND)
119
+ ```ts
149
120
  {
150
121
  all: [
151
- { field: 'age', operator: Operator.greaterThanEqual, value: 18 },
152
- { field: 'hasLicense', operator: Operator.equal, value: true }
122
+ { field: 'age', operator: Operator.greaterThanEquals, value: 18 },
123
+ { field: 'hasLicense', operator: Operator.equals, value: true }
153
124
  ]
154
125
  }
155
126
 
156
- // At least one must pass (OR)
157
127
  {
158
128
  any: [
159
- { field: 'role', operator: Operator.equal, value: 'admin' },
160
- { field: 'isOwner', operator: Operator.equal, value: true }
129
+ { field: 'role', operator: Operator.equals, value: 'admin' },
130
+ { field: 'isOwner', operator: Operator.equals, value: true }
161
131
  ]
162
132
  }
163
133
  ```
164
134
 
165
- ### Conditional Logic (If-Then-Else)
135
+ ### Conditional Rule
166
136
 
167
- ```typescript
137
+ ```ts
168
138
  {
169
- if: { field: 'type', operator: Operator.equal, value: 'premium' },
139
+ if: { field: 'type', operator: Operator.equals, value: 'premium' },
170
140
  then: { field: 'discount', operator: Operator.greaterThan, value: 0 },
171
- else: { field: 'discount', operator: Operator.equal, value: 0 }
141
+ else: { field: 'discount', operator: Operator.equals, value: 0 }
172
142
  }
173
143
  ```
174
144
 
175
- ### Array Validation
145
+ ### Array Rule
176
146
 
177
- ```typescript
147
+ ```ts
178
148
  {
179
149
  field: 'orders',
180
150
  arrayOperator: ArrayOperator.all,
181
151
  condition: {
182
152
  field: 'total',
183
- operator: Operator.lessThan,
184
- value: 1000
153
+ operator: Operator.lessThanEquals,
154
+ path: '$.maxBudget'
185
155
  }
186
156
  }
187
157
  ```
188
158
 
189
- ### Date Validation
159
+ ### Aggregate Rule
160
+
161
+ Computes `sum` or `avg` of an array and compares the result to a value.
190
162
 
191
- ```typescript
163
+ ```ts
164
+ // Primitive numeric array
165
+ {
166
+ field: 'scores',
167
+ aggregate: { mode: 'avg' },
168
+ operator: Operator.greaterThanEquals,
169
+ value: 80
170
+ }
171
+
172
+ // Object array — aggregate.field selects the numeric property per element
173
+ {
174
+ field: 'orders',
175
+ aggregate: { mode: 'sum', field: 'total' },
176
+ operator: Operator.greaterThan,
177
+ value: 1000
178
+ }
179
+ ```
180
+
181
+ Empty-array semantics: `sum([]) = 0`, `avg([]) = null` (comparison fails).
182
+
183
+ ### Date Rule
184
+
185
+ ```ts
192
186
  {
193
187
  field: 'expiryDate',
194
188
  dateOperator: DateOperator.after,
195
- value: '2024-12-31'
189
+ value: '2026-01-01'
196
190
  }
197
191
  ```
198
192
 
199
- ## Advanced Features
193
+ ## Path Semantics
194
+
195
+ `path` lets a rule resolve its comparison value from somewhere other than `value`.
200
196
 
201
- ### Path-Based Value Resolution
197
+ ### Root Context Reference
202
198
 
203
- Compare fields against each other using paths:
199
+ In runtime validation, a plain path is resolved from the root context:
204
200
 
205
- ```typescript
201
+ ```ts
206
202
  {
207
203
  field: 'confirmPassword',
208
- operator: Operator.equal,
209
- path: 'password' // Compare against another field
204
+ operator: Operator.equals,
205
+ path: 'password'
210
206
  }
211
207
  ```
212
208
 
213
- ### Array Element Context
209
+ ### Current Array Element Reference
214
210
 
215
- Use `$.` prefix to reference the current array element:
211
+ Inside array conditions, `$.` means "read from the current element":
216
212
 
217
- ```typescript
213
+ ```ts
218
214
  {
219
- field: 'items',
215
+ field: 'orders',
220
216
  arrayOperator: ArrayOperator.all,
221
217
  condition: {
222
- field: 'price',
223
- operator: Operator.lessThan,
224
- path: '$.maxPrice' // Reference field on current array element
218
+ field: 'total',
219
+ operator: Operator.lessThanEquals,
220
+ path: '$.maxBudget'
225
221
  }
226
222
  }
227
223
  ```
228
224
 
229
- ### Custom Error Messages
225
+ ## Runtime Validation
230
226
 
231
- Every rule supports custom error messages:
227
+ `check()` evaluates a rule against data and returns:
232
228
 
233
- ```typescript
234
- {
235
- field: 'email',
236
- operator: Operator.match,
237
- value: /^[^@]+@[^@]+\.[^@]+$/,
238
- error: 'Please enter a valid email address'
239
- }
240
- ```
229
+ - `true` when the rule passes
230
+ - a string when the rule fails
241
231
 
242
- ## Complex Example
232
+ ```ts
233
+ import { ArrayOperator, check, Operator } from '@inixiative/json-rules';
243
234
 
244
- ```typescript
245
235
  const rule = {
246
236
  all: [
247
- // User must be active
248
- { field: 'status', operator: Operator.equal, value: 'active' },
249
-
250
- // Age requirement
251
- { field: 'age', operator: Operator.between, value: [18, 65] },
252
-
253
- // Must have at least one verified email
237
+ { field: 'status', operator: Operator.equals, value: 'active' },
254
238
  {
255
- field: 'emails',
256
- arrayOperator: ArrayOperator.any,
257
- condition: { field: 'verified', operator: Operator.equal, value: true }
239
+ field: 'orders',
240
+ arrayOperator: ArrayOperator.atLeast,
241
+ count: 2,
242
+ condition: { field: 'status', operator: Operator.equals, value: 'completed' },
258
243
  },
259
-
260
- // Conditional premium features
261
- {
262
- if: { field: 'subscription', operator: Operator.equal, value: 'premium' },
263
- then: {
264
- field: 'features',
265
- arrayOperator: ArrayOperator.all,
266
- condition: { field: 'enabled', operator: Operator.equal, value: true }
267
- }
268
- }
269
- ]
244
+ ],
270
245
  };
271
246
 
272
- const userData = {
247
+ check(rule, {
273
248
  status: 'active',
274
- age: 25,
275
- emails: [
276
- { address: 'user@example.com', verified: true },
277
- { address: 'alt@example.com', verified: false }
249
+ orders: [
250
+ { status: 'completed' },
251
+ { status: 'pending' },
252
+ { status: 'completed' },
278
253
  ],
279
- subscription: 'premium',
280
- features: [
281
- { name: 'advanced', enabled: true },
282
- { name: 'analytics', enabled: true }
283
- ]
284
- };
254
+ }); // true
255
+ ```
256
+
257
+ ### Custom Errors
258
+
259
+ Every rule can define its own error:
285
260
 
286
- const result = check(rule, userData); // returns true
261
+ ```ts
262
+ {
263
+ field: 'email',
264
+ operator: Operator.matches,
265
+ value: /^[^@]+@[^@]+\.[^@]+$/,
266
+ error: 'Please enter a valid email address'
267
+ }
287
268
  ```
288
269
 
289
- ## API Reference
270
+ ## Prisma Query Planning
290
271
 
291
- ### `check(condition: Condition, data: any, context?: any): boolean | string`
272
+ `toPrisma()` converts a rule into a Prisma query plan.
292
273
 
293
- The main validation function.
274
+ ```ts
275
+ import { Operator, toPrisma } from '@inixiative/json-rules';
294
276
 
295
- - **condition**: The rule to evaluate
296
- - **data**: The data to validate against
297
- - **context**: Optional context (defaults to data)
298
- - **Returns**: `true` if validation passes, error string if it fails
277
+ const plan = toPrisma({
278
+ field: 'status',
279
+ operator: Operator.equals,
280
+ value: 'active',
281
+ });
299
282
 
300
- ### Types
283
+ // plan.steps => [{ operation: 'where', where: { status: { equals: 'active' } } }]
284
+ ```
301
285
 
302
- ```typescript
303
- type Condition = Rule | ArrayRule | DateRule | All | Any | IfThenElse | boolean;
286
+ Aggregate relation filters (`sum`, `avg`) and count-based filters (`atLeast`, `atMost`, `exactly`) can produce multi-step plans. Use `executePrismaQueryPlan()` to resolve `groupBy` step references before passing the final `where` into Prisma.
287
+
288
+ ```ts
289
+ import {
290
+ ArrayOperator,
291
+ Operator,
292
+ executePrismaQueryPlan,
293
+ toPrisma,
294
+ } from '@inixiative/json-rules';
295
+
296
+ const plan = toPrisma(
297
+ {
298
+ field: 'posts',
299
+ arrayOperator: ArrayOperator.atLeast,
300
+ count: 3,
301
+ condition: {
302
+ field: 'published',
303
+ operator: Operator.equals,
304
+ value: true,
305
+ },
306
+ },
307
+ { map, model: 'User' },
308
+ );
304
309
 
305
- type Rule = {
306
- field: string;
307
- operator: Operator;
308
- value?: any;
309
- path?: string;
310
- error?: string;
311
- };
310
+ const where = await executePrismaQueryPlan(plan, { post: prisma.post });
311
+ await prisma.user.findMany({ where });
312
+ ```
312
313
 
313
- type ArrayRule = {
314
- field: string;
315
- arrayOperator: ArrayOperator;
316
- condition?: Condition;
317
- count?: number;
318
- error?: string;
319
- };
314
+ Aggregate rules on relation lists work the same way:
315
+
316
+ ```ts
317
+ const plan = toPrisma(
318
+ {
319
+ field: 'orders',
320
+ aggregate: { mode: 'sum', field: 'total' },
321
+ operator: Operator.greaterThan,
322
+ value: 1000,
323
+ },
324
+ { map, model: 'User' },
325
+ );
326
+
327
+ const where = await executePrismaQueryPlan(plan, { order: prisma.order });
328
+ await prisma.user.findMany({ where }); // users whose orders sum to more than 1000
329
+ ```
320
330
 
321
- type DateRule = {
322
- field: string;
323
- dateOperator: DateOperator;
324
- value?: any;
325
- path?: string;
326
- error?: string;
327
- };
331
+ ## PostgreSQL SQL Generation
332
+
333
+ `toSql()` converts a rule into a parameterized PostgreSQL `WHERE` clause.
334
+
335
+ ```ts
336
+ import { Operator, toSql } from '@inixiative/json-rules';
337
+
338
+ const result = toSql({
339
+ field: 'status',
340
+ operator: Operator.equals,
341
+ value: 'active',
342
+ });
343
+
344
+ // {
345
+ // sql: '"status" = $1',
346
+ // params: ['active'],
347
+ // joins: []
348
+ // }
349
+ ```
350
+
351
+ With a field map and model, `toSql()` can generate `LEFT JOIN`s for relation traversal:
352
+
353
+ ```ts
354
+ const result = toSql(
355
+ { field: 'author.email', operator: Operator.equals, value: 'a@b.com' },
356
+ { map, model: 'Post', alias: 't0' },
357
+ );
358
+
359
+ // result.sql => '"t1"."email" = $1'
360
+ // result.joins => ['LEFT JOIN "User" AS "t1" ON "t1"."id" = "t0"."authorId"']
328
361
  ```
329
362
 
363
+ ## Backend Support Matrix
364
+
365
+ Not every backend supports every rule shape.
366
+
367
+ | Capability | `check()` | `toPrisma()` | `toSql()` |
368
+ | --- | --- | --- | --- |
369
+ | Field operators | Yes | Most | Yes |
370
+ | `matches` / `notMatches` | Yes | No | Yes |
371
+ | Logical operators | Yes | Yes | Yes |
372
+ | Array `all` / `any` / `none` | Yes | Yes | No |
373
+ | Array `atLeast` / `atMost` / `exactly` | Yes | Yes, with `map` + `model` | No |
374
+ | Array `empty` / `notEmpty` | Yes | Yes | Yes |
375
+ | Aggregate `sum` / `avg` — primitive or object array | Yes | No | Yes |
376
+ | Aggregate `sum` / `avg` — relation list | Yes | Yes, with `map` + `model` | No |
377
+ | Date comparisons | Yes | Most | Yes |
378
+ | `dayIn` / `dayNotIn` | Yes | No | Yes |
379
+ | `path: '$.field'` current-element / same-row refs | Yes | No | Yes |
380
+
381
+ ### Prisma Limitations
382
+
383
+ - `matches` and `notMatches` are not supported by Prisma output
384
+ - `dayIn` and `dayNotIn` are not supported by Prisma output
385
+ - `path: '$.field'` column-to-column comparisons are not supported by Prisma `WHERE`
386
+ - count-based and aggregate relation operators require `{ map, model }`
387
+ - aggregate rules with `notBetween` are not supported by Prisma output
388
+ - aggregate rules on JSON/native stored arrays are not supported by Prisma — use `toSql()` or `check()` for those
389
+
390
+ ### SQL Limitations
391
+
392
+ - complex array element operators are not supported in SQL output:
393
+ - `all`
394
+ - `any`
395
+ - `none`
396
+ - `atLeast`
397
+ - `atMost`
398
+ - `exactly`
399
+ - `toSql()` generates `WHERE` fragments and `LEFT JOIN`s, not complete queries
400
+
401
+ ## TypeScript Types
402
+
403
+ The public rule types are generic over comparison payloads:
404
+
405
+ ```ts
406
+ type Condition<TRuleValue = RuleValue, TDateValue = DateRuleValue> =
407
+ | Rule<TRuleValue>
408
+ | AggregateRule
409
+ | ArrayRule<TRuleValue, TDateValue>
410
+ | DateRule<TDateValue>
411
+ | All<TRuleValue, TDateValue>
412
+ | Any<TRuleValue, TDateValue>
413
+ | IfThenElse<TRuleValue, TDateValue>
414
+ | boolean;
415
+ ```
416
+
417
+ Useful exports:
418
+
419
+ - `check`
420
+ - `toPrisma`
421
+ - `executePrismaQueryPlan`
422
+ - `toSql`
423
+ - `validateRule`
424
+ - `assertValidRule`
425
+ - `Operator`
426
+ - `ArrayOperator`
427
+ - `DateOperator`
428
+ - `Condition`
429
+ - `StrictCondition`
430
+ - `Rule`
431
+ - `AggregateRule`
432
+ - `AggregateMode`
433
+ - `ArrayRule`
434
+ - `DateRule`
435
+
330
436
  ## Error Handling
331
437
 
332
- The engine throws errors for:
333
- - Invalid array fields when using array operators
334
- - Missing required parameters (e.g., count for atLeast)
335
- - Invalid dates in date comparisons
336
- - Primitive arrays with array operators (use `contains` or `in` instead)
438
+ The library throws when a rule is structurally invalid, for example:
439
+
440
+ - array operators used against non-arrays
441
+ - missing `count` for count-based array rules
442
+ - invalid date values
443
+ - unsupported backend translations
444
+
445
+ It returns string errors only from runtime `check()`.
446
+
447
+ If rules come from JSON, a database, an API, or an editor, validate them first:
448
+
449
+ ```ts
450
+ import { assertValidRule, validateRule } from '@inixiative/json-rules';
451
+
452
+ const result = validateRule(rule, { target: 'check' });
453
+ if (!result.ok) {
454
+ console.error(result.errors);
455
+ }
456
+
457
+ assertValidRule(rule, { target: 'toPrisma' });
458
+ ```
459
+
460
+ ## Examples
461
+
462
+ See [`examples/basic-validation.ts`](./examples/basic-validation.ts), [`examples/array-operations.ts`](./examples/array-operations.ts), [`examples/aggregate-rules.ts`](./examples/aggregate-rules.ts), [`examples/date-operations.ts`](./examples/date-operations.ts), and [`examples/advanced-features.ts`](./examples/advanced-features.ts).
337
463
 
338
464
  ## License
339
465
 
340
- MIT
466
+ MIT