@luxuryescapes/lib-criterion-tree 0.1.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.
@@ -0,0 +1,29 @@
1
+ export declare const MATCH_TYPES: readonly ['exact', 'not_exact', 'gt', 'gte', 'lt', 'lte', 'is_null', 'is_not_null', 'in', 'contains_substring', 'not_contains_substring', 'starts_with', 'ends_with'];
2
+ export declare const VALUE_TYPES: readonly ['string', 'number', 'boolean', 'array', 'date'];
3
+ export type Match = (typeof MATCH_TYPES)[number];
4
+ export type ValueType = (typeof VALUE_TYPES)[number];
5
+ export type AttributeLeaf = {
6
+ type: 'attribute';
7
+ name: string;
8
+ match: Match;
9
+ valueType?: ValueType;
10
+ value?: string | number | boolean | string[];
11
+ };
12
+ export type LogicalNode = {
13
+ type: 'and' | 'or';
14
+ children: CriterionNode[];
15
+ };
16
+ export type CriterionNode = AttributeLeaf | LogicalNode | null;
17
+ export type ConditionJoin = 'AND' | 'OR';
18
+ export type ChainedCondition = {
19
+ name: string;
20
+ match: Match;
21
+ valueType?: ValueType;
22
+ value?: string | number | boolean | string[];
23
+ joinWithNext?: ConditionJoin;
24
+ };
25
+ export declare const isValidCriterion: (value: unknown) => value is CriterionNode;
26
+ export declare const evaluate: (node: CriterionNode, ctx: Record<string, unknown>) => boolean;
27
+ export declare const optimize: (node: CriterionNode) => CriterionNode;
28
+ export declare const toHumanReadableString: (node: CriterionNode) => string;
29
+ export declare const fromChainedConditions: (rows: readonly ChainedCondition[]) => CriterionNode;
@@ -0,0 +1,394 @@
1
+ export const MATCH_TYPES = [
2
+ 'exact',
3
+ 'not_exact',
4
+ 'gt',
5
+ 'gte',
6
+ 'lt',
7
+ 'lte',
8
+ 'is_null',
9
+ 'is_not_null',
10
+ 'in',
11
+ 'contains_substring',
12
+ 'not_contains_substring',
13
+ 'starts_with',
14
+ 'ends_with',
15
+ ];
16
+ export const VALUE_TYPES = ['string', 'number', 'boolean', 'array', 'date'];
17
+ const MATCH_TYPE_SET = new Set(MATCH_TYPES);
18
+ const VALUE_TYPE_SET = new Set(VALUE_TYPES);
19
+ const MATCH_PHRASE = {
20
+ exact: 'is',
21
+ not_exact: 'is not',
22
+ gt: 'is greater than',
23
+ gte: 'is greater than or equal to',
24
+ lt: 'is less than',
25
+ lte: 'is less than or equal to',
26
+ is_null: 'is empty',
27
+ is_not_null: 'is not empty',
28
+ in: 'is one of',
29
+ contains_substring: 'contains',
30
+ not_contains_substring: 'does not contain',
31
+ starts_with: 'starts with',
32
+ ends_with: 'ends with',
33
+ };
34
+ const isMatch = (value) => typeof value === 'string' && MATCH_TYPE_SET.has(value);
35
+ const isValueType = (value) => typeof value === 'string' && VALUE_TYPE_SET.has(value);
36
+ const isLogicalNode = (node) => node !== null && (node.type === 'and' || node.type === 'or');
37
+ const isNullish = (value) => value === null || value === undefined;
38
+ const parseNumber = (value) => {
39
+ if (typeof value === 'number' && Number.isFinite(value)) {
40
+ return value;
41
+ }
42
+ if (typeof value === 'string' && value.trim() !== '') {
43
+ const parsed = Number(value);
44
+ if (Number.isFinite(parsed)) {
45
+ return parsed;
46
+ }
47
+ }
48
+ return undefined;
49
+ };
50
+ const parseDateMs = (value) => {
51
+ if (value instanceof Date) {
52
+ const ms = value.getTime();
53
+ return Number.isNaN(ms) ? undefined : ms;
54
+ }
55
+ if (typeof value === 'number' && Number.isFinite(value)) {
56
+ return value;
57
+ }
58
+ if (typeof value === 'string' && value !== '') {
59
+ const ms = Date.parse(value);
60
+ return Number.isNaN(ms) ? undefined : ms;
61
+ }
62
+ return undefined;
63
+ };
64
+ const parseBoolean = (value) => {
65
+ if (typeof value === 'boolean') {
66
+ return value;
67
+ }
68
+ if (value === 'true') {
69
+ return true;
70
+ }
71
+ if (value === 'false') {
72
+ return false;
73
+ }
74
+ return undefined;
75
+ };
76
+ const asString = (value) => typeof value === 'string' ? value : undefined;
77
+ const isNonEmptyString = (value) => typeof value === 'string' && value.length > 0;
78
+ const isStringArray = (value) => Array.isArray(value) && value.length > 0 && value.every(item => isNonEmptyString(item));
79
+ const readOwn = (ctx, name) => Object.hasOwn(ctx, name) ? ctx[name] : undefined;
80
+ const valuesEqual = (actual, expected, valueType) => {
81
+ if (valueType === 'number') {
82
+ const actualNumber = parseNumber(actual);
83
+ const expectedNumber = parseNumber(expected);
84
+ return (actualNumber !== undefined && expectedNumber !== undefined && actualNumber === expectedNumber);
85
+ }
86
+ if (valueType === 'boolean') {
87
+ const actualBoolean = parseBoolean(actual);
88
+ const expectedBoolean = parseBoolean(expected);
89
+ return (actualBoolean !== undefined &&
90
+ expectedBoolean !== undefined &&
91
+ actualBoolean === expectedBoolean);
92
+ }
93
+ if (valueType === 'date') {
94
+ const actualMs = parseDateMs(actual);
95
+ const expectedMs = parseDateMs(expected);
96
+ return actualMs !== undefined && expectedMs !== undefined && actualMs === expectedMs;
97
+ }
98
+ if (valueType === 'string') {
99
+ return isNonEmptyString(expected) && actual === expected;
100
+ }
101
+ return false;
102
+ };
103
+ const compareOrdered = (actual, expected, valueType, op) => {
104
+ if (valueType === 'date') {
105
+ return compareNumbers(parseDateMs(actual), parseDateMs(expected), op);
106
+ }
107
+ if (valueType === 'number') {
108
+ return compareNumbers(parseNumber(actual), parseNumber(expected), op);
109
+ }
110
+ return false;
111
+ };
112
+ const compareNumbers = (actual, expected, op) => {
113
+ if (actual === undefined || expected === undefined) {
114
+ return false;
115
+ }
116
+ if (op === 'gt') {
117
+ return actual > expected;
118
+ }
119
+ if (op === 'gte') {
120
+ return actual >= expected;
121
+ }
122
+ if (op === 'lt') {
123
+ return actual < expected;
124
+ }
125
+ return actual <= expected;
126
+ };
127
+ const matchSubstring = (actual, expected, valueType, op) => {
128
+ if (valueType !== 'string') {
129
+ return false;
130
+ }
131
+ const actualString = asString(actual);
132
+ const expectedString = asString(expected);
133
+ if (actualString === undefined || !isNonEmptyString(expectedString)) {
134
+ return false;
135
+ }
136
+ const haystack = actualString.toLowerCase();
137
+ const needle = expectedString.toLowerCase();
138
+ if (op === 'contains_substring') {
139
+ return haystack.includes(needle);
140
+ }
141
+ if (op === 'not_contains_substring') {
142
+ return !haystack.includes(needle);
143
+ }
144
+ if (op === 'starts_with') {
145
+ return haystack.startsWith(needle);
146
+ }
147
+ return haystack.endsWith(needle);
148
+ };
149
+ const evaluateAttribute = (node, ctx) => {
150
+ const actual = readOwn(ctx, node.name);
151
+ switch (node.match) {
152
+ case 'is_null':
153
+ return isNullish(actual);
154
+ case 'is_not_null':
155
+ return !isNullish(actual);
156
+ case 'not_exact':
157
+ return (node.valueType !== undefined &&
158
+ !isNullish(actual) &&
159
+ !valuesEqual(actual, node.value, node.valueType));
160
+ case 'exact':
161
+ return node.valueType !== undefined && valuesEqual(actual, node.value, node.valueType);
162
+ case 'gt':
163
+ case 'gte':
164
+ case 'lt':
165
+ case 'lte':
166
+ return compareOrdered(actual, node.value, node.valueType, node.match);
167
+ case 'in':
168
+ return (node.valueType === 'array' &&
169
+ isStringArray(node.value) &&
170
+ typeof actual === 'string' &&
171
+ node.value.includes(actual));
172
+ case 'contains_substring':
173
+ case 'not_contains_substring':
174
+ case 'starts_with':
175
+ case 'ends_with':
176
+ return matchSubstring(actual, node.value, node.valueType, node.match);
177
+ default:
178
+ return false;
179
+ }
180
+ };
181
+ const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
182
+ const isDateValue = (value) => {
183
+ if (isFiniteNumber(value)) {
184
+ return true;
185
+ }
186
+ return typeof value === 'string' && value !== '' && !Number.isNaN(Date.parse(value));
187
+ };
188
+ const isValidOrderedValue = (value, valueType) => {
189
+ if (valueType === 'date') {
190
+ return isDateValue(value);
191
+ }
192
+ if (valueType === 'number') {
193
+ return isFiniteNumber(value);
194
+ }
195
+ return false;
196
+ };
197
+ const isValidEqualityValue = (value, valueType) => {
198
+ if (valueType === 'date') {
199
+ return isDateValue(value);
200
+ }
201
+ if (valueType === 'number') {
202
+ return isFiniteNumber(value);
203
+ }
204
+ if (valueType === 'boolean') {
205
+ return typeof value === 'boolean';
206
+ }
207
+ if (valueType === 'string') {
208
+ return isNonEmptyString(value);
209
+ }
210
+ return false;
211
+ };
212
+ const isValidAttribute = (node) => {
213
+ if (typeof node.name !== 'string' || node.name.length === 0) {
214
+ return false;
215
+ }
216
+ if (!isMatch(node.match)) {
217
+ return false;
218
+ }
219
+ if (node.match === 'is_null' || node.match === 'is_not_null') {
220
+ return node.value === undefined && node.valueType === undefined;
221
+ }
222
+ if (!isValueType(node.valueType)) {
223
+ return false;
224
+ }
225
+ if (node.match === 'in') {
226
+ return node.valueType === 'array' && isStringArray(node.value);
227
+ }
228
+ if (node.match === 'starts_with' ||
229
+ node.match === 'ends_with' ||
230
+ node.match === 'contains_substring' ||
231
+ node.match === 'not_contains_substring') {
232
+ return node.valueType === 'string' && isNonEmptyString(node.value);
233
+ }
234
+ if (node.match === 'gt' || node.match === 'gte' || node.match === 'lt' || node.match === 'lte') {
235
+ return isValidOrderedValue(node.value, node.valueType);
236
+ }
237
+ return isValidEqualityValue(node.value, node.valueType);
238
+ };
239
+ const isValidNode = (value, isChild) => {
240
+ if (value === null) {
241
+ return !isChild;
242
+ }
243
+ if (value === undefined || typeof value !== 'object') {
244
+ return false;
245
+ }
246
+ const node = value;
247
+ if (node.type === 'and' || node.type === 'or') {
248
+ return (Array.isArray(node.children) &&
249
+ node.children.length > 0 &&
250
+ node.children.every(child => isValidNode(child, true)));
251
+ }
252
+ if (node.type === 'attribute') {
253
+ return isValidAttribute(node);
254
+ }
255
+ return false;
256
+ };
257
+ const formatValue = (value) => {
258
+ if (value === undefined) {
259
+ return '';
260
+ }
261
+ if (Array.isArray(value)) {
262
+ return value.join(', ');
263
+ }
264
+ return String(value);
265
+ };
266
+ const formatNode = (node, indent) => {
267
+ const pad = ' '.repeat(indent);
268
+ if (node === null) {
269
+ return `${pad}(empty)`;
270
+ }
271
+ if (isLogicalNode(node)) {
272
+ const prefix = node.type === 'and' ? 'All of:' : 'Any of:';
273
+ const children = node.children.map(child => formatNode(child, indent + 1)).join('\n');
274
+ return `${pad}${prefix}\n${children}`;
275
+ }
276
+ const phrase = MATCH_PHRASE[node.match];
277
+ if (node.match === 'is_null' || node.match === 'is_not_null') {
278
+ return `${pad}attribute '${node.name}' ${phrase}`;
279
+ }
280
+ return `${pad}attribute '${node.name}' ${phrase} ${formatValue(node.value)}`;
281
+ };
282
+ const chainedToLeaf = (row) => {
283
+ const leaf = {
284
+ type: 'attribute',
285
+ name: row.name,
286
+ match: row.match,
287
+ };
288
+ if (row.match === 'is_null' || row.match === 'is_not_null') {
289
+ return leaf;
290
+ }
291
+ if (row.valueType !== undefined) {
292
+ leaf.valueType = row.valueType;
293
+ }
294
+ if (row.value !== undefined) {
295
+ leaf.value = row.value;
296
+ }
297
+ return leaf;
298
+ };
299
+ export const isValidCriterion = (value) => isValidNode(value, false);
300
+ export const evaluate = (node, ctx) => {
301
+ if (node === null) {
302
+ return false;
303
+ }
304
+ if (node.type === 'and') {
305
+ if (node.children.length === 0) {
306
+ return false;
307
+ }
308
+ for (const child of node.children) {
309
+ if (!evaluate(child, ctx)) {
310
+ return false;
311
+ }
312
+ }
313
+ return true;
314
+ }
315
+ if (node.type === 'or') {
316
+ for (const child of node.children) {
317
+ if (evaluate(child, ctx)) {
318
+ return true;
319
+ }
320
+ }
321
+ return false;
322
+ }
323
+ if (node.type === 'attribute') {
324
+ return evaluateAttribute(node, ctx);
325
+ }
326
+ return false;
327
+ };
328
+ export const optimize = (node) => {
329
+ if (node === null) {
330
+ return null;
331
+ }
332
+ if (node.type === 'attribute') {
333
+ return node;
334
+ }
335
+ const children = [];
336
+ for (const child of node.children) {
337
+ const optimized = optimize(child);
338
+ if (optimized !== null) {
339
+ children.push(optimized);
340
+ }
341
+ }
342
+ if (children.length === 0) {
343
+ return null;
344
+ }
345
+ const firstChild = children[0];
346
+ if (children.length === 1 && firstChild !== undefined) {
347
+ return firstChild;
348
+ }
349
+ const flattened = [];
350
+ for (const child of children) {
351
+ if (isLogicalNode(child) && child.type === node.type) {
352
+ for (const nested of child.children) {
353
+ if (nested !== null) {
354
+ flattened.push(nested);
355
+ }
356
+ }
357
+ }
358
+ else {
359
+ flattened.push(child);
360
+ }
361
+ }
362
+ const onlyChild = flattened[0];
363
+ if (flattened.length === 1 && onlyChild !== undefined) {
364
+ return onlyChild;
365
+ }
366
+ return { type: node.type, children: flattened };
367
+ };
368
+ export const toHumanReadableString = (node) => formatNode(node, 0);
369
+ export const fromChainedConditions = (rows) => {
370
+ const first = rows[0];
371
+ if (first === undefined) {
372
+ return null;
373
+ }
374
+ let result = chainedToLeaf(first);
375
+ for (let index = 1; index < rows.length; index += 1) {
376
+ const previous = rows[index - 1];
377
+ const row = rows[index];
378
+ if (previous === undefined || row === undefined) {
379
+ continue;
380
+ }
381
+ const join = previous.joinWithNext === 'AND' ? 'and' : 'or';
382
+ const next = chainedToLeaf(row);
383
+ if (result.type === 'and' && join === 'and') {
384
+ result = { type: 'and', children: [...result.children, next] };
385
+ }
386
+ else if (result.type === 'or' && join === 'or') {
387
+ result = { type: 'or', children: [...result.children, next] };
388
+ }
389
+ else {
390
+ result = { type: join, children: [result, next] };
391
+ }
392
+ }
393
+ return result;
394
+ };
@@ -0,0 +1,2 @@
1
+ export { MATCH_TYPES, VALUE_TYPES, evaluate, fromChainedConditions, isValidCriterion, optimize, toHumanReadableString, } from './criterion-tree.js';
2
+ export type { AttributeLeaf, ChainedCondition, ConditionJoin, CriterionNode, LogicalNode, Match, ValueType, } from './criterion-tree.js';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { MATCH_TYPES, VALUE_TYPES, evaluate, fromChainedConditions, isValidCriterion, optimize, toHumanReadableString, } from './criterion-tree.js';
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@luxuryescapes/lib-criterion-tree",
3
+ "version": "0.1.0",
4
+ "description": "Criterion tree types and evaluation",
5
+ "repository": "git@github.com:lux-group/svc-lab-v2.git",
6
+ "author": "@lux-group/team-cx-platform",
7
+ "license": "MIT",
8
+ "type": "module",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "publishConfig": {
19
+ "main": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "import": "./dist/index.js"
25
+ }
26
+ }
27
+ },
28
+ "scripts": {
29
+ "build": "rimraf dist && tsc --project ./tsconfig.build.json",
30
+ "prepack": "yarn build",
31
+ "check:types": "tsc --noEmit --project ./tsconfig.json",
32
+ "test": "yarn run -T vitest run",
33
+ "test:watch": "yarn run -T vitest watch"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^26.6.2",
37
+ "rimraf": "6.1.3",
38
+ "typescript": "^7.0.2"
39
+ },
40
+ "main": "./dist/index.js",
41
+ "types": "./dist/index.d.ts"
42
+ }