@inixiative/json-rules 1.1.1 → 1.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 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,391 @@ 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
+ - date comparisons with timezone-aware runtime evaluation
44
+ - relative value references via `path`
45
+ - custom error messages on every rule
46
+ - compilation to Prisma and PostgreSQL for supported subsets
47
+
55
48
  ## Operators
56
49
 
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
50
+ ### Field Operators
51
+
52
+ - `equals`
53
+ - `notEquals`
54
+ - `lessThan`
55
+ - `lessThanEquals`
56
+ - `greaterThan`
57
+ - `greaterThanEquals`
58
+ - `contains`
59
+ - `notContains`
60
+ - `in`
61
+ - `notIn`
62
+ - `matches`
63
+ - `notMatches`
64
+ - `between`
65
+ - `notBetween`
66
+ - `isEmpty`
67
+ - `notEmpty`
68
+ - `exists`
69
+ - `notExists`
70
+ - `startsWith`
71
+ - `endsWith`
72
+
73
+ ### Array Operators
74
+
75
+ - `all`
76
+ - `any`
77
+ - `none`
78
+ - `atLeast`
79
+ - `atMost`
80
+ - `exactly`
81
+ - `empty`
82
+ - `notEmpty`
83
+
84
+ ### Date Operators
85
+
86
+ - `before`
87
+ - `after`
88
+ - `onOrBefore`
89
+ - `onOrAfter`
90
+ - `between`
91
+ - `notBetween`
92
+ - `dayIn`
93
+ - `dayNotIn`
94
+
95
+ ## Rule Shapes
96
+
97
+ ### Field Rule
98
+
99
+ ```ts
138
100
  {
139
101
  field: 'status',
140
- operator: Operator.equal,
102
+ operator: Operator.equals,
141
103
  value: 'active'
142
104
  }
143
105
  ```
144
106
 
145
- ### Logical Operators
107
+ ### Logical Rules
146
108
 
147
- ```typescript
148
- // All conditions must pass (AND)
109
+ ```ts
149
110
  {
150
111
  all: [
151
- { field: 'age', operator: Operator.greaterThanEqual, value: 18 },
152
- { field: 'hasLicense', operator: Operator.equal, value: true }
112
+ { field: 'age', operator: Operator.greaterThanEquals, value: 18 },
113
+ { field: 'hasLicense', operator: Operator.equals, value: true }
153
114
  ]
154
115
  }
155
116
 
156
- // At least one must pass (OR)
157
117
  {
158
118
  any: [
159
- { field: 'role', operator: Operator.equal, value: 'admin' },
160
- { field: 'isOwner', operator: Operator.equal, value: true }
119
+ { field: 'role', operator: Operator.equals, value: 'admin' },
120
+ { field: 'isOwner', operator: Operator.equals, value: true }
161
121
  ]
162
122
  }
163
123
  ```
164
124
 
165
- ### Conditional Logic (If-Then-Else)
125
+ ### Conditional Rule
166
126
 
167
- ```typescript
127
+ ```ts
168
128
  {
169
- if: { field: 'type', operator: Operator.equal, value: 'premium' },
129
+ if: { field: 'type', operator: Operator.equals, value: 'premium' },
170
130
  then: { field: 'discount', operator: Operator.greaterThan, value: 0 },
171
- else: { field: 'discount', operator: Operator.equal, value: 0 }
131
+ else: { field: 'discount', operator: Operator.equals, value: 0 }
172
132
  }
173
133
  ```
174
134
 
175
- ### Array Validation
135
+ ### Array Rule
176
136
 
177
- ```typescript
137
+ ```ts
178
138
  {
179
139
  field: 'orders',
180
140
  arrayOperator: ArrayOperator.all,
181
141
  condition: {
182
142
  field: 'total',
183
- operator: Operator.lessThan,
184
- value: 1000
143
+ operator: Operator.lessThanEquals,
144
+ path: '$.maxBudget'
185
145
  }
186
146
  }
