tablevu 0.2.22 → 0.3.0-beta.2

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,507 @@
1
+ /* ==========================================================================
2
+ * OData DataTable Query Builder
3
+ * ========================================================================== */
4
+ /* ==========================================================================
5
+ * Value
6
+ * ========================================================================== */
7
+ function formatValue(value) {
8
+ if (value === null)
9
+ return 'null';
10
+ if (typeof value === 'string')
11
+ return `'${escapeODataString(value)}'`;
12
+ if (typeof value === 'number') {
13
+ if (!Number.isFinite(value))
14
+ throw new Error('Invalid numeric value.');
15
+ return String(value);
16
+ }
17
+ if (typeof value === 'boolean')
18
+ return value ? 'true' : 'false';
19
+ if (value instanceof Date)
20
+ return value.toISOString();
21
+ /*
22
+ * Backward compatibility with:
23
+ *
24
+ * {
25
+ * func: 'now',
26
+ * params: []
27
+ * }
28
+ */
29
+ if (typeof value === 'object' &&
30
+ value !== null &&
31
+ 'func' in value &&
32
+ 'params' in value) {
33
+ const funcValue = value;
34
+ return (funcValue.func +
35
+ '(' +
36
+ funcValue.params
37
+ .map(m => formatValue(m))
38
+ .join(',') +
39
+ ')');
40
+ }
41
+ throw new Error(`Unsupported OData value type: ${typeof value}`);
42
+ }
43
+ function escapeODataString(value) {
44
+ /*
45
+ * OData escapes a single quote by doubling it.
46
+ *
47
+ * O'Reilly
48
+ * ↓
49
+ * 'O''Reilly'
50
+ */
51
+ return value.replace(/'/g, "''");
52
+ }
53
+ /* ==========================================================================
54
+ * Filter
55
+ * ========================================================================== */
56
+ function toODataFilter(filter) {
57
+ return buildFilter(filter);
58
+ }
59
+ function buildFilter(filter) {
60
+ if (!filter)
61
+ throw new Error('Invalid filter.');
62
+ /*
63
+ * Backward compatibility:
64
+ *
65
+ * {
66
+ * func: 'contains',
67
+ * params: [...]
68
+ * }
69
+ */
70
+ if (filter.func) {
71
+ return buildFunctionObject(filter);
72
+ }
73
+ switch (filter.operator) {
74
+ /* ------------------------------------------------------------------ */
75
+ /* Logical */
76
+ /* ------------------------------------------------------------------ */
77
+ case 'and':
78
+ return buildLogical(filter, 'and');
79
+ case 'or':
80
+ return buildLogical(filter, 'or');
81
+ case 'not':
82
+ return buildNot(filter);
83
+ /* ------------------------------------------------------------------ */
84
+ /* Comparison */
85
+ /* ------------------------------------------------------------------ */
86
+ case 'eq':
87
+ case 'ne':
88
+ case 'gt':
89
+ case 'ge':
90
+ case 'lt':
91
+ case 'le':
92
+ return buildComparison(filter);
93
+ /* ------------------------------------------------------------------ */
94
+ /* String */
95
+ /* ------------------------------------------------------------------ */
96
+ case 'contains':
97
+ return buildFunction(filter, 'contains');
98
+ case 'startsWith':
99
+ return buildFunction(filter, 'startswith');
100
+ case 'endsWith':
101
+ return buildFunction(filter, 'endswith');
102
+ /* ------------------------------------------------------------------ */
103
+ /* Value */
104
+ /* ------------------------------------------------------------------ */
105
+ case 'in':
106
+ return buildIn(filter);
107
+ case 'between':
108
+ return buildBetween(filter);
109
+ case 'isNull':
110
+ return `${getColumn(filter)} eq null`;
111
+ case 'isNotNull':
112
+ return `${getColumn(filter)} ne null`;
113
+ /* ------------------------------------------------------------------ */
114
+ /* Collection */
115
+ /* ------------------------------------------------------------------ */
116
+ case 'any':
117
+ return buildAny(filter);
118
+ case 'all':
119
+ return buildAll(filter);
120
+ /* ------------------------------------------------------------------ */
121
+ /* Collection count */
122
+ /* ------------------------------------------------------------------ */
123
+ case 'countGt':
124
+ case 'countGe':
125
+ case 'countLt':
126
+ case 'countLe':
127
+ return buildCount(filter);
128
+ default:
129
+ throw new Error(`Unsupported OData operator: ${filter.operator}`);
130
+ }
131
+ }
132
+ /* ==========================================================================
133
+ * Logical
134
+ * ========================================================================== */
135
+ function buildLogical(filter, operator) {
136
+ const filters = filter.filters || [];
137
+ if (filters.length === 0)
138
+ return '';
139
+ return filters
140
+ .map(buildFilter)
141
+ .map(expression => `(${expression})`)
142
+ .join(` ${operator} `);
143
+ }
144
+ function buildNot(filter) {
145
+ const filters = filter.filters || [];
146
+ if (filters.length !== 1)
147
+ return '';
148
+ return `not (${buildFilter(filters[0])})`;
149
+ }
150
+ /* ==========================================================================
151
+ * Comparison
152
+ * ========================================================================== */
153
+ function buildComparison(filter) {
154
+ const column = getColumn(filter);
155
+ return (`${column} ` +
156
+ `${filter.operator} ` +
157
+ `${formatValue(filter.value)}`);
158
+ }
159
+ /* ==========================================================================
160
+ * String functions
161
+ * ========================================================================== */
162
+ function buildFunction(filter, functionName) {
163
+ const column = getColumn(filter);
164
+ return (`${functionName}(` +
165
+ `${column},` +
166
+ `${formatValue(filter.value)}` +
167
+ `)`);
168
+ }
169
+ /* ==========================================================================
170
+ * Function object
171
+ *
172
+ * Backward compatibility with the old:
173
+ *
174
+ * {
175
+ * func: 'contains',
176
+ * params: [...]
177
+ * }
178
+ * ========================================================================== */
179
+ function buildFunctionObject(filter) {
180
+ if (!filter.func)
181
+ throw new Error('Function is required.');
182
+ return (filter.func +
183
+ '(' +
184
+ (filter.params ?? [])
185
+ .map(m => formatValue(m))
186
+ .join(',') +
187
+ ')');
188
+ }
189
+ /* ==========================================================================
190
+ * IN
191
+ * ========================================================================== */
192
+ function buildIn(filter) {
193
+ const column = getColumn(filter);
194
+ if (!Array.isArray(filter.value))
195
+ throw new Error("Operator 'in' requires an array value.");
196
+ if (filter.value.length === 0)
197
+ throw new Error("Operator 'in' requires at least one value.");
198
+ return (`${column} in (` +
199
+ filter.value
200
+ .map(formatValue)
201
+ .join(',') +
202
+ `)`);
203
+ }
204
+ /* ==========================================================================
205
+ * BETWEEN
206
+ * ========================================================================== */
207
+ function buildBetween(filter) {
208
+ const column = getColumn(filter);
209
+ if (!Array.isArray(filter.value) ||
210
+ filter.value.length !== 2) {
211
+ throw new Error("Operator 'between' requires exactly two values.");
212
+ }
213
+ const [min, max] = filter.value;
214
+ return (`(${column} ge ${formatValue(min)} ` +
215
+ `and ${column} le ${formatValue(max)})`);
216
+ }
217
+ /* ==========================================================================
218
+ * ANY
219
+ * ========================================================================== */
220
+ function buildAny(filter) {
221
+ const column = getColumn(filter);
222
+ /*
223
+ * orders/any()
224
+ */
225
+ if (!filter.filters?.length)
226
+ return `${column}/any()`;
227
+ if (filter.filters.length !== 1)
228
+ throw new Error("Operator 'any' requires exactly one filter.");
229
+ return (`${column}/any(x: ` +
230
+ `${buildLambdaFilter(filter.filters[0], 'x')}` +
231
+ `)`);
232
+ }
233
+ /* ==========================================================================
234
+ * ALL
235
+ * ========================================================================== */
236
+ function buildAll(filter) {
237
+ const column = getColumn(filter);
238
+ const filters = filter.filters || [];
239
+ if (filters.length !== 1)
240
+ return '';
241
+ return (`${column}/all(x: ` +
242
+ `${buildLambdaFilter(filters[0], 'x')}` +
243
+ `)`);
244
+ }
245
+ /* ==========================================================================
246
+ * Lambda filter
247
+ * ========================================================================== */
248
+ function buildLambdaFilter(filter, variable) {
249
+ /*
250
+ * Logical
251
+ */
252
+ if (filter.operator === 'and')
253
+ return buildLambdaLogical(filter, variable, 'and');
254
+ if (filter.operator === 'or')
255
+ return buildLambdaLogical(filter, variable, 'or');
256
+ if (filter.operator === 'not') {
257
+ const filters = requireFilters(filter);
258
+ if (filters.length !== 1)
259
+ throw new Error("Operator 'not' requires exactly one filter.");
260
+ return (`not (` +
261
+ `${buildLambdaFilter(filters[0], variable)}` +
262
+ `)`);
263
+ }
264
+ /*
265
+ * Comparison
266
+ */
267
+ switch (filter.operator) {
268
+ case 'eq':
269
+ case 'ne':
270
+ case 'gt':
271
+ case 'ge':
272
+ case 'lt':
273
+ case 'le':
274
+ return (`${variable}/${getColumn(filter)} ` +
275
+ `${filter.operator} ` +
276
+ `${formatValue(filter.value)}`);
277
+ /*
278
+ * String
279
+ */
280
+ case 'contains':
281
+ return (`contains(` +
282
+ `${getLambdaColumn(filter, variable)},` +
283
+ `${formatValue(filter.value)}` +
284
+ `)`);
285
+ case 'startsWith':
286
+ return (`startswith(` +
287
+ `${getLambdaColumn(filter, variable)},` +
288
+ `${formatValue(filter.value)}` +
289
+ `)`);
290
+ case 'endsWith':
291
+ return (`endswith(` +
292
+ `${getLambdaColumn(filter, variable)},` +
293
+ `${formatValue(filter.value)}` +
294
+ `)`);
295
+ /*
296
+ * Null
297
+ */
298
+ case 'isNull':
299
+ return (`${getLambdaColumn(filter, variable)} eq null`);
300
+ case 'isNotNull':
301
+ return (`${getLambdaColumn(filter, variable)} ne null`);
302
+ default:
303
+ throw new Error(`Operator '${filter.operator}' ` +
304
+ `is not supported inside any/all.`);
305
+ }
306
+ }
307
+ function buildLambdaLogical(filter, variable, operator) {
308
+ const filters = requireFilters(filter);
309
+ if (filters.length === 0)
310
+ throw new Error(`Operator '${filter.operator}' requires at least one filter.`);
311
+ return filters
312
+ .map(item => buildLambdaFilter(item, variable))
313
+ .map(expression => `(${expression})`)
314
+ .join(` ${operator} `);
315
+ }
316
+ /* ==========================================================================
317
+ * Collection count
318
+ * ========================================================================== */
319
+ function buildCount(filter) {
320
+ const column = getColumn(filter);
321
+ const operators = {
322
+ countGt: 'gt',
323
+ countGe: 'ge',
324
+ countLt: 'lt',
325
+ countLe: 'le'
326
+ };
327
+ return (`${column}/$count ` +
328
+ `${operators[filter.operator]} ` +
329
+ `${formatValue(filter.value)}`);
330
+ }
331
+ /* ==========================================================================
332
+ * Helpers
333
+ * ========================================================================== */
334
+ function getColumn(filter) {
335
+ if (!filter.column ||
336
+ filter.column.trim().length === 0) {
337
+ throw new Error(`Operator '${filter.operator}' requires a column.`);
338
+ }
339
+ /*
340
+ * Allow:
341
+ *
342
+ * customer.name
343
+ * customer.address.city
344
+ *
345
+ * to become:
346
+ *
347
+ * customer/name
348
+ * customer/address/city
349
+ */
350
+ return filter.column.replace(/\./g, '/');
351
+ }
352
+ function getLambdaColumn(filter, variable) {
353
+ return (`${variable}/` +
354
+ `${getColumn(filter)}`);
355
+ }
356
+ function requireFilters(filter) {
357
+ return filter.filters || [];
358
+ }
359
+ /* ==========================================================================
360
+ * OData Request
361
+ * ========================================================================== */
362
+ function odata(options, props = {}) {
363
+ const url = new URL(options.url);
364
+ /* ---------------------------------------------------------------------- */
365
+ /* Paging */
366
+ /* ---------------------------------------------------------------------- */
367
+ if (props.pageSize &&
368
+ props.pageSize > 0) {
369
+ const currPage = props.currPage && props.currPage > 0
370
+ ? props.currPage
371
+ : 1;
372
+ const skip = props.pageSize * (currPage - 1);
373
+ url.searchParams.set('$skip', String(skip));
374
+ url.searchParams.set('$top', String(props.pageSize));
375
+ }
376
+ /* ---------------------------------------------------------------------- */
377
+ /* Count */
378
+ /* ---------------------------------------------------------------------- */
379
+ if (options.count === true) {
380
+ url.searchParams.set('$count', 'true');
381
+ }
382
+ /* ---------------------------------------------------------------------- */
383
+ /* Select */
384
+ /* ---------------------------------------------------------------------- */
385
+ if (options.select &&
386
+ options.select.length > 0) {
387
+ url.searchParams.set('$select', options.select
388
+ .map(m => m.replace(/\./g, '/'))
389
+ .join(','));
390
+ }
391
+ /* ---------------------------------------------------------------------- */
392
+ /* Expand */
393
+ /* ---------------------------------------------------------------------- */
394
+ if (options.expand &&
395
+ options.expand.length > 0) {
396
+ const expand = options.expand
397
+ .map(expand => {
398
+ const name = expand.name.replace(/\./g, '/');
399
+ if (expand.select &&
400
+ expand.select.length > 0) {
401
+ return (`${name}` +
402
+ `($select=` +
403
+ `${expand.select
404
+ .map(m => m.replace(/\./g, '/'))
405
+ .join(',')}` +
406
+ `)`);
407
+ }
408
+ return name;
409
+ })
410
+ .join(',');
411
+ url.searchParams.set('$expand', expand);
412
+ }
413
+ /* ---------------------------------------------------------------------- */
414
+ /* Filter */
415
+ /* ---------------------------------------------------------------------- */
416
+ const filters = [];
417
+ /*
418
+ * Static filter from options
419
+ */
420
+ if (options.filter &&
421
+ ((options.filter.filters && options.filter.filters.length > 0) ||
422
+ options.filter.column ||
423
+ options.filter.func)) {
424
+ const expression = toODataFilter(options.filter);
425
+ if (expression)
426
+ filters.push(`(${expression})`);
427
+ }
428
+ /*
429
+ * Runtime filter from DataTable
430
+ */
431
+ if (props.filter &&
432
+ ((props.filter.filters && props.filter.filters.length > 0) ||
433
+ props.filter.column ||
434
+ props.filter.func)) {
435
+ const expression = toODataFilter(props.filter);
436
+ if (expression)
437
+ filters.push(`(${expression})`);
438
+ }
439
+ /* ---------------------------------------------------------------------- */
440
+ /* Search */
441
+ /* ---------------------------------------------------------------------- */
442
+ if (props.search &&
443
+ props.search.trim() &&
444
+ props.cols &&
445
+ props.cols.length > 0) {
446
+ const searchValue = escapeODataString(props.search.trim());
447
+ const searchExpressions = props.cols
448
+ .filter(column => column.searchable === undefined ||
449
+ column.searchable === true)
450
+ .map(column => {
451
+ const name = column.name.replace(/\./g, '/');
452
+ return (`contains(` +
453
+ `${name},` +
454
+ `'${searchValue}'` +
455
+ `)`);
456
+ });
457
+ if (searchExpressions.length > 0) {
458
+ filters.push(`(${searchExpressions.join(' or ')})`);
459
+ }
460
+ }
461
+ /*
462
+ * Combine all filters with AND.
463
+ */
464
+ if (filters.length > 0) {
465
+ url.searchParams.set('$filter', filters.join(' and '));
466
+ }
467
+ /* ---------------------------------------------------------------------- */
468
+ /* Order By */
469
+ /* ---------------------------------------------------------------------- */
470
+ if (props.sort &&
471
+ props.sort.length > 0) {
472
+ const orderBy = props.sort
473
+ .map(sort => {
474
+ let column;
475
+ if (sort.index !== undefined &&
476
+ sort.index >= 0) {
477
+ if (!props.cols ||
478
+ !props.cols[sort.index]) {
479
+ throw new Error(`Invalid sort column index: ${sort.index}`);
480
+ }
481
+ column =
482
+ props.cols[sort.index].name;
483
+ }
484
+ else if (sort.name) {
485
+ column = sort.name;
486
+ }
487
+ else {
488
+ throw new Error('Sort requires either index or name.');
489
+ }
490
+ column =
491
+ column.replace(/\./g, '/');
492
+ return (column +
493
+ (sort.dir
494
+ ? ` ${sort.dir}`
495
+ : ''));
496
+ })
497
+ .join(',');
498
+ url.searchParams.set('$orderby', orderBy);
499
+ }
500
+ /* ---------------------------------------------------------------------- */
501
+ /* Request */
502
+ /* ---------------------------------------------------------------------- */
503
+ const requestInit = options.headers ? { headers: options.headers } : undefined;
504
+ return new Request(url, requestInit);
505
+ }
506
+
507
+ export { odata as default, toODataFilter };