fix(web): handle array-style date values in object view filters
YAML-parsed date_between/between filters store range values as a two-element array under `value`, but the filter system expected separate value/valueTo fields. Add normalizeFilterGroup at the YAML read boundary and defensive array handling in the evaluator and SQL builder so both formats work correctly. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
38215f08ea
commit
00baf45a2f
227
apps/web/lib/object-filters.test.ts
Normal file
227
apps/web/lib/object-filters.test.ts
Normal file
@ -0,0 +1,227 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
normalizeFilterRule,
|
||||
normalizeFilterGroup,
|
||||
matchesFilter,
|
||||
buildWhereClause,
|
||||
type FilterRule,
|
||||
type FilterGroup,
|
||||
type FieldMeta,
|
||||
} from "./object-filters";
|
||||
|
||||
// ─── normalizeFilterRule ───
|
||||
|
||||
describe("normalizeFilterRule", () => {
|
||||
it("splits array value into value/valueTo for date_between", () => {
|
||||
const rule: FilterRule = {
|
||||
id: "r1",
|
||||
field: "Due Date",
|
||||
operator: "date_between",
|
||||
value: ["2026-03-01", "2026-03-31"],
|
||||
};
|
||||
const result = normalizeFilterRule(rule);
|
||||
expect(result.value).toBe("2026-03-01");
|
||||
expect(result.valueTo).toBe("2026-03-31");
|
||||
});
|
||||
|
||||
it("splits array value into value/valueTo for numeric between", () => {
|
||||
const rule: FilterRule = {
|
||||
id: "r2",
|
||||
field: "Score",
|
||||
operator: "between",
|
||||
value: ["10", "50"] as unknown as string[],
|
||||
};
|
||||
const result = normalizeFilterRule(rule);
|
||||
expect(result.value).toBe("10");
|
||||
expect(result.valueTo).toBe("50");
|
||||
});
|
||||
|
||||
it("does not touch date_between when valueTo already set", () => {
|
||||
const rule: FilterRule = {
|
||||
id: "r3",
|
||||
field: "Due Date",
|
||||
operator: "date_between",
|
||||
value: "2026-03-01",
|
||||
valueTo: "2026-03-31",
|
||||
};
|
||||
const result = normalizeFilterRule(rule);
|
||||
expect(result.value).toBe("2026-03-01");
|
||||
expect(result.valueTo).toBe("2026-03-31");
|
||||
});
|
||||
|
||||
it("does not touch non-range operators", () => {
|
||||
const rule: FilterRule = {
|
||||
id: "r4",
|
||||
field: "Status",
|
||||
operator: "is_any_of",
|
||||
value: ["In Progress", "Done"],
|
||||
};
|
||||
const result = normalizeFilterRule(rule);
|
||||
expect(result.value).toEqual(["In Progress", "Done"]);
|
||||
expect(result.valueTo).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── normalizeFilterGroup ───
|
||||
|
||||
describe("normalizeFilterGroup", () => {
|
||||
it("recursively normalizes nested groups", () => {
|
||||
const group: FilterGroup = {
|
||||
id: "root",
|
||||
conjunction: "and",
|
||||
rules: [
|
||||
{
|
||||
id: "f1",
|
||||
field: "Due Date",
|
||||
operator: "date_between",
|
||||
value: ["2026-03-01", "2026-03-31"],
|
||||
} as FilterRule,
|
||||
{
|
||||
id: "nested",
|
||||
conjunction: "or",
|
||||
rules: [
|
||||
{
|
||||
id: "f2",
|
||||
field: "Score",
|
||||
operator: "between",
|
||||
value: ["1", "100"] as unknown as string[],
|
||||
} as FilterRule,
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = normalizeFilterGroup(group);
|
||||
|
||||
const r1 = result.rules[0] as FilterRule;
|
||||
expect(r1.value).toBe("2026-03-01");
|
||||
expect(r1.valueTo).toBe("2026-03-31");
|
||||
|
||||
const nested = result.rules[1] as FilterGroup;
|
||||
const r2 = nested.rules[0] as FilterRule;
|
||||
expect(r2.value).toBe("1");
|
||||
expect(r2.valueTo).toBe("100");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── matchesFilter with array-style date_between ───
|
||||
|
||||
describe("matchesFilter with array-style date_between", () => {
|
||||
const entries = [
|
||||
{ "Due Date": "2026-03-10" },
|
||||
{ "Due Date": "2026-02-28" },
|
||||
{ "Due Date": "2026-04-01" },
|
||||
{ "Due Date": "2026-03-01" },
|
||||
{ "Due Date": "2026-03-31" },
|
||||
];
|
||||
|
||||
it("correctly filters with normalized value/valueTo", () => {
|
||||
const filters: FilterGroup = {
|
||||
id: "root",
|
||||
conjunction: "and",
|
||||
rules: [
|
||||
{
|
||||
id: "f1",
|
||||
field: "Due Date",
|
||||
operator: "date_between",
|
||||
value: "2026-03-01",
|
||||
valueTo: "2026-03-31",
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = matchesFilter(entries, filters);
|
||||
expect(result).toEqual([
|
||||
{ "Due Date": "2026-03-10" },
|
||||
{ "Due Date": "2026-03-01" },
|
||||
{ "Due Date": "2026-03-31" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("correctly filters with array-style value (defensive fallback)", () => {
|
||||
const filters: FilterGroup = {
|
||||
id: "root",
|
||||
conjunction: "and",
|
||||
rules: [
|
||||
{
|
||||
id: "f1",
|
||||
field: "Due Date",
|
||||
operator: "date_between",
|
||||
value: ["2026-03-01", "2026-03-31"],
|
||||
} as FilterRule,
|
||||
],
|
||||
};
|
||||
const result = matchesFilter(entries, filters);
|
||||
expect(result).toEqual([
|
||||
{ "Due Date": "2026-03-10" },
|
||||
{ "Due Date": "2026-03-01" },
|
||||
{ "Due Date": "2026-03-31" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildWhereClause with array-style date_between ───
|
||||
|
||||
describe("buildWhereClause with array-style date_between", () => {
|
||||
const fields: FieldMeta[] = [{ name: "Due Date", type: "date" }];
|
||||
|
||||
it("builds correct SQL with value/valueTo", () => {
|
||||
const filters: FilterGroup = {
|
||||
id: "root",
|
||||
conjunction: "and",
|
||||
rules: [
|
||||
{
|
||||
id: "f1",
|
||||
field: "Due Date",
|
||||
operator: "date_between",
|
||||
value: "2026-03-01",
|
||||
valueTo: "2026-03-31",
|
||||
},
|
||||
],
|
||||
};
|
||||
const sql = buildWhereClause(filters, fields);
|
||||
expect(sql).toBe(
|
||||
`((CAST("Due Date" AS DATE) BETWEEN '2026-03-01' AND '2026-03-31'))`,
|
||||
);
|
||||
});
|
||||
|
||||
it("builds correct SQL with array-style value (defensive fallback)", () => {
|
||||
const filters: FilterGroup = {
|
||||
id: "root",
|
||||
conjunction: "and",
|
||||
rules: [
|
||||
{
|
||||
id: "f1",
|
||||
field: "Due Date",
|
||||
operator: "date_between",
|
||||
value: ["2026-03-01", "2026-03-31"],
|
||||
} as FilterRule,
|
||||
],
|
||||
};
|
||||
const sql = buildWhereClause(filters, fields);
|
||||
expect(sql).toBe(
|
||||
`((CAST("Due Date" AS DATE) BETWEEN '2026-03-01' AND '2026-03-31'))`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildWhereClause with array-style numeric between ───
|
||||
|
||||
describe("buildWhereClause with array-style numeric between", () => {
|
||||
const fields: FieldMeta[] = [{ name: "Score", type: "number" }];
|
||||
|
||||
it("builds correct SQL with array-style value", () => {
|
||||
const filters: FilterGroup = {
|
||||
id: "root",
|
||||
conjunction: "and",
|
||||
rules: [
|
||||
{
|
||||
id: "f1",
|
||||
field: "Score",
|
||||
operator: "between",
|
||||
value: ["10", "50"] as unknown as string[],
|
||||
} as FilterRule,
|
||||
],
|
||||
};
|
||||
const sql = buildWhereClause(filters, fields);
|
||||
expect(sql).toBe(`((CAST("Score" AS DOUBLE) BETWEEN 10 AND 50))`);
|
||||
});
|
||||
});
|
||||
@ -209,6 +209,43 @@ export function isFilterGroup(
|
||||
return "conjunction" in rule && "rules" in rule;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// YAML normalization — convert array-style range values to value/valueTo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RANGE_OPS = new Set<FilterOperator>(["date_between", "between"]);
|
||||
|
||||
/**
|
||||
* Normalize a single filter rule so that array-style range values
|
||||
* (e.g. `value: ["2026-03-01", "2026-03-31"]`) are split into
|
||||
* separate `value` and `valueTo` fields.
|
||||
*/
|
||||
export function normalizeFilterRule(rule: FilterRule): FilterRule {
|
||||
if (
|
||||
RANGE_OPS.has(rule.operator) &&
|
||||
Array.isArray(rule.value) &&
|
||||
rule.value.length >= 2 &&
|
||||
rule.valueTo == null
|
||||
) {
|
||||
return { ...rule, value: rule.value[0], valueTo: rule.value[1] };
|
||||
}
|
||||
return rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively normalize all filter rules in a group.
|
||||
*/
|
||||
export function normalizeFilterGroup(group: FilterGroup): FilterGroup {
|
||||
return {
|
||||
...group,
|
||||
rules: group.rules.map((r) =>
|
||||
isFilterGroup(r)
|
||||
? normalizeFilterGroup(r)
|
||||
: normalizeFilterRule(r),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client-side evaluator
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -347,8 +384,9 @@ function evaluateRule(
|
||||
case "lte":
|
||||
return n <= v!;
|
||||
case "between": {
|
||||
const lo = coerceNumber(rule.value);
|
||||
const hi = coerceNumber(rule.valueTo);
|
||||
const arr = Array.isArray(rule.value) ? rule.value : null;
|
||||
const lo = coerceNumber(arr ? arr[0] : rule.value);
|
||||
const hi = coerceNumber(arr ? arr[1] : rule.valueTo);
|
||||
if (lo == null || hi == null) {return false;}
|
||||
return n >= lo && n <= hi;
|
||||
}
|
||||
@ -391,8 +429,9 @@ function evaluateRule(
|
||||
case "after":
|
||||
return dateStr > target;
|
||||
case "date_between": {
|
||||
const from = coerceString(rule.value);
|
||||
const to = coerceString(rule.valueTo);
|
||||
const arr = Array.isArray(rule.value) ? rule.value : null;
|
||||
const from = coerceString(arr ? arr[0] : rule.value);
|
||||
const to = coerceString(arr ? arr[1] : rule.valueTo);
|
||||
return dateStr >= from && dateStr <= to;
|
||||
}
|
||||
}
|
||||
@ -538,8 +577,9 @@ function buildRuleSQL(rule: FilterRule, fields: FieldMeta[]): string | null {
|
||||
if (op === "lt" && numVal != null) {return `(CAST(${col} AS DOUBLE) < ${numVal})`;}
|
||||
if (op === "lte" && numVal != null) {return `(CAST(${col} AS DOUBLE) <= ${numVal})`;}
|
||||
if (op === "between") {
|
||||
const lo = coerceNumber(rule.value);
|
||||
const hi = coerceNumber(rule.valueTo);
|
||||
const arr = Array.isArray(rule.value) ? rule.value : null;
|
||||
const lo = coerceNumber(arr ? arr[0] : rule.value);
|
||||
const hi = coerceNumber(arr ? arr[1] : rule.valueTo);
|
||||
if (lo != null && hi != null) {return `(CAST(${col} AS DOUBLE) BETWEEN ${lo} AND ${hi})`;}
|
||||
}
|
||||
|
||||
@ -557,8 +597,9 @@ function buildRuleSQL(rule: FilterRule, fields: FieldMeta[]): string | null {
|
||||
return `(CAST(${col} AS DATE) > '${sqlEscape(d)}')`;
|
||||
}
|
||||
if (op === "date_between") {
|
||||
const from = coerceString(rule.value);
|
||||
const to = coerceString(rule.valueTo);
|
||||
const arr = Array.isArray(rule.value) ? rule.value : null;
|
||||
const from = coerceString(arr ? arr[0] : rule.value);
|
||||
const to = coerceString(arr ? arr[1] : rule.valueTo);
|
||||
return `(CAST(${col} AS DATE) BETWEEN '${sqlEscape(from)}' AND '${sqlEscape(to)}')`;
|
||||
}
|
||||
if (op === "relative_past") {
|
||||
|
||||
@ -4,7 +4,7 @@ import { promisify } from "node:util";
|
||||
import { join, resolve, normalize, relative } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import YAML from "yaml";
|
||||
import type { SavedView } from "./object-filters";
|
||||
import { normalizeFilterGroup, type SavedView } from "./object-filters";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@ -869,7 +869,10 @@ export function getObjectViews(objectName: string): {
|
||||
if (!config) {return { views: [], activeView: undefined };}
|
||||
|
||||
return {
|
||||
views: config.views ?? [],
|
||||
views: (config.views ?? []).map((v) => ({
|
||||
...v,
|
||||
filters: v.filters ? normalizeFilterGroup(v.filters) : undefined,
|
||||
})),
|
||||
activeView: config.active_view,
|
||||
};
|
||||
}
|
||||
|
||||
@ -117,6 +117,19 @@ views:
|
||||
active_view: "Active deals"
|
||||
```
|
||||
|
||||
**Date format**: All date filter values MUST use ISO 8601 `YYYY-MM-DD` strings (e.g. `"2026-03-01"`). The special value `today` is also supported for `on`, `before`, and `after` operators.
|
||||
|
||||
**Date range filter** (`date_between`):
|
||||
|
||||
```yaml
|
||||
- id: f1
|
||||
field: Due Date
|
||||
operator: date_between
|
||||
value:
|
||||
- "2026-03-01"
|
||||
- "2026-03-31"
|
||||
```
|
||||
|
||||
**Relative date filters** (e.g. "in the last 7 days"):
|
||||
|
||||
```yaml
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user