187
147
  ```
188
148
 
189
- ### Date Validation
149
+ ### Date Rule
190
150
 
191
- ```typescript
151
+ ```ts
192
152
  {
193
153
  field: 'expiryDate',
194
154
  dateOperator: DateOperator.after,
195
- value: '2024-12-31'
155
+ value: '2026-01-01'
196
156
  }
197
157
  ```
198
158
 
199
- ## Advanced Features
159
+ ## Path Semantics
200
160
 
201
- ### Path-Based Value Resolution
161
+ `path` lets a rule resolve its comparison value from somewhere other than `value`.
202
162
 
203
- Compare fields against each other using paths:
163
+ ### Root Context Reference
204
164
 
205
- ```typescript
165
+ In runtime validation, a plain path is resolved from the root context:
166
+
167
+ ```ts
206
168
  {
207
169
  field: 'confirmPassword',
208
- operator: Operator.equal,
209
- path: 'password' // Compare against another field
170
+ operator: Operator.equals,
171
+ path: 'password'
210
172
  }
211
173
  ```
212
174
 
213
- ### Array Element Context
175
+ ### Current Array Element Reference
214
176
 
215
- Use `$.` prefix to reference the current array element:
177
+ Inside array conditions, `$.` means "read from the current element":
216
178
 
217
- ```typescript
179
+ ```ts
218
180
  {
219
- field: 'items',
181
+ field: 'orders',
220
182
  arrayOperator: ArrayOperator.all,
221
183
  condition: {
222
- field: 'price',
223
- operator: Operator.lessThan,
224
- path: '$.maxPrice' // Reference field on current array element
184
+ field: 'total',
185
+ operator: Operator.lessThanEquals,
186
+ path: '$.maxBudget'
225
187
  }
226
188
  }
227
189
  ```
228
190
 
229
- ### Custom Error Messages
191
+ ## Runtime Validation
230
192
 
231
- Every rule supports custom error messages:
193
+ `check()` evaluates a rule against data and returns:
232
194
 
233
- ```typescript
234
- {
235
- field: 'email',
236
- operator: Operator.match,
237
- value: /^[^@]+@[^@]+\.[^@]+$/,
238
- error: 'Please enter a valid email address'
239
- }
240
- ```
195
+ - `true` when the rule passes
196
+ - a string when the rule fails
241
197
 
242
- ## Complex Example
198
+ ```ts
199
+ import { ArrayOperator, check, Operator } from '@inixiative/json-rules';
243
200
 
244
- ```typescript
245
201
  const rule = {
246
202
  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
203
+ { field: 'status', operator: Operator.equals, value: 'active' },
254
204
  {
255
- field: 'emails',
256
- arrayOperator: ArrayOperator.any,
257
- condition: { field: 'verified', operator: Operator.equal, value: true }
205
+ field: 'orders',
206
+ arrayOperator: ArrayOperator.atLeast,
207
+ count: 2,
208
+ condition: { field: 'status', operator: Operator.equals, value: 'completed' },
258
209
  },
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
- ]
210
+ ],
270
211
  };
271
212
 
