wizard-ql 1.0.0 → 2.0.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
@@ -258,7 +258,7 @@ Values can be direct values (string, number, boolean) or regex expressions
258
258
  "deny" will deny the values/patterns and allow all others
259
259
 
260
260
  ### `types`
261
- A record mapping field names to (`boolean`, `string`, `number`). The value in the record can either be a single allowed type or an array of allowed types. Only operators that can function on that type can be used for that field. By default, fields will be treated as being able to be any of the three types.
261
+ A record mapping field names to (`boolean`, `string`, `number`, `date`). The value in the record can either be a single allowed type or an array of allowed types. Only operators that can function on that type can be used for that field. By default, fields will be treated as being able to be any of the three types.
262
262
 
263
263
  > Example:
264
264
  > ```js
@@ -270,6 +270,15 @@ A record mapping field names to (`boolean`, `string`, `number`). The value in th
270
270
  > })
271
271
  > ```
272
272
 
273
+ > [!NOTE]
274
+ > A value will only be attempted to be parsed as a date if `'date'` is included in the field's type record.
275
+ > If the value is a number, it will be parsed as number of milliseconds. Any non-number character will cause the value to be parsed as a string
276
+
277
+ The type coercion priority chain is as follows:
278
+ `boolean` -> `date` -> `number` -> `string`
279
+ > [!WARNING]
280
+ > This can cause issues for numbers with leading zeros. If this is a potential problem, make sure to exclude `number` from the types and include `string`
281
+
273
282
  ### Regarding constraints: `validated` property
274
283
  When a field has matched either a key in `types` or a field in `restricted`, the `validated` property on the parsed condition will be true. This is due to a limitation with TypeScript's type checking.
275
284
 
@@ -306,10 +315,10 @@ Type/constraint checks will be case-insensitive on the field name
306
315
  ### `disallowUnvalidated`
307
316
  Fields that are not present in the type or restriction record will considered invalid fields
308
317
 
309
- ### `interpretDates`
310
- If a field is set as a number, dates-like strings will still be interpreted and converted to their millisecond representations.
318
+ ### `dateInterpreter`
319
+ A callback that determines how WizardQL interprets dates. Wizard will attempt to parse a value as a date if `'date'` is supplied in its type record
311
320
 
312
- `true` can be passed to enable this feature, using `new Date()` to parse dates or a custom callback can be passed.
321
+ By default, this is simply `(v) => new Date(v)`
313
322
 
314
323
  ## Stringification
315
324
  Parsed expressions can be converted back into strings using the `stringify` function. The stringify function comes with its own slew of options as its second parameter
