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:
kumarabhirup 2026-02-22 02:08:25 -08:00
parent 38215f08ea
commit 00baf45a2f
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
4 changed files with 294 additions and 10 deletions

View 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))`);
});
});

View File

@ -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") {

View File

@ -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,
};
}

View File

@ -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