@isrd-isi-edu/ermrestjs 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.
Files changed (71) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +55 -0
  3. package/dist/ermrest.d.ts +3481 -0
  4. package/dist/ermrest.js +45 -0
  5. package/dist/ermrest.js.gz +0 -0
  6. package/dist/ermrest.js.map +1 -0
  7. package/dist/ermrest.min.js +45 -0
  8. package/dist/ermrest.min.js.gz +0 -0
  9. package/dist/ermrest.min.js.map +1 -0
  10. package/dist/ermrest.ver.txt +1 -0
  11. package/dist/stats.html +4949 -0
  12. package/js/ag_reference.js +1483 -0
  13. package/js/core.js +4931 -0
  14. package/js/datapath.js +336 -0
  15. package/js/export.js +956 -0
  16. package/js/filters.js +192 -0
  17. package/js/format.js +344 -0
  18. package/js/hatrac.js +1130 -0
  19. package/js/json_ld_validator.js +285 -0
  20. package/js/parser.js +2320 -0
  21. package/js/setup/node.js +27 -0
  22. package/js/utils/helpers.js +2300 -0
  23. package/js/utils/json_ld_schema.js +680 -0
  24. package/js/utils/pseudocolumn_helpers.js +2196 -0
  25. package/package.json +79 -0
  26. package/src/index.ts +204 -0
  27. package/src/models/comment.ts +14 -0
  28. package/src/models/deferred-promise.ts +16 -0
  29. package/src/models/display-name.ts +5 -0
  30. package/src/models/errors.ts +408 -0
  31. package/src/models/path-prefix-alias-mapping.ts +130 -0
  32. package/src/models/reference/bulk-create-foreign-key-object.ts +133 -0
  33. package/src/models/reference/citation.ts +98 -0
  34. package/src/models/reference/contextualize.ts +535 -0
  35. package/src/models/reference/google-dataset-metadata.ts +72 -0
  36. package/src/models/reference/index.ts +14 -0
  37. package/src/models/reference/page.ts +520 -0
  38. package/src/models/reference/reference-aggregate-fn.ts +37 -0
  39. package/src/models/reference/reference.ts +2813 -0
  40. package/src/models/reference/related-reference.ts +467 -0
  41. package/src/models/reference/tuple.ts +652 -0
  42. package/src/models/reference-column/asset-pseudo-column.ts +498 -0
  43. package/src/models/reference-column/column-aggregate.ts +313 -0
  44. package/src/models/reference-column/facet-column.ts +1380 -0
  45. package/src/models/reference-column/foreign-key-pseudo-column.ts +626 -0
  46. package/src/models/reference-column/inbound-foreign-key-pseudo-column.ts +131 -0
  47. package/src/models/reference-column/index.ts +13 -0
  48. package/src/models/reference-column/key-pseudo-column.ts +236 -0
  49. package/src/models/reference-column/pseudo-column.ts +850 -0
  50. package/src/models/reference-column/reference-column.ts +740 -0
  51. package/src/models/source-object-node.ts +156 -0
  52. package/src/models/source-object-wrapper.ts +694 -0
  53. package/src/models/table-source-definitions.ts +98 -0
  54. package/src/services/authn.ts +43 -0
  55. package/src/services/catalog.ts +37 -0
  56. package/src/services/config.ts +202 -0
  57. package/src/services/error.ts +247 -0
  58. package/src/services/handlebars.ts +607 -0
  59. package/src/services/history.ts +136 -0
  60. package/src/services/http.ts +536 -0
  61. package/src/services/logger.ts +70 -0
  62. package/src/services/mustache.ts +0 -0
  63. package/src/utils/column-utils.ts +308 -0
  64. package/src/utils/constants.ts +526 -0
  65. package/src/utils/markdown-utils.ts +855 -0
  66. package/src/utils/reference-utils.ts +1658 -0
  67. package/src/utils/template-utils.ts +0 -0
  68. package/src/utils/type-utils.ts +89 -0
  69. package/src/utils/value-utils.ts +127 -0
  70. package/tsconfig.json +30 -0
  71. package/vite.config.mts +104 -0