package/dist/index.js CHANGED
@@ -60,15 +60,16 @@ var OPERATION_PURPOSE_DICTIONARY = {
60
60
  var COMPARISON_TYPE_DICTIONARY = {
61
61
  EQUAL: "primitive",
62
62
  NOTEQUAL: "primitive",
63
- GEQ: "number",
64
- GREATER: "number",
65
- LEQ: "number",
66
- LESS: "number",
63
+ GEQ: "numeric",
64
+ GREATER: "numeric",
65
+ LEQ: "numeric",
66
+ LESS: "numeric",
67
67
  IN: "array",
68
68
  NOTIN: "array",
69
69
  MATCH: "string",
70
70
  NOTMATCH: "string"
71
71
  };
72
+ var TYPE_PRIORITY = ["boolean", "date", "number", "string"];
72
73
  // src/regex.ts
73
74
  var ESCAPE_REGEX = "(?<=(?<!\\\\)(?:\\\\\\\\)*)";
74
75
  var QUOTES = ["'", '"', "`"];
@@ -194,10 +195,6 @@ function _tokenize(expression, pattern) {
194
195
  function tokenize(expression) {
195
196
  return _tokenize(expression, TOKEN_REGEX);
196
197
  }
197
- function parseValue(token, isQuoted) {
198
- const number = Number(token);
199
- return isQuoted || isNaN(number) ? token === "true" ? true : token === "false" ? false : token : number;
200
- }
201
198
  function processToken(token) {
202
199
  const unquoted = token.match(QUOTE_EDGE_REGEX)?.groups?.quotecontent;
203
200
  const escaped = unquoted ?? token;
@@ -273,6 +270,53 @@ function complementExpression(expression) {
273
270
  break;
274
271
  }
275
272
  }
273
+ function coerceType(value, fieldTypes, operatorTypes, dateInterpreter) {
274
+ const { unescaped } = processToken(value);
275
+ let operatorHits = 0;
276
+ let fieldHits = 0;
277
+ for (const type of TYPE_PRIORITY) {
278
+ const operatorHit = operatorTypes.includes(type);
279
+ const fieldHit = fieldTypes.includes(type);
280
+ if (operatorHit)
281
+ ++operatorHits;
282
+ if (fieldHit)
283
+ ++fieldHits;
284
+ if (!operatorHit || !fieldHit)
285
+ continue;
286
+ switch (type) {
287
+ case "boolean":
288
+ if (value === "true")
289
+ return true;
290
+ else if (value === "false")
291
+ return false;
292
+ break;
293
+ case "number": {
294
+ const num = Number(value);
295
+ if (isNaN(num))
296
+ break;
297
+ return num;
298
+ }
299
+ case "string":
300
+ return unescaped;
301
+ case "date": {
302
+ const num = Number(value);
303
+ const date = dateInterpreter(isNaN(num) ? unescaped : num);
304
+ if (isNaN(+date))
305
+ break;
306
+ return date;
307
+ }
308
+ }
309
+ if (operatorTypes.includes(type))
310
+ --operatorHits;
311
+ if (fieldTypes.includes(type))
312
+ --fieldHits;
313
+ }
314
+ if (!operatorHits)
315
+ throw new Error("operator");
316
+ if (!fieldHits)
317
+ throw new Error("field");
318
+ throw new Error("operator");
319
+ }
276
320
  function validateCondition(condition, constraints, valueIsImplicit, ctx) {
277
321
  let validated = false;
278
322
  const field = constraints?.caseInsensitive ? [...Object.keys(constraints.types ?? {}), ...Object.keys(constraints.restricted ?? {})].find((k) => k.toLowerCase() === condition.field.toLowerCase()) ?? condition.field : condition.field;
@@ -282,26 +326,6 @@ function validateCondition(condition, constraints, valueIsImplicit, ctx) {
282
326
  throw new ConstraintError(`Unknown field "${condition.field}"`, ctx?.startToken, ctx?.startIndex, ctx?.endToken, ctx?.endIndex);
283
327
  const operationType = COMPARISON_TYPE_DICTIONARY[condition.operation];
284
328
  const types = type && (Array.isArray(type) ? type : [type]);
285
- if (!valueIsImplicit && constraints?.interpretDates && (!types || types.includes("number")) && ["primitive", "number"].includes(operationType)) {
286
- const validator = constraints.interpretDates === true ? (v) => +new Date(v) : constraints.interpretDates;
287
- if (Array.isArray(condition.value)) {
288
- for (let v = 0;v < condition.value.length; ++v) {
289
- const val = condition.value[v];
290
- if (typeof val === "string") {
291
- const numberized = validator(val);
292
- if (!isNaN(numberized))
293
- condition.value[v] = numberized;
294
- }
295
- }
296
- } else {
297
- const val = condition.value;
298
- if (typeof val === "string") {
299
- const numberized = validator(val);
300
- if (!isNaN(numberized))
301
- condition.value = numberized;
302
- }
303
- }
304
- }
305
329
  const values = Array.isArray(condition.value) ? condition.value : [condition.value];
306
330
  if (restriction === true)
307
331
  throw new ConstraintError(`Field "${condition.field}" is restricted`, ctx?.startToken, ctx?.startIndex, ctx?.endToken, ctx?.endIndex);
@@ -328,50 +352,50 @@ function validateCondition(condition, constraints, valueIsImplicit, ctx) {
328
352
  }
329
353
  validated = true;
330
354
  }
331
- if (!valueIsImplicit && operationType === "string" && (typeof condition.value === "number" || typeof condition.value === "boolean"))
332
- condition.value = condition.value.toString();
333
- let operationAllowed;
334
- switch (operationType) {
335
- case "primitive":
336
- operationAllowed = !Array.isArray(condition.value);
337
- break;
338
- case "string":
339
- operationAllowed = typeof condition.value === "string";
340
- break;
341
- case "number":
342
- operationAllowed = typeof condition.value === "number";
343
- break;
344
- case "array":
345
- operationAllowed = Array.isArray(condition.value);
346
- break;
347
- }
348
- if (!operationAllowed)
349
- throw new ConstraintError(`Value "${condition.value.toString()}" not allowed for operation "${condition.operation}" which only allows for "${operationType}" type`, ctx?.startToken, ctx?.startIndex, ctx?.endToken, ctx?.endIndex);
350
- if (types) {
351
- const meets = values.every((v, i) => {
352
- if (!valueIsImplicit && (typeof v === "number" && !types.includes("number") || typeof v === "boolean" && !types.includes("boolean")) && types.includes("string")) {
353
- v = v.toString();
354
- values[i] = v;
355
- if (!Array.isArray(condition.value))
356
- condition.value = v;
355
+ if (operationType === "array" && !Array.isArray(condition.value))
356
+ throw new ConstraintError(`Value "${condition.value.toString()}" is not permitted for "${condition.operation}" which expects an array`, ctx?.startToken, ctx?.startIndex, ctx?.endToken, ctx?.endIndex);
357
+ else if (operationType !== "array" && Array.isArray(condition.value))
358
+ throw new ConstraintError(`Value "${condition.value.toString()}" is not permitted for "${condition.operation}" which expects a non-array value`, ctx?.startToken, ctx?.startIndex, ctx?.endToken, ctx?.endIndex);
359
+ const dateInterpreter = constraints?.dateInterpreter ?? ((v) => new Date(v));
360
+ for (let i = 0;i < values.length; ++i) {
361
+ const v = values[i];
362
+ let opTypes;
363
+ if (valueIsImplicit)
364
+ opTypes = ["boolean"];
365
+ else {
366
+ switch (operationType) {
367
+ case "primitive":
368
+ case "array":
369
+ opTypes = ["boolean", "number", "string"];
370
+ if (types?.includes("date"))
371
+ opTypes.push("date");
372
+ break;
373
+ case "numeric":
374
+ opTypes = ["number"];
375
+ if (types?.includes("date"))
376
+ opTypes.push("date");
377
+ break;
378
+ case "string":
379
+ opTypes = ["string"];
380
+ break;
357
381
  }
358
- return types.some((t) => {
359
- switch (t) {
360
- case "boolean":
361
- return typeof v === "boolean";
362
- case "number":
363
- return typeof v === "number";
364
- case "string":
365
- return typeof v === "string";
366
- default:
367
- return false;
368
- }
369
- });
370
- });
371
- if (!meets)
372
- throw new ConstraintError(`Value "${condition.value.toString()}" includes a type not permitted for field "${condition.field}". Allowed types: ${types.join(", ")}`, ctx?.startToken, ctx?.startIndex, ctx?.endToken, ctx?.endIndex);
373
- validated = true;
382
+ }
383
+ try {
384
+ const value = coerceType(v, types ?? opTypes, opTypes, dateInterpreter);
385
+ values[i] = value;
386
+ if (!Array.isArray(condition.value))
387
+ condition.value = value;
388
+ } catch (err) {
389
+ switch (err.message) {
390
+ case "operator":
391
+ throw new ConstraintError(`Value "${condition.value.toString()}" not allowed for operation "${condition.operation}" which only allows for "${operationType}" type`, ctx?.startToken, ctx?.startIndex, ctx?.endToken, ctx?.endIndex);
392
+ case "field":
393
+ throw new ConstraintError(`Value "${condition.value.toString()}" includes a type not permitted for field "${condition.field}". Allowed types: ${(types ?? opTypes).join(", ")}`, ctx?.startToken, ctx?.startIndex, ctx?.endToken, ctx?.endIndex);
394
+ }
395
+ }
374
396
  }
397
+ if (types)
398
+ validated = true;
375
399
  const edit = condition;
376
400
  edit.field = field;
377
401
  edit.validated = validated;
@@ -424,7 +448,7 @@ function _parse(tokens, _offset, constraints) {
424
448
  type: "condition",
425
449
  field: field.content,
426
450
  operation: "EQUAL",
427
- value: true
451
+ value: "true"
428
452
  }, constraints, true, baseCtx));
429
453
  inConjunction = false;
430
454
  expectingExpression = false;
@@ -436,10 +460,6 @@ function _parse(tokens, _offset, constraints) {
436
460
  }
437
461
  for (let t = 0;t < tokens.length; ++t) {
438
462
  const token = tokens[t];
439
- const {
440
- unquoted,
441
- unescaped
442
- } = processToken(token.content);
443
463
  if (token.content === ")")
444
464
  throw new ParseError("Unexpected closing parenthesis", token, _offset + t);
445
465
  if (["]", "}"].includes(token.content))
@@ -588,7 +608,7 @@ function _parse(tokens, _offset, constraints) {
588
608
  content: "EQUAL"
589
609
  };
590
610
  value = {
591
- content: false,
611
+ content: "false",
592
612
  implicit: true
593
613
  };
594
614
  resolveCondition({
@@ -599,7 +619,7 @@ function _parse(tokens, _offset, constraints) {
599
619
  });
600
620
  } else {
601
621
  field = {
602
- content: unescaped,
622
+ content: processToken(token.content).unescaped,
603
623
  token,
604
624
  index: _offset + t
605
625
  };
@@ -611,15 +631,14 @@ function _parse(tokens, _offset, constraints) {
611
631
  let resolveEntry = function(subtoken, subindex) {
612
632
  if (workingEntry) {
613
633
  const {
614
- unquoted: unquotedWorkingEntry,
615
- unescaped: unescapedWorkingEntry
634
+ unquoted: unquotedWorkingEntry
616
635
  } = processToken(workingEntry);
617
636
  const subquotes = workingEntry.match(QUOTE_REGEX);
618
637
  if (subquotes && (subquotes.index !== 0 || subquotes[0].length !== workingEntry.length))
619
638
  throw new ParseError("Quotes must surround entire values in arrays", firstEntryToken ?? subtoken, firstEntryTokenIndex ?? subindex, lastEntryToken ?? subtoken, lastEntryTokenIndex ?? subindex);
620
639
  if (!unquotedWorkingEntry && (token.content === "[" && new RegExp(`${ESCAPE_REGEX}(?:\\[|\\])`).test(workingEntry) || token.content === "{" && new RegExp(`${ESCAPE_REGEX}(?:\\{|\\})`).test(workingEntry)))
621
640
  throw new ParseError("Unescaped bracket in an array value", firstEntryToken ?? subtoken, firstEntryTokenIndex ?? subindex, lastEntryToken ?? subtoken, lastEntryTokenIndex ?? subindex);
622
- arr.push(parseValue(unescapedWorkingEntry, unquotedWorkingEntry !== undefined));
641
+ arr.push(workingEntry);
623
642
  workingEntry = "";
624
643
  firstEntryToken = undefined;
625
644
  firstEntryTokenIndex = undefined;
@@ -666,7 +685,7 @@ function _parse(tokens, _offset, constraints) {
666
685
  t = closingIndex;
667
686
  } else {
668
687
  value = {
669
- content: parseValue(unescaped, unquoted !== undefined),
688
+ content: token.content,
670
689
  token,
671
690
  index: _offset + t
672
691
  };
package/dist/parse.d.ts CHANGED
@@ -12,15 +12,19 @@ export interface ExpressionConstraints<T extends TypeRecord, V extends boolean>
12
12
  /**
13
13
  * Restricted fields.\
14
14
  * Restrict an entire field by setting it to true.\
15
+ * Restrict an exact value by providing a string.\
16
+ * Restrict a pattern by providing a Regex expression.\
15
17
  * By default allow any value and restrict a collection of values by passing ['deny', VALUES[]]\
16
18
  * By default restrict any value and allow a collection of values by passing ['allow', VALUES[]]\
17
- * If the field query is of array type, it will check all entries of the array.
18
- * @warn If 42 (the number) is prohibited, "42" (the string) will still be allowed
19
+ * If the query value is of array type, it will check all entries of the array and ensure they're all allowed.\
20
+ * This check runs before type coercsion so the value checked will always be a string.
19
21
  */
20
- restricted?: Partial<Record<keyof T | (string & {}), boolean | ['allow' | 'deny', Array<boolean | string | number | RegExp>]>>;
22
+ restricted?: Partial<Record<keyof T | (string & {}), boolean | ['allow' | 'deny', Array<string | RegExp>]>>;
21
23
  /**
22
24
  * The types of fields\
23
- * Either provide the field type singularly or permit multiple types with an array of field types
25
+ * Either provide the field type singularly or permit multiple types with an array of field types\
26
+ * A field with type 'date' will parse the value into a Date object if possible (Wizard will not attempt to do this otherwise)\
27
+ * Type coercion priority: boolean -> date -> number -> string
24
28
  */
25
29
  types?: T;
26
30
  /**
@@ -34,11 +38,10 @@ export interface ExpressionConstraints<T extends TypeRecord, V extends boolean>
34
38
  */
35
39
  disallowUnvalidated?: V;
36
40
  /**
37
- * Interpret date string values as numbers\
38
- * If true, use `new Date()` constructor to make this determination\
39
- * If a callback is passed, use that. A value of `NaN` denotes an invalid date
41
+ * A callback that determines how dates are interpreted\
42
+ * By default, uses `new Date()`
40
43
  */
41
- interpretDates?: boolean | ((v: string) => number);
44
+ dateInterpreter?: (v: string | number) => Date;
42
45
  }
43
46
  /**
44
47
  * Parse a Wizard expression into its object form
package/dist/spec.d.ts CHANGED
@@ -74,10 +74,10 @@ export type ComparisonOperation = KeysWhereValue<typeof OPERATION_PURPOSE_DICTIO
74
74
  export declare const COMPARISON_TYPE_DICTIONARY: {
75
75
  readonly EQUAL: "primitive";
76
76
  readonly NOTEQUAL: "primitive";
77
- readonly GEQ: "number";
78
- readonly GREATER: "number";
79
- readonly LEQ: "number";
80
- readonly LESS: "number";
77
+ readonly GEQ: "numeric";
78
+ readonly GREATER: "numeric";
79
+ readonly LEQ: "numeric";
80
+ readonly LESS: "numeric";
81
81
  readonly IN: "array";
82
82
  readonly NOTIN: "array";
83
83
  readonly MATCH: "string";
@@ -89,15 +89,18 @@ export type ComparisonTypeToTSType<T extends keyof typeof COMPARISON_TYPE_DICTIO
89
89
  boolean: boolean;
90
90
  string: string;
91
91
  number: number;
92
+ date: Date;
93
+ numeric: number | Date;
92
94
  array: Primitive[];
93
95
  }[typeof COMPARISON_TYPE_DICTIONARY[T]];
94
- export type FieldType = 'boolean' | 'string' | 'number';
96
+ export type FieldType = 'boolean' | 'string' | 'number' | 'date';
95
97
  export type TypeRecord = Record<string, FieldType | FieldType[]>;
96
98
  /** Convert a field type string to a language server type */
97
99
  export type FieldTypeToTSType<T extends FieldType> = {
98
100
  boolean: boolean;
99
101
  string: string;
100
102
  number: number;
103
+ date: Date;
101
104
  }[T];
102
105
  /** Primitive values that can be used in comparisons */
103
106
  export type Primitive = FieldTypeToTSType<FieldType>;
@@ -105,6 +108,7 @@ export type Primitive = FieldTypeToTSType<FieldType>;
105
108
  export type ConvertTypeRecord<T extends TypeRecord> = {
106
109
  [K in keyof T]: (T[K] extends FieldType[] ? FieldTypeToTSType<T[K][number]> : T[K] extends FieldType ? FieldTypeToTSType<T[K]> : Primitive) & Primitive;
107
110
  };
111
+ export declare const TYPE_PRIORITY: ["boolean", "date", "number", "string"];
108
112
  /**
109
113
  * A group of conditions joined by a junction operator
110
114
  * @template R A record mapping field names to values
package/package.json CHANGED
@@ -6,18 +6,27 @@
6
6
  },
7
7
  "homepage": "https://github.com/guidance-analytics/wizard-ql",
8
8
  "description": "WizardQL is a natural-language-like query language for constructing data queries for resources that meet conditions.",
9
- "version": "1.0.0",
9
+ "version": "2.0.0",
10
10
  "repository": {
11
11
  "type": "git",
12
- "url": "https://github.com/guidance-analytics/wizard-ql.git"
12
+ "url": "git+https://github.com/guidance-analytics/wizard-ql.git"
13
13
  },
14
- "keywords": ["query", "language", "querylanguage", "parser", "cel", "expression"],
14
+ "keywords": [
15
+ "query",
16
+ "language",
17
+ "querylanguage",
18
+ "parser",
19
+ "cel",
20
+ "expression"
21
+ ],
15
22
  "license": "Apache-2.0",
16
23
  "main": "dist/index.js",
17
24
  "module": "dist/index.js",
18
25
  "type": "module",
19
26
  "types": "dist/index.d.ts",
20
- "files": ["dist/"],
27
+ "files": [
28
+ "dist/"
29
+ ],
21
30
  "scripts": {
22
31
  "build": "[ -d dist ] && rm -r dist; bun build --target=node --format=esm --packages external src/index.ts --outfile dist/index.js && tsc --project tsconfig.build.json",
23
32
  "lint": "eslint ."