@pythia-software/query-table-core 0.4.1 → 0.5.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
@@ -44,9 +44,18 @@ Text filters include `matches_regex` / `not_matches_regex`. An order term may
44
44
  set `extract: { regex: "..." }` to compare the first capture group (or the whole
45
45
  match when there is no capture group); non-matches sort as nulls.
46
46
 
47
+ Text fields also support `length_gt`, `length_lt`, and `length_eq` with a
48
+ non-negative integer value. Length counts Unicode code points. `length_eq` with
49
+ `"0"` matches empty strings but excludes NULL; `length_gt` with `"0"` matches
50
+ nonempty strings.
51
+
47
52
  See the [repository README](https://github.com/Pythia-Software/query-table#readme)
48
53
  for the complete schema and backend documentation.
49
54
 
50
55
  ## Computed SELECT definitions
51
56
 
52
57
  Exports include `ComputedColumnStore`, `httpComputedColumnStore`, `memoryComputedColumnStore`, `compileFormula`, `FORMULA_FUNCTIONS`, and `formulaRuntime`. Shared source definitions are versioned separately from queries; query SELECT entries use `{ field: "@computed/<id>" }`. The React package executes the interpreter in a bounded worker. Do not run user regex on the browser main thread. See the repository’s `docs/computed-columns.md` for semantics and persistence details.
58
+
59
+ For case-sensitive array keys, set `filter: { arrayCaseSensitive: true }` on the
60
+ `textarray` field. The default remains case-insensitive. This setting applies to
61
+ local filtering and metrics and to PostgreSQL array membership.
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /** All filter operators across both source projects, unioned. Every value can
2
2
  * be NULL, so `is_null`/`is_not_null` are valid for every field type. */
3
- type FilterOp = "=" | "!=" | ">" | ">=" | "<" | "<=" | "contains" | "starts_with" | "ends_with" | "matches_regex" | "not_matches_regex" | "includes" | "is_null" | "is_not_null";
3
+ type FilterOp = "=" | "!=" | ">" | ">=" | "<" | "<=" | "contains" | "starts_with" | "ends_with" | "matches_regex" | "not_matches_regex" | "length_gt" | "length_lt" | "length_eq" | "includes" | "is_null" | "is_not_null";
4
4
  /** A single filter predicate (a "literal" in CNF terms). `value` is always the
5
5
  * raw string the UI captured; coercion to number/bool/date happens at
6
6
  * apply/compile time based on the field's declared type (never on the value's
@@ -17,12 +17,21 @@ interface WhereClause {
17
17
  op: FilterOp;
18
18
  value: string;
19
19
  negated?: boolean;
20
+ setFilter?: SetFilterMetadata;
21
+ }
22
+ type SetFilterMode = "any" | "all" | "none" | "empty";
23
+ /** Presentation-only identity; execution always uses the accompanying predicates. */
24
+ interface SetFilterMetadata {
25
+ id: string;
26
+ mode: SetFilterMode;
27
+ values: string[];
20
28
  }
21
29
  /** A disjunction of predicates — the inner OR of conjunctive normal form. Every
22
30
  * member is a plain literal; nesting is intentionally not allowed yet (CNF
23
31
  * only), but the object shape leaves room to grow. */
24
32
  interface OrGroup {
25
33
  any: WhereClause[];
34
+ setFilter?: SetFilterMetadata;
26
35
  }
27
36
  /** One conjunct of the WHERE clause: either a single predicate or an OR group.
28
37
  * `QueryState.where` is the AND of these terms, so the whole WHERE is
@@ -180,6 +189,10 @@ type FilterValues = {
180
189
  source: "freeform";
181
190
  };
182
191
  interface FilterConfig {
192
+ /** Opt into the native multi-value editor for textarray fields. */
193
+ editor?: "set";
194
+ /** Preserve case when matching textarray values. Default false. */
195
+ arrayCaseSensitive?: boolean;
183
196
  /** Whether this field is filterable. Default: backend ⇒ true, derived ⇒ false. */
184
197
  enabled?: boolean;
185
198
  /** Evaluate on the backend (predicate pushdown)? Default: backend ⇒ true.
@@ -606,4 +619,17 @@ interface ColumnPreview {
606
619
  }
607
620
  declare function groupPreview(dependencies: string[], inputs: Record<string, FormulaValue>[], results: FormulaResult[], total: number): ColumnPreview;
608
621
 
609
- export { AGG_OPS_BY_TYPE, AGG_OPS_NEEDING_FIELD, type AggOp, type AggregateConfig, type AggregationBucket, type AggregationClause, type AggregationRequest, type AggregationResult, type AggregationResultEntry, type Align, type ApplyResult, type BackendField, COMPUTED_PREFIX, type CellRenderer, type ColumnPreview, type ComputedCellError, type ComputedColumn, type ComputedColumnDraft, type ComputedColumnStore, type DerivedField, type DistinctValuesQuery, type DistinctValuesResult, EMPTY_QUERY, FORMULA_FUNCTIONS, type FetchRowsResult, type FieldDef, type FieldSchema, type FieldSource, type FieldStats, type FieldType, type FilterConfig, type FilterOp, type FilterValues, type FormulaDiagnostic, FormulaError, type FormulaFunction, type FormulaNode, type FormulaPlan, type FormulaResult, type FormulaType, type FormulaValue, MAX_AGGREGATIONS, MAX_GROUP_BY_FIELDS, MAX_ORDER_BY_TERMS, MAX_QUERY_LIMIT, MAX_QUERY_OFFSET, MAX_QUERY_TOKEN_LENGTH, MAX_SELECT_COLUMNS, MAX_WHERE_CLAUSES, NULLARY_OPS, OPS_BY_TYPE, type OpChoice, type OpPair, type OrGroup, type OrderByClause, type PreviewGroup, type QueryState, type RegexInspection, type RowId, type SavedQuery, type SelectColumn, type SelectConfig, type ServerQuery, type SortConfig, type StorageAdapter, type Transport, type WhereClause, type WhereTerm, aggOpAllowedForType, aggOpNeedsField, aggOpsForField, applyAggregations, applyQuery, coerceValue, compileFormula, computedFieldName, decodeQuery, encodeQuery, filterValues, formulaRuntime, groupPreview, httpComputedColumnStore, indexFields, isComputedCellError, isComputedField, isFilterable, isGroupable, isMeasurable, isNegativePredicate, isOrGroup, isPushdownFilter, isSelectable, isSortable, loadSchema, localStorageAdapter, matchesClause, memoryComputedColumnStore, memoryStorageAdapter, negateClause, normalizeQueryState, opAllowedForType, opPairsForField, opsForField, predicatesOf, queriesEqual, readFieldValue, selectedFields, toAggregationQuery, toServerQuery, validateComputedColumn };
622
+ /** Compile an explicitly identified editor into existing CNF predicates. */
623
+ declare function createSetFilter(field: string, mode: SetFilterMode, values: string[], id: string): WhereTerm[];
624
+ /** Remove presentation metadata at transport boundaries, including inner literals. */
625
+ declare function stripSetFilter(term: WhereTerm): WhereTerm;
626
+ /** Only collapse explicitly marked, intact editor output. Never infer from predicates. */
627
+ declare function readSetFilter(where: WhereTerm[], index: number): {
628
+ field: string;
629
+ metadata: SetFilterMetadata;
630
+ count: number;
631
+ } | null;
632
+ /** Canonicalize consumer-owned value aliases without disconnecting editor metadata. */
633
+ declare function mapSetFilterValues(where: WhereTerm[], canonicalize: (field: string, value: string) => string): WhereTerm[];
634
+
635
+ export { AGG_OPS_BY_TYPE, AGG_OPS_NEEDING_FIELD, type AggOp, type AggregateConfig, type AggregationBucket, type AggregationClause, type AggregationRequest, type AggregationResult, type AggregationResultEntry, type Align, type ApplyResult, type BackendField, COMPUTED_PREFIX, type CellRenderer, type ColumnPreview, type ComputedCellError, type ComputedColumn, type ComputedColumnDraft, type ComputedColumnStore, type DerivedField, type DistinctValuesQuery, type DistinctValuesResult, EMPTY_QUERY, FORMULA_FUNCTIONS, type FetchRowsResult, type FieldDef, type FieldSchema, type FieldSource, type FieldStats, type FieldType, type FilterConfig, type FilterOp, type FilterValues, type FormulaDiagnostic, FormulaError, type FormulaFunction, type FormulaNode, type FormulaPlan, type FormulaResult, type FormulaType, type FormulaValue, MAX_AGGREGATIONS, MAX_GROUP_BY_FIELDS, MAX_ORDER_BY_TERMS, MAX_QUERY_LIMIT, MAX_QUERY_OFFSET, MAX_QUERY_TOKEN_LENGTH, MAX_SELECT_COLUMNS, MAX_WHERE_CLAUSES, NULLARY_OPS, OPS_BY_TYPE, type OpChoice, type OpPair, type OrGroup, type OrderByClause, type PreviewGroup, type QueryState, type RegexInspection, type RowId, type SavedQuery, type SelectColumn, type SelectConfig, type ServerQuery, type SetFilterMetadata, type SetFilterMode, type SortConfig, type StorageAdapter, type Transport, type WhereClause, type WhereTerm, aggOpAllowedForType, aggOpNeedsField, aggOpsForField, applyAggregations, applyQuery, coerceValue, compileFormula, computedFieldName, createSetFilter, decodeQuery, encodeQuery, filterValues, formulaRuntime, groupPreview, httpComputedColumnStore, indexFields, isComputedCellError, isComputedField, isFilterable, isGroupable, isMeasurable, isNegativePredicate, isOrGroup, isPushdownFilter, isSelectable, isSortable, loadSchema, localStorageAdapter, mapSetFilterValues, matchesClause, memoryComputedColumnStore, memoryStorageAdapter, negateClause, normalizeQueryState, opAllowedForType, opPairsForField, opsForField, predicatesOf, queriesEqual, readFieldValue, readSetFilter, selectedFields, stripSetFilter, toAggregationQuery, toServerQuery, validateComputedColumn };
package/dist/index.js CHANGED
@@ -179,6 +179,9 @@ var FILTER_OPS = /* @__PURE__ */ new Set([
179
179
  "ends_with",
180
180
  "matches_regex",
181
181
  "not_matches_regex",
182
+ "length_gt",
183
+ "length_lt",
184
+ "length_eq",
182
185
  "includes",
183
186
  "is_null",
184
187
  "is_not_null"
@@ -206,7 +209,16 @@ function normalizeWhereClause(item) {
206
209
  return clause;
207
210
  }
208
211
  function cloneWhereTerm(term) {
209
- return isOrGroup(term) ? { any: term.any.map((c) => ({ ...c })) } : { ...term };
212
+ const copy = isOrGroup(term) ? { ...term, any: term.any.map((c) => ({ ...c })) } : { ...term };
213
+ if (term.setFilter) copy.setFilter = { ...term.setFilter, values: [...term.setFilter.values] };
214
+ return copy;
215
+ }
216
+ function normalizeSetFilter(item) {
217
+ if (!isRecord(item)) return void 0;
218
+ const id = boundedString(item.id, MAX_FIELD_NAME_LENGTH);
219
+ if (!id || !["any", "all", "none", "empty"].includes(String(item.mode)) || !Array.isArray(item.values)) return void 0;
220
+ if (item.values.length > MAX_WHERE_CLAUSES || !item.values.every((value) => typeof value === "string" && value.length <= MAX_FILTER_VALUE_LENGTH)) return void 0;
221
+ return { id, mode: item.mode, values: [...item.values] };
210
222
  }
211
223
  function normalizeQueryState(input, fallback = EMPTY_QUERY) {
212
224
  const raw = isRecord(input) ? input : {};
@@ -230,6 +242,7 @@ function normalizeQueryState(input, fallback = EMPTY_QUERY) {
230
242
  let literalBudget = MAX_WHERE_CLAUSES;
231
243
  for (const item of raw.where) {
232
244
  if (literalBudget <= 0) break;
245
+ const start = where.length;
233
246
  if (isRecord(item) && Array.isArray(item.any)) {
234
247
  const members = [];
235
248
  for (const inner of item.any) {
@@ -249,6 +262,10 @@ function normalizeQueryState(input, fallback = EMPTY_QUERY) {
249
262
  literalBudget--;
250
263
  }
251
264
  }
265
+ if (where.length > start && isRecord(item)) {
266
+ const metadata = normalizeSetFilter(item.setFilter);
267
+ if (metadata) where[start].setFilter = metadata;
268
+ }
252
269
  }
253
270
  } else {
254
271
  where.push(...fallback.where.map(cloneWhereTerm));
@@ -487,6 +504,12 @@ var COMPLEMENT_OP = {
487
504
  not_matches_regex: "matches_regex"
488
505
  };
489
506
  function negateClause(clause, allowedOps) {
507
+ if (clause.value === "0" && !clause.negated) {
508
+ const zeroComplement = clause.op === "length_gt" ? "length_eq" : clause.op === "length_eq" ? "length_gt" : null;
509
+ if (zeroComplement && (!allowedOps || allowedOps.includes(zeroComplement))) {
510
+ return { field: clause.field, op: zeroComplement, value: "0" };
511
+ }
512
+ }
490
513
  const complement = COMPLEMENT_OP[clause.op];
491
514
  if (complement && (!allowedOps || allowedOps.includes(complement))) {
492
515
  const next2 = { field: clause.field, op: complement, value: clause.value };
@@ -499,7 +522,7 @@ function negateClause(clause, allowedOps) {
499
522
  function isNegativePredicate(clause) {
500
523
  return Boolean(clause.negated) || clause.op === "!=" || clause.op === "not_matches_regex" || clause.op === "is_not_null";
501
524
  }
502
- var POSITIVE_OP_ORDER = ["=", ">", ">=", "contains", "starts_with", "ends_with", "matches_regex", "includes"];
525
+ var POSITIVE_OP_ORDER = ["=", ">", ">=", "contains", "starts_with", "ends_with", "length_gt", "length_lt", "length_eq", "matches_regex", "includes"];
503
526
  function opPairsForField(field) {
504
527
  const ops = opsForField(field);
505
528
  const allow = new Set(ops);
@@ -523,6 +546,9 @@ var OPS_BY_TYPE = {
523
546
  "ends_with",
524
547
  "matches_regex",
525
548
  "not_matches_regex",
549
+ "length_gt",
550
+ "length_lt",
551
+ "length_eq",
526
552
  ...NULLITY
527
553
  ],
528
554
  enum: ["=", "!=", ...NULLITY],
@@ -597,6 +623,51 @@ function isGroupable(field) {
597
623
  return field.type === "enum" || field.type === "text" || field.type === "bool";
598
624
  }
599
625
 
626
+ // src/setFilter.ts
627
+ function createSetFilter(field, mode, values, id) {
628
+ const keys = [...new Set(values)];
629
+ if (mode !== "empty" && keys.length === 0) return [];
630
+ const includes = keys.map((value) => ({ field, op: "includes", value }));
631
+ let terms;
632
+ if (mode === "empty") terms = [{ field, op: "is_null", value: "" }];
633
+ else if (mode === "any") terms = includes.length === 1 ? includes : [{ any: includes }];
634
+ else if (mode === "all") terms = includes;
635
+ else terms = includes.map((clause) => ({ any: [{ field, op: "is_null", value: "" }, { ...clause, negated: true }] }));
636
+ return terms.map((term) => ({ ...term, setFilter: { id, mode, values: mode === "empty" ? [] : [...keys] } }));
637
+ }
638
+ function stripSetFilter(term) {
639
+ const strip = ({ setFilter: _metadata, ...clause }) => clause;
640
+ return isOrGroup(term) ? { any: term.any.map(strip) } : strip(term);
641
+ }
642
+ function readSetFilter(where, index) {
643
+ const term = where[index];
644
+ const metadata = term?.setFilter;
645
+ if (!term || !metadata || where[index - 1]?.setFilter?.id === metadata.id) return null;
646
+ const field = predicatesOf(term)[0]?.field;
647
+ if (!field) return null;
648
+ const expected = createSetFilter(field, metadata.mode, metadata.values, metadata.id);
649
+ if (expected.length === 0) return null;
650
+ for (let offset = 0; offset < expected.length; offset++) {
651
+ const actual = where[index + offset];
652
+ if (!actual || JSON.stringify(actual.setFilter) !== JSON.stringify(expected[offset].setFilter) || JSON.stringify(stripSetFilter(actual)) !== JSON.stringify(stripSetFilter(expected[offset]))) return null;
653
+ }
654
+ if (where[index + expected.length]?.setFilter?.id === metadata.id) return null;
655
+ return { field, metadata, count: expected.length };
656
+ }
657
+ function mapSetFilterValues(where, canonicalize) {
658
+ const result = [];
659
+ for (let index = 0; index < where.length; index++) {
660
+ const set = readSetFilter(where, index);
661
+ if (!set) {
662
+ result.push(where[index]);
663
+ continue;
664
+ }
665
+ result.push(...createSetFilter(set.field, set.metadata.mode, set.metadata.values.map((value) => canonicalize(set.field, value)), set.metadata.id));
666
+ index += set.count - 1;
667
+ }
668
+ return result;
669
+ }
670
+
600
671
  // src/encode.ts
601
672
  function toBase64Url(json) {
602
673
  let b64;
@@ -667,7 +738,7 @@ function toServerQuery(q, schema) {
667
738
  q = normalizeQueryState(q);
668
739
  const byName = indexFields(schema);
669
740
  const resolveField2 = (name) => resolveFieldName(schema, name) ?? name;
670
- const where = q.where.filter((term) => termIsPushdown(term, byName, resolveField2));
741
+ const where = q.where.filter((term) => termIsPushdown(term, byName, resolveField2)).map(stripSetFilter);
671
742
  const orderBy = [];
672
743
  for (const term of q.orderBy) {
673
744
  const field = resolveField2(term.field);
@@ -705,7 +776,7 @@ function toAggregationQuery(q, schema) {
705
776
  q = normalizeQueryState(q);
706
777
  const byName = indexFields(schema);
707
778
  const resolveField2 = (name) => resolveFieldName(schema, name) ?? name;
708
- const where = q.where.filter((term) => termIsPushdown(term, byName, resolveField2));
779
+ const where = q.where.filter((term) => termIsPushdown(term, byName, resolveField2)).map(stripSetFilter);
709
780
  const isBackend = (name) => {
710
781
  if (name == null) return true;
711
782
  const field = resolveField2(name);
@@ -867,6 +938,9 @@ function matchesWith(byName, row, prepared) {
867
938
  if (clause.value === "" && clause.op !== "=" && clause.op !== "!=" && clause.op !== "is_null" && clause.op !== "is_not_null") {
868
939
  return true;
869
940
  }
941
+ if (clause.op === "length_gt" || clause.op === "length_lt" || clause.op === "length_eq") {
942
+ if (!/^(0|[1-9]\d*)$/.test(clause.value) || !Number.isSafeInteger(Number(clause.value))) return false;
943
+ }
870
944
  const base = matchesBase(field, v, prepared);
871
945
  if (!clause.negated) return base;
872
946
  const isNull = Array.isArray(v) ? v.length === 0 : v == null || v === "";
@@ -876,10 +950,11 @@ function matchesBase(field, v, prepared) {
876
950
  const { clause, regex } = prepared;
877
951
  if (clause.op === "is_null") return Array.isArray(v) ? v.length === 0 : v == null || v === "";
878
952
  if (clause.op === "is_not_null") return Array.isArray(v) ? v.length > 0 : v != null && v !== "";
953
+ const arrayText = (value) => field.filter?.arrayCaseSensitive ? String(value) : String(value).toLowerCase();
879
954
  if (clause.op === "includes") {
880
955
  const arr = Array.isArray(v) ? v : [];
881
- const needle = clause.value.toLowerCase();
882
- return arr.some((x) => String(x).toLowerCase() === needle);
956
+ const needle = arrayText(clause.value);
957
+ return arr.some((x) => arrayText(x) === needle);
883
958
  }
884
959
  if (clause.op === "matches_regex" || clause.op === "not_matches_regex") {
885
960
  if (v == null || Array.isArray(v)) return false;
@@ -887,9 +962,16 @@ function matchesBase(field, v, prepared) {
887
962
  const matches = regex.test(String(v));
888
963
  return clause.op === "matches_regex" ? matches : !matches;
889
964
  }
965
+ if (clause.op === "length_gt" || clause.op === "length_lt" || clause.op === "length_eq") {
966
+ if (typeof v !== "string" || !/^(0|[1-9]\d*)$/.test(clause.value)) return false;
967
+ const limit = Number(clause.value);
968
+ if (!Number.isSafeInteger(limit)) return false;
969
+ const length = Array.from(v).length;
970
+ return clause.op === "length_gt" ? length > limit : clause.op === "length_lt" ? length < limit : length === limit;
971
+ }
890
972
  if (Array.isArray(v)) {
891
- const needle = clause.value.toLowerCase();
892
- const hay = v.map((x) => String(x).toLowerCase());
973
+ const needle = arrayText(clause.value);
974
+ const hay = v.map(arrayText);
893
975
  switch (clause.op) {
894
976
  case "contains":
895
977
  return hay.some((s) => s.includes(needle));
@@ -2239,6 +2321,7 @@ export {
2239
2321
  coerceValue,
2240
2322
  compileFormula,
2241
2323
  computedFieldName,
2324
+ createSetFilter,
2242
2325
  decodeQuery,
2243
2326
  encodeQuery,
2244
2327
  filterValues,
@@ -2258,6 +2341,7 @@ export {
2258
2341
  isSortable,
2259
2342
  loadSchema,
2260
2343
  localStorageAdapter,
2344
+ mapSetFilterValues,
2261
2345
  matchesClause,
2262
2346
  memoryComputedColumnStore,
2263
2347
  memoryStorageAdapter,
@@ -2269,7 +2353,9 @@ export {
2269
2353
  predicatesOf,
2270
2354
  queriesEqual,
2271
2355
  readFieldValue,
2356
+ readSetFilter,
2272
2357
  selectedFields,
2358
+ stripSetFilter,
2273
2359
  toAggregationQuery,
2274
2360
  toServerQuery,
2275
2361
  validateComputedColumn