@@ -0,0 +1,1380 @@
1
+ // models
2
+ import SourceObjectWrapper from '@isrd-isi-edu/ermrestjs/src/models/source-object-wrapper';
3
+ import type SourceObjectNode from '@isrd-isi-edu/ermrestjs/src/models/source-object-node';
4
+ import { ReferenceColumn } from '@isrd-isi-edu/ermrestjs/src/models/reference-column';
5
+ import type { CommentType } from '@isrd-isi-edu/ermrestjs/src/models/comment';
6
+ import type { DisplayName } from '@isrd-isi-edu/ermrestjs/src/models/display-name';
7
+ import { type Tuple, Reference } from '@isrd-isi-edu/ermrestjs/src/models/reference';
8
+
9
+ // services
10
+ import $log from '@isrd-isi-edu/ermrestjs/src/services/logger';
11
+ import ErrorService from '@isrd-isi-edu/ermrestjs/src/services/error';
12
+
13
+ // utils
14
+ import { renderMarkdown } from '@isrd-isi-edu/ermrestjs/src/utils/markdown-utils';
15
+ import { isDefinedAndNotNull, verify } from '@isrd-isi-edu/ermrestjs/src/utils/type-utils';
16
+ import { fixedEncodeURIComponent, simpleDeepCopy } from '@isrd-isi-edu/ermrestjs/src/utils/value-utils';
17
+ import {
18
+ _contexts,
19
+ _facetFilterTypes,
20
+ _facetUXModes,
21
+ _facetUXModeNames,
22
+ _histogramSupportedTypes,
23
+ _HTMLColumnType,
24
+ _specialSourceDefinitions,
25
+ _parserAliases,
26
+ } from '@isrd-isi-edu/ermrestjs/src/utils/constants';
27
+
28
+ // legacy imports
29
+ import { parse } from '@isrd-isi-edu/ermrestjs/js/parser';
30
+ import { Column } from '@isrd-isi-edu/ermrestjs/js/core';
31
+ import { type AttributeGroupReference } from '@isrd-isi-edu/ermrestjs/js/ag_reference';
32
+ import { _processColumnOrderList, _processSourceObjectComment } from '@isrd-isi-edu/ermrestjs/js/utils/helpers';
33
+ import { _compressSource } from '@isrd-isi-edu/ermrestjs/js/utils/pseudocolumn_helpers';
34
+
35
+ interface FacetChoiceDisplayName {
36
+ uniqueId: any;
37
+ displayname: DisplayName;
38
+ tuple: Tuple | null;
39
+ }
40
+
41
+ interface FacetFilterResult {
42
+ reference: Reference;
43
+ filter?: FacetFilter;
44
+ }
45
+
46
+ abstract class FacetFilter {
47
+ public _column: Column;
48
+ public term: unknown;
49
+ public uniqueId: unknown;
50
+ public facetFilterKey?: string;
51
+
52
+ constructor(term: unknown, column: Column) {
53
+ this._column = column;
54
+ this.term = term;
55
+ this.uniqueId = term;
56
+ }
57
+
58
+ /**
59
+ * String representation of filter
60
+ */
61
+ toString(): string | null {
62
+ if (this.term === null || this.term === undefined) {
63
+ return null;
64
+ }
65
+ return this._column.formatvalue(this.term, _contexts.COMPACT_SELECT) as string;
66
+ }
67
+
68
+ /**
69
+ * JSON representation of filter
70
+ */
71
+ toJSON(): any {
72
+ return this.term;
73
+ }
74
+ }
75
+
76
+ class SearchFacetFilter extends FacetFilter {
77
+ public facetFilterKey: string;
78
+
79
+ constructor(term: unknown, column: Column) {
80
+ super(term, column);
81
+ this.facetFilterKey = _facetFilterTypes.SEARCH;
82
+ }
83
+ }
84
+
85
+ class ChoiceFacetFilter extends FacetFilter {
86
+ public facetFilterKey: string;
87
+
88
+ constructor(value: unknown, column: Column) {
89
+ super(value, column);
90
+ this.facetFilterKey = _facetFilterTypes.CHOICE;
91
+ }
92
+ }
93
+
94
+ class RangeFacetFilter extends FacetFilter {
95
+ public min: unknown;
96
+ public minExclusive: boolean;
97
+ public max: unknown;
98
+ public maxExclusive: boolean;
99
+ public facetFilterKey: string;
100
+
101
+ constructor(min: unknown, minExclusive: boolean, max: unknown, maxExclusive: boolean, column: Column) {
102
+ super(null, column);
103
+ this.min = !isDefinedAndNotNull(min) ? null : min;
104
+ this.minExclusive = minExclusive === true ? true : false;
105
+ this.max = !isDefinedAndNotNull(max) ? null : max;
106
+ this.maxExclusive = maxExclusive === true ? true : false;
107
+ this.facetFilterKey = _facetFilterTypes.RANGE;
108
+ this.uniqueId = this.toString();
109
+ }
110
+
111
+ /**
112
+ * String representation of range filter. With the format of:
113
+ *
114
+ * - both min and max defined: `{{min}}-{{max}}`
115
+ * - only min defined: `> {{min}}`
116
+ * - only max defined: `< {{max}}`
117
+ */
118
+ toString(): string {
119
+ const getValue = (isMin: boolean): string => {
120
+ return this._column.formatvalue(isMin ? (this.min as string) : (this.max as string), _contexts.COMPACT_SELECT) as string;
121
+ };
122
+
123
+ // assumption: at least one of them is defined
124
+ if (!isDefinedAndNotNull(this.max)) {
125
+ return (this.minExclusive ? '> ' : '≥ ') + getValue(true);
126
+ }
127
+ if (!isDefinedAndNotNull(this.min)) {
128
+ return (this.maxExclusive ? '< ' : '≤ ') + getValue(false);
129
+ }
130
+ return getValue(true) + ' to ' + getValue(false);
131
+ }
132
+
133
+ /**
134
+ * JSON representation of range filter.
135
+ */
136
+ toJSON(): any {
137
+ const res: any = {};
138
+ if (isDefinedAndNotNull(this.max)) {
139
+ res.max = this.max;
140
+ }
141
+ if (this.maxExclusive === true) {
142
+ res.max_exclusive = true;
143
+ }
144
+
145
+ if (isDefinedAndNotNull(this.min)) {
146
+ res.min = this.min;
147
+ }
148
+ if (this.minExclusive === true) {
149
+ res.min_exclusive = true;
150
+ }
151
+
152
+ return res;
153
+ }
154
+ }
155
+
156
+ class NotNullFacetFilter {
157
+ public facetFilterKey: string;
158
+
159
+ constructor() {
160
+ this.facetFilterKey = 'not_null';
161
+ }
162
+ }
163
+
164
+ /**
165
+ * @param {Reference} reference the reference that this FacetColumn blongs to.
166
+ * @param {int} index The index of this FacetColumn in the list of facetColumns
167
+ * @param {SourceObjectWrapper} facetObjectWrapper The filter object that this FacetColumn will be created based on
168
+ * @param {?FacetFilter[]} filters Array of filters
169
+ */
170
+ export class FacetColumn {
171
+ /**
172
+ * The column object that the filters are based on
173
+ */
174
+ public _column: Column;
175
+
176
+ /**
177
+ * The reference that this facet blongs to
178
+ */
179
+ public reference: Reference;
180
+
181
+ /**
182
+ * The index of facetColumn in the list of facetColumns
183
+ * NOTE: Might not be needed
184
+ */
185
+ public index: number;
186
+
187
+ /**
188
+ * A valid data-source path
189
+ * NOTE: we're not validating this data-source, we assume that this is valid.
190
+ */
191
+ public dataSource: any;
192
+
193
+ /**
194
+ * the compressed version of data source data-source path
195
+ */
196
+ public compressedDataSource: any;
197
+
198
+ /**
199
+ * Filters that are applied to this facet.
200
+ */
201
+ public filters: Array<FacetFilter | NotNullFacetFilter>;
202
+
203
+ // the whole filter object
204
+ // NOTE: This might not include the filters
205
+ public _facetObject: any;
206
+
207
+ public sourceObjectWrapper: SourceObjectWrapper;
208
+
209
+ public sourceObjectNodes: SourceObjectNode[];
210
+
211
+ public lastForeignKeyNode?: SourceObjectNode;
212
+
213
+ public foreignKeyPathLength: number;
214
+
215
+ /**
216
+ * Whether the source has path or not
217
+ */
218
+ public hasPath: boolean;
219
+
220
+ /**
221
+ * Returns true if the source is on a key column.
222
+ * If facetObject['entity'] is defined as false, it will return false,
223
+ * otherwise it will true if filter is based on key.
224
+ */
225
+ public isEntityMode: boolean;
226
+
227
+ // Cached properties
228
+ private _isOpen?: boolean;
229
+ private _preferredMode?: string;
230
+ private _barPlot?: boolean;
231
+ private _numBuckets?: number;
232
+ private _referenceColumn?: ReferenceColumn;
233
+ private _sourceReference?: Reference;
234
+ private _displayname?: DisplayName;
235
+ private _comment?: CommentType;
236
+ private _hideNullChoice?: boolean;
237
+ private _hideNotNullChoice?: boolean;
238
+ private _hideNumOccurrences?: boolean;
239
+ private _sortColumns?: any[];
240
+ private _scalarValuesRef?: AttributeGroupReference;
241
+ private _fastFilterSource?: SourceObjectWrapper | null;
242
+ private _hasNotNullFilter?: boolean;
243
+ private _hasNullFilter?: boolean;
244
+ private _searchFilters?: SearchFacetFilter[];
245
+ private _choiceFilters?: ChoiceFacetFilter[];
246
+ private _rangeFilters?: RangeFacetFilter[];
247
+
248
+ constructor(reference: Reference, index: number, facetObjectWrapper: SourceObjectWrapper, filters?: Array<FacetFilter | NotNullFacetFilter>) {
249
+ this._column = facetObjectWrapper.column!;
250
+ this.reference = reference;
251
+ this.index = index;
252
+ this.dataSource = facetObjectWrapper.sourceObject.source;
253
+ this.compressedDataSource = _compressSource(this.dataSource);
254
+
255
+ this.filters = [];
256
+ if (Array.isArray(filters)) {
257
+ this.filters = filters;
258
+ } else {
259
+ this._setFilters(facetObjectWrapper.sourceObject);
260
+ }
261
+
262
+ this._facetObject = facetObjectWrapper.sourceObject;
263
+ this.sourceObjectWrapper = facetObjectWrapper;
264
+ this.sourceObjectNodes = facetObjectWrapper.sourceObjectNodes;
265
+ this.lastForeignKeyNode = facetObjectWrapper.lastForeignKeyNode;
266
+ this.foreignKeyPathLength = facetObjectWrapper.foreignKeyPathLength;
267
+ this.hasPath = facetObjectWrapper.hasPath;
268
+ this.isEntityMode = facetObjectWrapper.isEntityMode;
269
+ }
270
+
271
+ /**
272
+ * If has filters it will return true,
273
+ * otherwise returns facetObject['open']
274
+ */
275
+ get isOpen(): boolean {
276
+ if (this._isOpen === undefined) {
277
+ const open = this._facetObject.open;
278
+ this._isOpen = this.filters.length > 0 ? true : open === true;
279
+ }
280
+ return this._isOpen;
281
+ }
282
+
283
+ /**
284
+ * The Preferred ux mode.
285
+ * Any of:
286
+ * `choices`, `ranges`, or `check_presence`
287
+ * This should be used if we're not in entity mode. In entity mode it will
288
+ * always return `choices`.
289
+ *
290
+ * The logic is as follows,
291
+ * 1. if facet has only choice or range filter, return that.
292
+ * 2. use ux_mode if available
293
+ * 3. use choices if in entity mode
294
+ * 4. return choices if int or serial, part of key, and not null.
295
+ * 5. return ranges or choices based on the type.
296
+ *
297
+ * Note:
298
+ * - null and not-null are applicaple in all types, so we're ignoring those while figuring out the preferred mode.
299
+ */
300
+ get preferredMode(): string {
301
+ if (this._preferredMode === undefined) {
302
+ const modes = _facetUXModes;
303
+
304
+ // a facet is in range mode if it's column's type is integer, float, date, timestamp, or serial
305
+ const isRangeMode = (column: Column): boolean => {
306
+ const typename = column.type.rootName;
307
+
308
+ //default facet for unique not null integer/serial should be choice (not range)
309
+ if (typename.startsWith('serial') || typename.startsWith('int')) {
310
+ return !column.isUniqueNotNull;
311
+ }
312
+
313
+ return typename.startsWith('float') || typename.startsWith('date') || typename.startsWith('timestamp') || typename.startsWith('numeric');
314
+ };
315
+
316
+ const getPreferredMode = (self: FacetColumn): string => {
317
+ // see if only one type of facet is preselected
318
+ let onlyChoice = false,
319
+ onlyRange = false;
320
+
321
+ // not-null and null can be applied to both choices and ranges
322
+ let filterLen = self.filters.length,
323
+ choiceFilterLen = self.choiceFilters.length;
324
+ if (self.hasNotNullFilter) {
325
+ filterLen--;
326
+ }
327
+ if (self.hasNullFilter) {
328
+ filterLen--;
329
+ choiceFilterLen--;
330
+ }
331
+
332
+ if (filterLen > 0) {
333
+ onlyChoice = choiceFilterLen === filterLen;
334
+ onlyRange = self.rangeFilters.length === filterLen;
335
+ }
336
+ // if only choices or ranges preselected, honor it
337
+ if (onlyChoice || onlyRange) {
338
+ return onlyChoice ? modes.CHOICE : modes.RANGE;
339
+ }
340
+
341
+ // use the defined ux_mode
342
+ if (_facetUXModeNames.indexOf(self._facetObject.ux_mode) !== -1) {
343
+ return self._facetObject.ux_mode;
344
+ }
345
+
346
+ // use the column type to determien its ux_mode
347
+ return !self.isEntityMode && isRangeMode(self._column) ? modes.RANGE : modes.CHOICE;
348
+ };
349
+
350
+ this._preferredMode = getPreferredMode(this);
351
+ }
352
+ return this._preferredMode;
353
+ }
354
+
355
+ /**
356
+ * Returns true if the plotly histogram graph should be shown in the UI
357
+ * If _facetObject.barPlot is not defined, the value is true. By default
358
+ * the histogram should be shown unless specified otherwise
359
+ */
360
+ get barPlot(): boolean {
361
+ if (this._barPlot === undefined) {
362
+ this._barPlot = this._facetObject.bar_plot === false ? false : true;
363
+
364
+ // if it's not in the list of spported types we won't show it even if the user defined it in the annotation
365
+ if (_histogramSupportedTypes.indexOf(this.column.type.rootName) === -1) {
366
+ this._barPlot = false;
367
+ }
368
+ }
369
+ return this._barPlot;
370
+ }
371
+
372
+ /**
373
+ * Returns the value of `barPlot.nBins` if it was defined as part of the
374
+ * `facetObject` in the annotation. If undefined, the default # of buckets is 30
375
+ */
376
+ get histogramBucketCount(): number {
377
+ if (this._numBuckets === undefined) {
378
+ this._numBuckets = 30;
379
+ const barPlot = this._facetObject.bar_plot;
380
+ if (barPlot && barPlot.n_bins) {
381
+ this._numBuckets = barPlot.n_bins;
382
+ }
383
+ }
384
+ return this._numBuckets!;
385
+ }
386
+
387
+ /**
388
+ * ReferenceColumn that this facetColumn is based on
389
+ */
390
+ get column(): ReferenceColumn {
391
+ if (this._referenceColumn === undefined) {
392
+ this._referenceColumn = new ReferenceColumn(this.sourceReference, [this._column]);
393
+ }
394
+ return this._referenceColumn;
395
+ }
396
+
397
+ /**
398
+ * uncontextualized {@link ERMrest.Reference} that has all the joins specified
399
+ * in the source with all the filters of other FacetColumns in the reference.
400
+ *
401
+ * The returned reference will be in the following format:
402
+ * <main-table>/<facets of main table except current facet>/<path to current facet>
403
+ *
404
+ *
405
+ * Consider the following scenario:
406
+ * Table T has two foreignkeys to R1 (fk1), R2 (fk2), and R3 (fk3).
407
+ * R1 has a fitler for term=1, and R2 has a filter for term=2
408
+ * Then the source reference for R3 will be the following:
409
+ * T:=S:T/(fk1)/term=1/$T/(fk2)/term2/$T/M:=(fk3)
410
+ * As you can see it has all the filters of the main table + join to current table.
411
+ *
412
+ * Notes:
413
+ * - This function used to reverse the path from the current facet to each of the
414
+ * other facets in the main reference. Since this was very inefficient, we decided
415
+ * to rewrite it to start from the main table instead.
416
+ * - The path from the main table to facet is based on the given column directive and
417
+ * therefore might have filters or reused table instances (shared path). That's why
418
+ * we're ensuring to pass the whole facetObjectWrapper to parser, so it can properly
419
+ * parse it.
420
+ */
421
+ get sourceReference(): Reference {
422
+ if (this._sourceReference === undefined) {
423
+ const jsonFilters: any[] = [];
424
+ const table = this._column.table;
425
+ const loc = this.reference.location;
426
+
427
+ // TODO might be able to improve this
428
+ if (typeof loc.searchTerm === 'string') {
429
+ jsonFilters.push({ sourcekey: _specialSourceDefinitions.SEARCH_BOX, search: [loc.searchTerm] });
430
+ }
431
+
432
+ let newLoc = parse(loc.compactUri, loc.catalogObject);
433
+
434
+ //get all the filters from other facetColumns
435
+ if (loc.facets) {
436
+ // create new facet filters
437
+ // TODO might be able to imporve this. Instead of recreating the whole json file.
438
+ this.reference.facetColumns.forEach((fc: FacetColumn, index: number) => {
439
+ if (index !== this.index && fc.filters.length !== 0) {
440
+ jsonFilters.push(fc.toJSON());
441
+ }
442
+ });
443
+
444
+ // apply the hidden filters
445
+ loc.facets.andFilters.forEach((f: any) => {
446
+ if (f.hidden) {
447
+ jsonFilters.push(f);
448
+ }
449
+ });
450
+ }
451
+
452
+ if (jsonFilters.length > 0) {
453
+ newLoc.facets = { and: jsonFilters };
454
+ } else {
455
+ newLoc.facets = null;
456
+ }
457
+
458
+ // add custom facets as the facets of the parent
459
+ const alias = _parserAliases.JOIN_TABLE_PREFIX + (newLoc.hasJoin ? newLoc.pathParts.length : '');
460
+ if (loc.customFacets) {
461
+ //NOTE this is just a hack, and since the whole feature is just a hack it's fine.
462
+ // In the ermrest_path we're allowing them to use the M alias. In here, we are making
463
+ // sure to change the M alias to T. Because we're going to use T to refer to the main
464
+ // reference when the facet has a path. In other words if the following is main reference
465
+ // M:=S:main_table/cutom-facet-that-might-have-$M-alias/facets-on-the-main-table
466
+ //
467
+ // Then the source reference for any of the facets will be
468
+ //
469
+ // T:=S:main_table/custom-facet-that-should-change-$M-to-$T/facets-on-the-main-table/join-path-to-current-facet/$M:=S:facet_table
470
+ //
471
+ // You can see why we are changing $M to $T.
472
+ //
473
+ // As I mentioned this is hacky, so we should eventually find a way around this.
474
+ const cfacet = simpleDeepCopy(loc.customFacets.decoded);
475
+ if (cfacet.ermrest_path && this.sourceObjectNodes.length > 0) {
476
+ // switch the alias names, the cfacet is originally written with the assumption of
477
+ // the main table having "M" alias. So we just have to swap the aliases.
478
+ const mainAlias = _parserAliases.MAIN_TABLE;
479
+ cfacet.ermrest_path = cfacet.ermrest_path.replaceAll('$' + mainAlias, '$' + alias);
480
+ }
481
+ newLoc.customFacets = cfacet;
482
+ }
483
+
484
+ /**
485
+ * if it has path, we have to pass the whole facetObjectWrapper
486
+ * as a join. this is so we can properly share path
487
+ */
488
+ if (this.hasPath) {
489
+ newLoc = newLoc.addJoin(this.sourceObjectWrapper, table.schema.name, table.name);
490
+ }
491
+ // if it only has filter (and no path, then we can just add the filter to path)
492
+ else if (this.sourceObjectWrapper.isFiltered) {
493
+ // TODO can this be improved?
494
+ const filterPath = this.sourceObjectWrapper.toString(false, false);
495
+ if (filterPath.length > 0) {
496
+ newLoc = parse(newLoc.compactUri + '/' + filterPath);
497
+ }
498
+ }
499
+
500
+ this._sourceReference = new Reference(newLoc, table.schema.catalog);
501
+ }
502
+ return this._sourceReference;
503
+ }
504
+
505
+ /**
506
+ * Returns the displayname object that should be used for this facetColumn.
507
+ * TODO the heuristics should be changed to be aligned with PseudoColumn
508
+ * Heuristics are as follows (first applicable rule):
509
+ * 0. If markdown_name is defined, use it.
510
+ * 1. If column is part of the main table (there's no join), use the column's displayname.
511
+ * 2. If last foreignkey is outbound and has to_name, use it.
512
+ * 3. If last foreignkey is inbound and has from_name, use it.
513
+ * 4. Otherwise use the table name.
514
+ * - If it's in `scalar` mode, append the column name. `table_name (column_name)`.
515
+ *
516
+ * Returned object has `value`, `unformatted`, and `isHTML` properties.
517
+ */
518
+ get displayname(): DisplayName {
519
+ if (this._displayname === undefined) {
520
+ const getDisplayname = (self: FacetColumn): DisplayName => {
521
+ if (self._facetObject.markdown_name) {
522
+ return {
523
+ value: renderMarkdown(self._facetObject.markdown_name, true),
524
+ unformatted: self._facetObject.markdown_name,
525
+ isHTML: true,
526
+ };
527
+ }
528
+
529
+ const lastFK = self.lastForeignKeyNode ? self.lastForeignKeyNode.nodeObject : null;
530
+
531
+ // if is part of the main table, just return the column's displayname
532
+ if (lastFK === null) {
533
+ return self.column.displayname;
534
+ }
535
+
536
+ // Otherwise
537
+ let value: string,
538
+ unformatted: string,
539
+ isHTML = false;
540
+ const lastFKIsInbound = self.lastForeignKeyNode!.isInbound;
541
+ const lastFKDisplay = lastFK.getDisplay(_contexts.COMPACT_SELECT);
542
+
543
+ // use from_name of the last fk if it's inbound
544
+ if (lastFKIsInbound && lastFKDisplay.fromName) {
545
+ value = unformatted = lastFKDisplay.fromName;
546
+ }
547
+ // use to_name of the last fk if it's outbound
548
+ else if (!lastFKIsInbound && lastFKDisplay.toName) {
549
+ value = unformatted = lastFKDisplay.toName;
550
+ }
551
+ // use the table name if it was not defined
552
+ else {
553
+ value = self.column.table.displayname.value;
554
+ unformatted = self.column.table.displayname.unformatted;
555
+ isHTML = self.column.table.displayname.isHTML;
556
+
557
+ if (!self.isEntityMode) {
558
+ value += ' (' + self.column.displayname.value + ')';
559
+ unformatted += ' (' + self.column.displayname.unformatted + ')';
560
+ if (!isHTML) {
561
+ isHTML = self.column.displayname.isHTML;
562
+ }
563
+ }
564
+ }
565
+
566
+ return { value: value, isHTML: isHTML, unformatted: unformatted };
567
+ };
568
+
569
+ this._displayname = getDisplayname(this);
570
+ }
571
+ return this._displayname;
572
+ }
573
+
574
+ /**
575
+ * Could be used as tooltip to provide more information about the facetColumn
576
+ */
577
+ get comment(): CommentType {
578
+ if (this._comment === undefined) {
579
+ let commentDisplayMode: any, disp: any;
580
+ if (!this.isEntityMode) {
581
+ disp = this._column.getDisplay(_contexts.COMPACT_SELECT);
582
+ commentDisplayMode = disp.commentDisplayMode;
583
+ } else {
584
+ disp = this._column.table.getDisplay(_contexts.COMPACT_SELECT);
585
+ commentDisplayMode = disp.tableCommentDisplayMode;
586
+ }
587
+ this._comment = _processSourceObjectComment(
588
+ this._facetObject,
589
+ disp.comment ? disp.comment.unformatted : null,
590
+ disp.commentRenderMarkdown,
591
+ commentDisplayMode,
592
+ );
593
+ }
594
+ return this._comment!;
595
+ }
596
+
597
+ /**
598
+ * Whether client should hide the null choice.
599
+ *
600
+ * Before going through the logic, it's good to know the following:
601
+ * -`null` filter could mean any of the following:
602
+ * - Scalar value being `null`. In terms of ermrest, a simple col::null:: query
603
+ * - No value exists in the given path (checking presence of a value in the path). In terms of ermrest,
604
+ * we have to construct an outer join. For performance we're going to use right outer join.
605
+ * Because of ermrest limitation, we cannot have more than two right outer joins and therefore
606
+ * two such null checks cannot co-exist.
607
+ * - Since we're not going to show two different options for these two meanings,
608
+ * we have to make sure to offer `null` option when only one of these two meanings would make sense.
609
+ * - There are some cases when the `null` is not even possible based on the model. So we shouldn't offer this option.
610
+ * - Due to ermrest and our parse limitations, facets with filter cannot support null.
611
+ *
612
+ * Therefore, the following is the logic for this function:
613
+ * 1. If the facet already has `null` filter, return `false`.
614
+ * 2. If facet has `"hide_null_choice": true`, return `true`.
615
+ * 3. If facet has filer, return `true` as our parse can't handle it.
616
+ * 4. If it's a local column,
617
+ * 4.1. if it's not-null, return `true`.
618
+ * 4.2. if it's nullable, return `false`.
619
+ * 5. If it's an all-outbound path where all the columns in the path are not-null,
620
+ * 5.1. If the end column is nullable, return `false` as null value only means scalar value being null.
621
+ * 5.2. If the end column is not-null, return `true` as null value is not possible.
622
+ * 6. For any other paths, if the end column is nullable, `null` filter could mean both scalar and path. so return `true`.
623
+ * 7. Facets with only one hop where the column used in foreignkey is the same column for faceting.
624
+ * In this case, we can completely ignore the foreignkey path and just do a value check on main table. so return `false`.
625
+ * 8. any other cases (facet with arbiarty path),
626
+ * 8.1. if other facets have `null` filter, return `true` as we cannot support multiple right outer joins.
627
+ * 8.2. otherwise, return `false`.
628
+ *
629
+ * NOTE this function used to check for select access as well as versioned catalog,
630
+ * but we decided to remove them since it's not the desired behavior:
631
+ * https://github.com/informatics-isi-edu/ermrestjs/issues/888
632
+ */
633
+ get hideNullChoice(): boolean {
634
+ if (this._hideNullChoice === undefined) {
635
+ const getHideNull = (self: FacetColumn): boolean => {
636
+ // if null filter exists, we have to show it
637
+ if (self.hasNullFilter) {
638
+ return false;
639
+ }
640
+
641
+ // if facet definition tells us to hide it
642
+ if (self._facetObject.hide_null_choice === true) {
643
+ return true;
644
+ }
645
+
646
+ // parse cannot support this
647
+ if (self.sourceObjectWrapper.isFiltered) {
648
+ return true;
649
+ }
650
+
651
+ // local column
652
+ if (self.foreignKeyPathLength === 0) {
653
+ return !self._column.nullok;
654
+ }
655
+
656
+ // path cannot be null
657
+ if (self.sourceObjectWrapper.isAllOutboundNotNullPerModel) {
658
+ /**
659
+ * if the last column is not-null, then "null" can never happen so there's no point in showing the option.
660
+ * but if it's nullable, then this check could only mean scalar value being null and so we can show null facet
661
+ */
662
+ return !self._column.nullok;
663
+ }
664
+
665
+ // has arbitary path
666
+ if (self._column.nullok) {
667
+ return true;
668
+ }
669
+
670
+ // G3.1
671
+ if (!self.hasPath) {
672
+ return false;
673
+ }
674
+
675
+ // G3
676
+ const othersHaveNull =
677
+ self.reference.location.isUsingRightJoin ||
678
+ self.reference.facetColumns.some((fc: FacetColumn, index: number) => {
679
+ return index !== self.index && fc.hasNullFilter && fc.hasPath;
680
+ });
681
+
682
+ return othersHaveNull;
683
+ };
684
+ this._hideNullChoice = getHideNull(this);
685
+ }
686
+ return this._hideNullChoice;
687
+ }
688
+
689
+ /**
690
+ * Whether client should hide the not-null choice. The logic is as follows:
691
+ * - `false` if facet has not-null filter.
692
+ * - `true` if facet has hide_not_null_choice in it's definition
693
+ * - `true` if facet is from the same table and it's not-nullable.
694
+ * - `true` if facet is all outbound not null.
695
+ * - otherwise `false`
696
+ */
697
+ get hideNotNullChoice(): boolean {
698
+ if (this._hideNotNullChoice === undefined) {
699
+ const getHideNotNull = (self: FacetColumn): boolean => {
700
+ // if not-null filter exists
701
+ if (self.hasNotNullFilter) return false;
702
+
703
+ // if hide_not_null_choice is available in facet definition
704
+ if (self._facetObject.hide_not_null_choice === true) return true;
705
+
706
+ //if from the same table, don't show if it's not-null
707
+ if (self.sourceObjectWrapper.foreignKeyPathLength === 0) {
708
+ return !self._column.nullok;
709
+ }
710
+
711
+ //if all outbound not-null don't show it.
712
+ return self.sourceObjectWrapper.isAllOutboundNotNullPerModel && !self._column.nullok;
713
+ };
714
+
715
+ this._hideNotNullChoice = getHideNotNull(this);
716
+ }
717
+ return this._hideNotNullChoice;
718
+ }
719
+
720
+ /**
721
+ * Whether we should hide the number of Occurrences column
722
+ */
723
+ get hideNumOccurrences(): boolean {
724
+ if (this._hideNumOccurrences === undefined) {
725
+ this._hideNumOccurrences = this._facetObject.hide_num_occurrences === true;
726
+ }
727
+ return this._hideNumOccurrences;
728
+ }
729
+
730
+ /**
731
+ * Returns the sortColumns when we're sorting this facet in scalar mode
732
+ * - uses row_order if defined.
733
+ * - otherwise it will be descending of num_occurrences and column order of base column.
734
+ */
735
+ get sortColumns(): any[] {
736
+ verify(!this.isEntityMode, 'sortColumns cannot be used in entity mode.');
737
+
738
+ if (this._sortColumns === undefined) {
739
+ this._determineSortable();
740
+ }
741
+ return this._sortColumns!;
742
+ }
743
+
744
+ private _determineSortable(): void {
745
+ const rowOrder = _processColumnOrderList(this._facetObject.order, this._column.table, { allowNumOccurrences: true });
746
+
747
+ // default sorting:
748
+ // - descending frequency + ascending the column sort columns
749
+ if (!Array.isArray(rowOrder) || rowOrder.length === 0) {
750
+ this._sortColumns = [
751
+ { num_occurrences: true, descending: true },
752
+ { column: this._column, descending: false },
753
+ ];
754
+ return;
755
+ }
756
+
757
+ this._sortColumns = rowOrder;
758
+ }
759
+
760
+ /**
761
+ * An {@link ERMrest.AttributeGroupReference} object that can be used to get
762
+ * the available scalar values of this facet. This will use the sortColumns, and hideNumOccurrences APIs.
763
+ * It will throw an error if it's used in entity-mode.
764
+ */
765
+ get scalarValuesReference(): AttributeGroupReference {
766
+ verify(!this.isEntityMode, 'this API cannot be used in entity-mode');
767
+
768
+ if (this._scalarValuesRef === undefined) {
769
+ this._scalarValuesRef = this.column.groupAggregate!.entityCounts(this.displayname, this.sortColumns, this.hideNumOccurrences, true);
770
+ }
771
+ return this._scalarValuesRef;
772
+ }
773
+
774
+ get fastFilterSourceObjectWrapper(): SourceObjectWrapper | null {
775
+ if (this._fastFilterSource === undefined) {
776
+ let res: SourceObjectWrapper | null = null;
777
+ const fastFilter = this._facetObject.fast_filter_source;
778
+ if (fastFilter !== undefined || fastFilter !== null) {
779
+ try {
780
+ res = new SourceObjectWrapper({ source: fastFilter }, this.reference.table, true);
781
+ } catch {
782
+ $log.warn('given fast_filter_source for facet index=`' + this.index + '` is not valid.');
783
+ res = null;
784
+ }
785
+ }
786
+ this._fastFilterSource = res;
787
+ }
788
+ return this._fastFilterSource;
789
+ }
790
+
791
+ /**
792
+ * When presenting the applied choice filters, the displayname might be differnt from the value.
793
+ * This only happens in case of entity-picker. Othercases we can just return the list of fitleres as is.
794
+ * In case of entity-picker, we should get the displayname of the choices.
795
+ * Therefore heuristic is as follows:
796
+ * - If no fitler -> resolve with empty list.
797
+ * - If in scalar mode -> resolve with list of filters (don't change their displaynames.)
798
+ * - Otherwise (entity-mode) -> generate an ermrest request to get the displaynames.
799
+ *
800
+ * NOTE This function will not return the null filter.
801
+ * NOTE the request might not return any result for a given filter (because of user access or missing data),
802
+ * in this case, we will return the raw value instead.
803
+ *
804
+ * @param contextHeaderParams object that we want to be logged with the request
805
+ * @return A promise resolved with list of objects that have `uniqueId`, and `displayname`.
806
+ */
807
+ async getChoiceDisplaynames(contextHeaderParams: any): Promise<FacetChoiceDisplayName[]> {
808
+ const filters: FacetChoiceDisplayName[] = [];
809
+ const table = this._column.table;
810
+ const column = this._column;
811
+ const columnName = this._column.name;
812
+ // whether the output must be displayed as markdown or not
813
+ const isHTML = _HTMLColumnType.indexOf(this._column.type.name) !== -1;
814
+
815
+ const createRef = (filterStrs: string[]): Reference => {
816
+ const uri = [
817
+ table.schema.catalog.server.uri,
818
+ 'catalog',
819
+ table.schema.catalog.id,
820
+ 'entity',
821
+ fixedEncodeURIComponent(table.schema.name) + ':' + fixedEncodeURIComponent(table.name),
822
+ filterStrs.join(';'),
823
+ ].join('/');
824
+
825
+ let ref = new Reference(parse(uri), table.schema.catalog).contextualize.compactSelect;
826
+ ref = ref.sort([{ column: columnName, descending: false }]);
827
+ return ref;
828
+ };
829
+
830
+ const convertChoiceFilter = (f: ChoiceFacetFilter): FacetChoiceDisplayName => {
831
+ let displayedValue = f.toString();
832
+ if (column.type.name === 'json' || column.type.name === 'jsonb') {
833
+ /**
834
+ * If the value is already a valid string representation of a JSON object,
835
+ * return it as is. otherwise use the toString which will turn the object into a string.
836
+ */
837
+ try {
838
+ void JSON.parse(f.term as any);
839
+ displayedValue = f.term as string;
840
+ } catch {
841
+ /* empty */
842
+ }
843
+ }
844
+ return {
845
+ uniqueId: f.term,
846
+ displayname: {
847
+ value: isHTML ? renderMarkdown(displayedValue!, true) : displayedValue!,
848
+ isHTML: isHTML,
849
+ },
850
+ tuple: null,
851
+ };
852
+ };
853
+
854
+ // if no filter, just return empty list.
855
+ if (this.choiceFilters.length === 0) {
856
+ return filters;
857
+ }
858
+ // in scalar mode, use the their toString as displayname.
859
+ else if (!this.isEntityMode) {
860
+ this.choiceFilters.forEach((f: ChoiceFacetFilter) => {
861
+ // don't return the null filter
862
+ if (f.term === null || f.term === undefined) return;
863
+
864
+ filters.push(convertChoiceFilter(f));
865
+ });
866
+ return filters;
867
+ }
868
+ // otherwise generate an ermrest request to get the displaynames.
869
+ else {
870
+ // if we already fetched the page, then just use it
871
+ if (this.sourceObjectWrapper.entityChoiceFilterTuples) {
872
+ this.sourceObjectWrapper.entityChoiceFilterTuples.forEach((t: Tuple) => {
873
+ filters.push({ uniqueId: t.data[columnName], displayname: t.displayname, tuple: t });
874
+ });
875
+ return filters;
876
+ }
877
+
878
+ const filterStr: string[] = []; // used for generating the request
879
+ const filterTerms: Record<string, number> = {}; // used for figuring out if there are any filter that didn't return result
880
+ // key is the term, value is the index in self.choiceFilters
881
+
882
+ // list of filters that we want their displaynames.
883
+ this.choiceFilters.forEach((f: ChoiceFacetFilter, index: number) => {
884
+ // don't return the null filter
885
+ if (f.term == null) {
886
+ return;
887
+ }
888
+
889
+ filterStr.push(fixedEncodeURIComponent(columnName) + '=' + fixedEncodeURIComponent(f.term as string));
890
+ filterTerms[f.term as string] = index;
891
+ });
892
+
893
+ // the case that we have only the null value.
894
+ if (filterStr.length === 0) {
895
+ return filters;
896
+ }
897
+
898
+ try {
899
+ // create a url
900
+ const page = await createRef(filterStr).read(this.choiceFilters.length, contextHeaderParams, true);
901
+
902
+ // add the pages that we got
903
+ page.tuples.forEach((t: Tuple) => {
904
+ filters.push({ uniqueId: t.data[columnName], displayname: t.displayname, tuple: t });
905
+
906
+ // remove it from the term list
907
+ delete filterTerms[t.data[columnName]];
908
+ });
909
+
910
+ // if there are any filter terms that didn't match any rows, just return the raw value:
911
+ // NOTE we could merge these two (page and filter) together to make the code easier to follow,
912
+ // but we want to keep the selected values ordered based on roworder and
913
+ // not based on the order of filters in the url
914
+
915
+ // sort the keys for deterministic output (based on the original order of filters in the url)
916
+ const filterTermKeys = Object.keys(filterTerms).sort((a: string, b: string) => {
917
+ return filterTerms[a] - filterTerms[b];
918
+ });
919
+
920
+ // add the terms to filter list
921
+ filterTermKeys.forEach((k: string) => {
922
+ filters.push(convertChoiceFilter(this.choiceFilters[filterTerms[k]]));
923
+ });
924
+
925
+ return filters;
926
+ } catch (err: unknown) {
927
+ throw ErrorService.responseToError(err);
928
+ }
929
+ }
930
+ }
931
+
932
+ /**
933
+ * Return JSON presentation of the filters. This will be used in the location.
934
+ * Anything that we want to leak to the url should be here.
935
+ * It will be in the following format:
936
+ *
937
+ * ```
938
+ * {
939
+ * "source": <data-source>,
940
+ * "choices": [v, ...],
941
+ * "ranges": [{"min": v1, "max": v2}, ...],
942
+ * "search": [v, ...],
943
+ * "not_null": true
944
+ * }
945
+ * ```
946
+ */
947
+ toJSON(): any {
948
+ const res: any = { source: Array.isArray(this.dataSource) ? this.dataSource.slice() : this.dataSource };
949
+
950
+ // to avoid adding more than one null for json.
951
+ const hasJSONNull: Record<string, boolean> = {};
952
+ for (let i = 0, f; i < this.filters.length; i++) {
953
+ f = this.filters[i];
954
+
955
+ if (f.facetFilterKey === 'not_null' || f instanceof NotNullFacetFilter) {
956
+ res.not_null = true;
957
+ continue;
958
+ }
959
+
960
+ if (!(f.facetFilterKey! in res)) {
961
+ res[f.facetFilterKey!] = [];
962
+ }
963
+
964
+ if ((this._column.type.name === 'json' || this._column.type.name === 'jsonb') && f.facetFilterKey === 'choices') {
965
+ /*
966
+ * We cannot distinguish between json `null` in sql and actual `null`,
967
+ * therefore in other parts of code we're treating them the same.
968
+ * But to generate the filters, we have to add these two cases,
969
+ * that's why we're adding two values for null.
970
+ */
971
+ if (f.term === null || f.term === 'null') {
972
+ if (!hasJSONNull[f.facetFilterKey!]) {
973
+ res[f.facetFilterKey!].push(null, 'null');
974
+ }
975
+ hasJSONNull[f.facetFilterKey!] = true;
976
+ } else {
977
+ let value = f.term;
978
+ /**
979
+ * If the value is already a valid string representation of a JSON object,
980
+ * return it as is. otherwise turn it into a string.
981
+ */
982
+ try {
983
+ void JSON.parse(f.term as any);
984
+ } catch {
985
+ value = JSON.stringify(f.term);
986
+ }
987
+
988
+ res[f.facetFilterKey!].push(value);
989
+ }
990
+ } else {
991
+ res[f.facetFilterKey!].push(f.toJSON());
992
+ }
993
+ }
994
+
995
+ return res;
996
+ }
997
+
998
+ /**
999
+ * Given an object will create list of filters.
1000
+ *
1001
+ * NOTE: if we have not_null, other filters except =null are not relevant.
1002
+ * That means if we saw not_null:
1003
+ * 1. If =null exist, then set the filters to empty array.
1004
+ * 2. otherwise set the filter to just the not_null
1005
+ *
1006
+ * Expected object format format:
1007
+ * ```
1008
+ * {
1009
+ * "source": <data-source>,
1010
+ * "choices": [v, ...],
1011
+ * "ranges": [{"min": v1, "max": v2}, ...],
1012
+ * "search": [v, ...],
1013
+ * "not_null": true
1014
+ * }
1015
+ * ```
1016
+ *
1017
+ * @param json JSON representation of filters
1018
+ */
1019
+ private _setFilters(json: any): void {
1020
+ let current: FacetFilter | undefined;
1021
+ let hasNotNull = false;
1022
+ this.filters = [];
1023
+
1024
+ if (!isDefinedAndNotNull(json)) {
1025
+ return;
1026
+ }
1027
+
1028
+ // if there's a not_null other filters are not applicable.
1029
+ if (json.not_null === true) {
1030
+ this.filters.push(new NotNullFacetFilter());
1031
+ hasNotNull = true;
1032
+ }
1033
+
1034
+ // create choice filters
1035
+ if (Array.isArray(json[_facetFilterTypes.CHOICE])) {
1036
+ json[_facetFilterTypes.CHOICE].forEach((ch: any) => {
1037
+ /*
1038
+ * We cannot distinguish between json `null` in sql and actual `null`,
1039
+ * therefore we should treat them the same.
1040
+ */
1041
+ if (this._column.type.name === 'json' || this._column.type.name === 'jsonb') {
1042
+ if (ch === null || ch === 'null') {
1043
+ ch = null;
1044
+ }
1045
+ }
1046
+
1047
+ if (hasNotNull) {
1048
+ // if not-null filter exists, the only relevant filter is =null.
1049
+ // Other filters will be ignored.
1050
+ // If =null exist, we are removing all the filters.
1051
+ if (ch === null) {
1052
+ this.filters = [];
1053
+ }
1054
+ return;
1055
+ }
1056
+
1057
+ current = this.filters.filter((f: FacetFilter | NotNullFacetFilter) => {
1058
+ return f instanceof ChoiceFacetFilter && f.term === ch;
1059
+ })[0] as ChoiceFacetFilter | undefined;
1060
+
1061
+ if (current !== undefined) {
1062
+ return; // don't add duplicate
1063
+ }
1064
+
1065
+ this.filters.push(new ChoiceFacetFilter(ch, this._column));
1066
+ });
1067
+ }
1068
+
1069
+ // create range filters
1070
+ if (!hasNotNull && Array.isArray(json[_facetFilterTypes.RANGE])) {
1071
+ json[_facetFilterTypes.RANGE].forEach((ch: any) => {
1072
+ current = this.filters.filter((f: FacetFilter | NotNullFacetFilter) => {
1073
+ return f instanceof RangeFacetFilter && f.min === ch.min && f.max === ch.max;
1074
+ })[0] as RangeFacetFilter | undefined;
1075
+
1076
+ if (current !== undefined) {
1077
+ return; // don't add duplicate
1078
+ }
1079
+
1080
+ this.filters.push(new RangeFacetFilter(ch.min, ch.min_exclusive, ch.max, ch.max_exclusive, this._column));
1081
+ });
1082
+ }
1083
+
1084
+ // create search filters
1085
+ if (!hasNotNull && Array.isArray(json[_facetFilterTypes.SEARCH])) {
1086
+ json[_facetFilterTypes.SEARCH].forEach((ch: unknown) => {
1087
+ current = this.filters.filter((f: FacetFilter | NotNullFacetFilter) => {
1088
+ return f instanceof SearchFacetFilter && f.term === ch;
1089
+ })[0] as SearchFacetFilter | undefined;
1090
+
1091
+ if (current !== undefined) {
1092
+ return; // don't add duplicate
1093
+ }
1094
+
1095
+ this.filters.push(new SearchFacetFilter(ch, this._column));
1096
+ });
1097
+ }
1098
+ }
1099
+
1100
+ /**
1101
+ * Returns true if the not-null filter exists.
1102
+ */
1103
+ get hasNotNullFilter(): boolean {
1104
+ if (this._hasNotNullFilter === undefined) {
1105
+ this._hasNotNullFilter =
1106
+ this.filters.filter((f: FacetFilter | NotNullFacetFilter) => {
1107
+ return f instanceof NotNullFacetFilter;
1108
+ })[0] !== undefined;
1109
+ }
1110
+ return this._hasNotNullFilter;
1111
+ }
1112
+
1113
+ /**
1114
+ * Returns true if choice null filter exists.
1115
+ */
1116
+ get hasNullFilter(): boolean {
1117
+ if (this._hasNullFilter === undefined) {
1118
+ this._hasNullFilter =
1119
+ this.filters.filter((f: FacetFilter | NotNullFacetFilter) => {
1120
+ return f instanceof ChoiceFacetFilter && (f.term === null || f.term === undefined);
1121
+ })[0] !== undefined;
1122
+ }
1123
+ return this._hasNullFilter;
1124
+ }
1125
+
1126
+ /**
1127
+ * search filters
1128
+ * NOTE ASSUMES that filters is immutable
1129
+ */
1130
+ get searchFilters(): SearchFacetFilter[] {
1131
+ if (this._searchFilters === undefined) {
1132
+ this._searchFilters = this.filters.filter((f: FacetFilter | NotNullFacetFilter) => {
1133
+ return f instanceof SearchFacetFilter;
1134
+ }) as SearchFacetFilter[];
1135
+ }
1136
+ return this._searchFilters;
1137
+ }
1138
+
1139
+ /**
1140
+ * choice filters
1141
+ * NOTE ASSUMES that filters is immutable
1142
+ */
1143
+ get choiceFilters(): ChoiceFacetFilter[] {
1144
+ if (this._choiceFilters === undefined) {
1145
+ this._choiceFilters = this.filters.filter((f: FacetFilter | NotNullFacetFilter) => {
1146
+ return f instanceof ChoiceFacetFilter;
1147
+ }) as ChoiceFacetFilter[];
1148
+ }
1149
+ return this._choiceFilters;
1150
+ }
1151
+
1152
+ /**
1153
+ * range filters
1154
+ * NOTE ASSUMES that filters is immutable
1155
+ */
1156
+ get rangeFilters(): RangeFacetFilter[] {
1157
+ if (this._rangeFilters === undefined) {
1158
+ this._rangeFilters = this.filters.filter((f: FacetFilter | NotNullFacetFilter) => {
1159
+ return f instanceof RangeFacetFilter;
1160
+ }) as RangeFacetFilter[];
1161
+ }
1162
+ return this._rangeFilters;
1163
+ }
1164
+
1165
+ /**
1166
+ * Create a new Reference with appending a new Search filter to current FacetColumn
1167
+ * @param term the term for search
1168
+ * @return the Reference with the new filter
1169
+ */
1170
+ addSearchFilter(term: string): Reference {
1171
+ verify(isDefinedAndNotNull(term), '`term` is required.');
1172
+
1173
+ const filters = this.filters.slice();
1174
+ filters.push(new SearchFacetFilter(term, this._column));
1175
+
1176
+ return this._applyFilters(filters);
1177
+ }
1178
+
1179
+ /**
1180
+ * Create a new Reference with appending a list of choice filters to current FacetColumn
1181
+ * @return the reference with the new filter
1182
+ */
1183
+ addChoiceFilters(values: unknown[]): Reference {
1184
+ verify(Array.isArray(values), 'given argument must be an array');
1185
+
1186
+ const filters = this.filters.slice();
1187
+ values.forEach((v: any) => {
1188
+ filters.push(new ChoiceFacetFilter(v, this._column));
1189
+ });
1190
+
1191
+ return this._applyFilters(filters);
1192
+ }
1193
+
1194
+ /**
1195
+ * Create a new Reference with replacing choice facet filters by the given input
1196
+ * This will also remove NotNullFacetFilter
1197
+ * @return the reference with the new filter
1198
+ */
1199
+ replaceAllChoiceFilters(values: unknown[]): Reference {
1200
+ verify(Array.isArray(values), 'given argument must be an array');
1201
+ const filters = this.filters.slice().filter((f: FacetFilter | NotNullFacetFilter) => {
1202
+ return !(f instanceof ChoiceFacetFilter) && !(f instanceof NotNullFacetFilter);
1203
+ });
1204
+ values.forEach((v: any) => {
1205
+ filters.push(new ChoiceFacetFilter(v, this._column));
1206
+ });
1207
+
1208
+ return this._applyFilters(filters);
1209
+ }
1210
+
1211
+ /**
1212
+ * Given a term, it will remove any choice filter with that term (if any).
1213
+ * @param terms array of terms
1214
+ * @return the reference with the new filter
1215
+ */
1216
+ removeChoiceFilters(terms: unknown[]): Reference {
1217
+ verify(Array.isArray(terms), 'given argument must be an array');
1218
+ const filters = this.filters.slice().filter((f: FacetFilter | NotNullFacetFilter) => {
1219
+ return !(f instanceof ChoiceFacetFilter) || terms.indexOf(f.term) === -1;
1220
+ });
1221
+ return this._applyFilters(filters);
1222
+ }
1223
+
1224
+ /**
1225
+ * Create a new Reference with appending a new range filter to current FacetColumn
1226
+ * @param min minimum value. Can be null or undefined.
1227
+ * @param minExclusive whether the minimum boundary is exclusive or not.
1228
+ * @param max maximum value. Can be null or undefined.
1229
+ * @param maxExclusive whether the maximum boundary is exclusive or not.
1230
+ * @return the reference with the new filter
1231
+ */
1232
+ addRangeFilter(min?: unknown, minExclusive?: boolean, max?: unknown, maxExclusive?: boolean): FacetFilterResult | false {
1233
+ verify(isDefinedAndNotNull(min) || isDefinedAndNotNull(max), 'One of min and max must be defined.');
1234
+
1235
+ const current = this.filters.filter((f: FacetFilter | NotNullFacetFilter) => {
1236
+ // eslint-disable-next-line eqeqeq
1237
+ return f instanceof RangeFacetFilter && f.min === min && f.max === max && f.minExclusive == minExclusive && f.maxExclusive == maxExclusive;
1238
+ })[0];
1239
+
1240
+ if (current !== undefined) {
1241
+ return false;
1242
+ }
1243
+
1244
+ const filters = this.filters.slice();
1245
+ const newFilter = new RangeFacetFilter(min, minExclusive || false, max, maxExclusive || false, this._column);
1246
+ filters.push(newFilter);
1247
+
1248
+ return {
1249
+ reference: this._applyFilters(filters),
1250
+ filter: newFilter,
1251
+ };
1252
+ }
1253
+
1254
+ /**
1255
+ * Create a new Reference with removing any range filter that has the given min and max combination.
1256
+ * @param min minimum value. Can be null or undefined.
1257
+ * @param minExclusive whether the minimum boundary is exclusive or not.
1258
+ * @param max maximum value. Can be null or undefined.
1259
+ * @param maxExclusive whether the maximum boundary is exclusive or not.
1260
+ * @return the reference with the new filter
1261
+ */
1262
+ removeRangeFilter(min?: unknown, minExclusive?: boolean, max?: unknown, maxExclusive?: boolean): FacetFilterResult {
1263
+ //TODO needs refactoring
1264
+ verify(isDefinedAndNotNull(min) || isDefinedAndNotNull(max), 'One of min and max must be defined.');
1265
+ const filters = this.filters.filter((f: FacetFilter | NotNullFacetFilter) => {
1266
+ return (
1267
+ // eslint-disable-next-line eqeqeq
1268
+ !(f instanceof RangeFacetFilter) || !(f.min === min && f.max === max && f.minExclusive == minExclusive && f.maxExclusive == maxExclusive)
1269
+ );
1270
+ });
1271
+ return {
1272
+ reference: this._applyFilters(filters),
1273
+ };
1274
+ }
1275
+
1276
+ /**
1277
+ * Create a new Reference with removing all the filters and adding a not-null filter.
1278
+ * NOTE based on current usecases this is currently removing all the previous filters.
1279
+ * We might need to change this behavior in the future. I could change the behavior of
1280
+ * this function to only add the filter, and then in the client first remove all and thenadd
1281
+ * addNotNullFilter, but since the code is not very optimized that would result on a heavy
1282
+ * operation.
1283
+ */
1284
+ addNotNullFilter(): Reference {
1285
+ return this._applyFilters([new NotNullFacetFilter()]);
1286
+ }
1287
+
1288
+ /**
1289
+ * Create a new Reference without any filters.
1290
+ */
1291
+ removeNotNullFilter(): Reference {
1292
+ const filters = this.filters.filter((f: FacetFilter | NotNullFacetFilter) => {
1293
+ return !(f instanceof NotNullFacetFilter);
1294
+ });
1295
+ return this._applyFilters(filters);
1296
+ }
1297
+
1298
+ /**
1299
+ * Create a new Reference by removing all the filters from current facet.
1300
+ * @return the reference with the new filter
1301
+ */
1302
+ removeAllFilters(): Reference {
1303
+ return this._applyFilters([]);
1304
+ }
1305
+
1306
+ /**
1307
+ * Create a new Reference by removing a filter from current facet.
1308
+ * @param index index of element that we want to remove from list
1309
+ * @return the reference with the new filter
1310
+ */
1311
+ removeFilter(index: number): Reference {
1312
+ const filters = this.filters.slice();
1313
+ filters.splice(index, 1);
1314
+
1315
+ return this._applyFilters(filters);
1316
+ }
1317
+
1318
+ /**
1319
+ * Given an array of {@link ERMrest.FacetFilter}, will return a new
1320
+ * {@link ERMrest.Reference} with the applied filters to the current FacetColumn
1321
+ * @private
1322
+ * @param filters array of filters
1323
+ * @return the reference with the new filter
1324
+ */
1325
+ private _applyFilters(filters: (FacetFilter | NotNullFacetFilter)[]): Reference {
1326
+ const loc = this.reference.location;
1327
+ const newReference = this.reference.copy();
1328
+ const facets: FacetColumn[] = [];
1329
+
1330
+ // create a new FacetColumn so it doesn't reference to the current FacetColum
1331
+ // TODO can be refactored
1332
+ const jsonFilters: any[] = [];
1333
+
1334
+ // TODO might be able to imporve this. Instead of recreating the whole json file.
1335
+ // gather all the filters from the facetColumns
1336
+ // NOTE: this part can be improved so we just change one JSON element.
1337
+ let newFc: FacetColumn;
1338
+ this.reference.facetColumns.forEach((fc: FacetColumn) => {
1339
+ if (fc.index !== this.index) {
1340
+ newFc = new FacetColumn(newReference, fc.index, fc.sourceObjectWrapper, fc.filters.slice() as FacetFilter[]);
1341
+ } else {
1342
+ newFc = new FacetColumn(newReference, this.index, this.sourceObjectWrapper, filters as FacetFilter[]);
1343
+ }
1344
+
1345
+ facets.push(newFc);
1346
+
1347
+ if (newFc.filters.length !== 0) {
1348
+ jsonFilters.push(newFc.toJSON());
1349
+ }
1350
+ });
1351
+
1352
+ newReference.manuallySetFacetColumns(facets);
1353
+ newReference.setLocation(this.reference.location._clone(newReference));
1354
+ newReference.location.beforeObject = null;
1355
+ newReference.location.afterObject = null;
1356
+
1357
+ // TODO might be able to improve this
1358
+ if (typeof loc.searchTerm === 'string') {
1359
+ jsonFilters.push({ sourcekey: _specialSourceDefinitions.SEARCH_BOX, search: [this.reference.location.searchTerm] });
1360
+ }
1361
+
1362
+ // apply the hidden facets
1363
+ if (loc.facets) {
1364
+ loc.facets.andFilters.forEach((f: any) => {
1365
+ if (f.hidden) {
1366
+ jsonFilters.push(f);
1367
+ }
1368
+ });
1369
+ }
1370
+
1371
+ // change the facets in location object
1372
+ if (jsonFilters.length > 0) {
1373
+ newReference.location.facets = { and: jsonFilters };
1374
+ } else {
1375
+ newReference.location.facets = null;
1376
+ }
1377
+
1378
+ return newReference;
1379
+ }
1380
+ }