272
- const userData = {
213
+ check(rule, {
273
214
  status: 'active',
274
- age: 25,
275
- emails: [
276
- { address: 'user@example.com', verified: true },
277
- { address: 'alt@example.com', verified: false }
215
+ orders: [
216
+ { status: 'completed' },
217
+ { status: 'pending' },
218
+ { status: 'completed' },
278
219
  ],
279
- subscription: 'premium',
280
- features: [
281
- { name: 'advanced', enabled: true },
282
- { name: 'analytics', enabled: true }
283
- ]
284
- };
220
+ }); // true
221
+ ```
222
+
223
+ ### Custom Errors
285
224
 
286
- const result = check(rule, userData); // returns true
225
+ Every rule can define its own error:
226
+
227
+ ```ts
228
+ {
229
+ field: 'email',
230
+ operator: Operator.matches,
231
+ value: /^[^@]+@[^@]+\.[^@]+$/,
232
+ error: 'Please enter a valid email address'
233
+ }
287
234
  ```
288
235
 
289
- ## API Reference
236
+ ## Prisma Query Planning
290
237
 
291
- ### `check(condition: Condition, data: any, context?: any): boolean | string`
238
+ `toPrisma()` converts a rule into a Prisma query plan.
292
239
 
293
- The main validation function.
240
+ ```ts
241
+ import { Operator, toPrisma } from '@inixiative/json-rules';
294
242
 
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
243
+ const plan = toPrisma({
244
+ field: 'status',
245
+ operator: Operator.equals,
246
+ value: 'active',
247
+ });
299
248
 
300
- ### Types
249
+ // plan.steps => [{ operation: 'where', where: { status: { equals: 'active' } } }]
250
+ ```
301
251
 
302
- ```typescript
303
- type Condition = Rule | ArrayRule | DateRule | All | Any | IfThenElse | boolean;
252
+ Count-based relation filters such as `atLeast`, `atMost`, and `exactly` can produce multi-step plans. Use `executePrismaQueryPlan()` to resolve `groupBy` step references before passing the final `where` into Prisma.
253
+
254
+ ```ts
255
+ import {
256
+ ArrayOperator,
257
+ Operator,
258
+ executePrismaQueryPlan,
259
+ toPrisma,
260
+ } from '@inixiative/json-rules';
261
+
262
+ const plan = toPrisma(
263
+ {
264
+ field: 'posts',
265
+ arrayOperator: ArrayOperator.atLeast,
266
+ count: 3,
267
+ condition: {
268
+ field: 'published',
269
+ operator: Operator.equals,
270
+ value: true,
271
+ },
272
+ },
273
+ { map, model: 'User' },
274
+ );
304
275
 
305
- type Rule = {
306
- field: string;
307
- operator: Operator;
308
- value?: any;
309
- path?: string;
310
- error?: string;
311
- };
276
+ const where = await executePrismaQueryPlan(plan, { post: prisma.post });
277
+ await prisma.user.findMany({ where });
278
+ ```
312
279
 
313
- type ArrayRule = {
314
- field: string;
315
- arrayOperator: ArrayOperator;
316
- condition?: Condition;
317
- count?: number;
318
- error?: string;
319
- };
280
+ ## PostgreSQL SQL Generation
320
281
 
321
- type DateRule = {
322
- field: string;
323
- dateOperator: DateOperator;
324
- value?: any;
325
- path?: string;
326
- error?: string;
327
- };
282
+ `toSql()` converts a rule into a parameterized PostgreSQL `WHERE` clause.
283
+
284
+ ```ts
285
+ import { Operator, toSql } from '@inixiative/json-rules';
286
+
287
+ const result = toSql({
288
+ field: 'status',
289
+ operator: Operator.equals,
290
+ value: 'active',
291
+ });
292
+
293
+ // {
294
+ // sql: '"status" = $1',
295
+ // params: ['active'],
296
+ // joins: []
297
+ // }
298
+ ```
299
+
300
+ With a field map and model, `toSql()` can generate `LEFT JOIN`s for relation traversal:
301
+
302
+ ```ts
303
+ const result = toSql(
304
+ { field: 'author.email', operator: Operator.equals, value: 'a@b.com' },
305
+ { map, model: 'Post', alias: 't0' },
306
+ );
307
+
308
+ // result.sql => '"t1"."email" = $1'
309
+ // result.joins => ['LEFT JOIN "User" AS "t1" ON "t1"."id" = "t0"."authorId"']
310
+ ```
311
+
312
+ ## Backend Support Matrix
313
+
314
+ Not every backend supports every rule shape.
315
+
316
+ | Capability | `check()` | `toPrisma()` | `toSql()` |
317
+ | --- | --- | --- | --- |
318
+ | Field operators | Yes | Most | Yes |
319
+ | `matches` / `notMatches` | Yes | No | Yes |
320
+ | Logical operators | Yes | Yes | Yes |
321
+ | Array `all` / `any` / `none` | Yes | Yes | No |
322
+ | Array `atLeast` / `atMost` / `exactly` | Yes | Yes, with `map` + `model` | No |
323
+ | Array `empty` / `notEmpty` | Yes | Yes | Yes |
324
+ | Date comparisons | Yes | Most | Yes |
325
+ | `dayIn` / `dayNotIn` | Yes | No | Yes |
326
+ | `path: '$.field'` current-element / same-row refs | Yes | No | Yes |
327
+
328
+ ### Prisma Limitations
329
+
330
+ - `matches` and `notMatches` are not supported by Prisma output
331
+ - `dayIn` and `dayNotIn` are not supported by Prisma output
332
+ - `path: '$.field'` column-to-column comparisons are not supported by Prisma `WHERE`
333
+ - count-based relation operators require `{ map, model }`
334
+
335
+ ### SQL Limitations
336
+
337
+ - complex array element operators are not supported in SQL output:
338
+ - `all`
339
+ - `any`
340
+ - `none`
341
+ - `atLeast`
342
+ - `atMost`
343
+ - `exactly`
344
+ - `toSql()` generates `WHERE` fragments and `LEFT JOIN`s, not complete queries
345
+
346
+ ## TypeScript Types
347
+
348
+ The public rule types are generic over comparison payloads:
349
+
350
+ ```ts
351
+ type Condition<TRuleValue = RuleValue, TDateValue = DateRuleValue> =
352
+ | Rule<TRuleValue>
353
+ | ArrayRule<TRuleValue, TDateValue>
354
+ | DateRule<TDateValue>
355
+ | All<TRuleValue, TDateValue>
356
+ | Any<TRuleValue, TDateValue>
357
+ | IfThenElse<TRuleValue, TDateValue>
358
+ | boolean;
328
359
  ```
329
360
 
361
+ Useful exports:
362
+
363
+ - `check`
364
+ - `toPrisma`
365
+ - `executePrismaQueryPlan`
366
+ - `toSql`
367
+ - `validateRule`
368
+ - `assertValidRule`
369
+ - `Operator`
370
+ - `ArrayOperator`
371
+ - `DateOperator`
372
+ - `Condition`
373
+ - `StrictCondition`
374
+ - `Rule`
375
+ - `ArrayRule`
376
+ - `DateRule`
377
+
330
378
  ## Error Handling
331
379
 
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)
380
+ The library throws when a rule is structurally invalid, for example:
381
+
382
+ - array operators used against non-arrays
383
+ - missing `count` for count-based array rules
384
+ - invalid date values
385
+ - unsupported backend translations
386
+
387
+ It returns string errors only from runtime `check()`.
388
+
389
+ If rules come from JSON, a database, an API, or an editor, validate them first:
390
+
391
+ ```ts
392
+ import { assertValidRule, validateRule } from '@inixiative/json-rules';
393
+
394
+ const result = validateRule(rule, { target: 'check' });
395
+ if (!result.ok) {
396
+ console.error(result.errors);
397
+ }
398
+
399
+ assertValidRule(rule, { target: 'toPrisma' });
400
+ ```
401
+
402
+ ## Examples
403
+
404
+ See [`examples/basic-validation.ts`](./examples/basic-validation.ts), [`examples/array-operations.ts`](./examples/array-operations.ts), [`examples/date-operations.ts`](./examples/date-operations.ts), and [`examples/advanced-features.ts`](./examples/advanced-features.ts).
337
405
 
338
406
  ## License
339
407
 
340
- MIT
408
+ MIT