@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 +9 -0
- package/dist/index.d.ts +28 -2
- package/dist/index.js +94 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
|
882
|
-
return arr.some((x) =>
|
|
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
|
|
892
|
-
const hay = v.map(
|
|
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
|