Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- A malformed filter snapshot no longer brings the grid down. `filtering`'s
`hydrate` cast the slice straight to `FilterModel`, so an operator that
belonged to another kind, a `set` whose `values` was not a list, or a
condition missing the value its operator needs all threw while the pipeline
was reading them - a throw inside a `$derived` costs the render pass, not one
column. Six of the seven shapes measured against 1.3.0 threw. The slice is
now sanitized at the boundary: what cannot be read is dropped, and a column
left with nothing stops filtering, which shows more rows rather than none.
- The filter predicates no longer assume the condition handed to them is well
formed. An unknown operator, a missing value, a `set` whose values are not a
list and a kind nothing knows now pass every row instead of throwing. This is
the layer that covers `applyFilterModel`, which an app can call with a model
it read back from its own storage.
- `sorting`'s `hydrate` checked `Array.isArray` and then cast, so a null entry
in the array threw on `columnId`. Entries that do not name a column and a
direction are now dropped.

## [1.3.0] - 2026-08-24

### Added
Expand Down
50 changes: 41 additions & 9 deletions src/lib/features/filtering/filter-predicates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ import { getCellValue, isBlank } from '../../core/utils/index.js'
import { setKeyOf } from './distinct-values.js'
import { normalizeFilterEntry } from './filter-model.js'

/**
* What an unreadable condition does: nothing. `sanitizeFilterModel` is the
* boundary that should have dropped it, and this is the layer that keeps a
* condition arriving some other way out of the pipeline's `$derived`, where a
* throw costs the whole render pass rather than one column.
*/
const PASSES = (): boolean => true

export function filterTypeOf<TRow>(def: ColumnDef<TRow>): FilterType | null {
if (def.filter === false || def.filter === undefined) return null
return typeof def.filter === 'string' ? def.filter : def.filter.type
Expand All @@ -24,18 +32,27 @@ function customPredicateOf<TRow>(
return typeof def.filter === 'object' ? def.filter.predicate : undefined
}

/** Folding once here keeps `toLowerCase` out of the per-row loop. */
function foldedQuery(filter: Extract<ColumnFilter, { kind: 'text' }>): {
query: string
read: (value: unknown) => string
} {
const fold = filter.caseSensitive
? (text: string) => text
: (text: string) => text.toLowerCase()
return {
query: fold(String(filter.value ?? '').trim()),
read: (value: unknown) => fold(String(value))
}
}

function textPredicate(
filter: Extract<ColumnFilter, { kind: 'text' }>
): (value: unknown) => boolean {
if (filter.op === 'blank') return (value) => isBlank(value)
if (filter.op === 'notBlank') return (value) => !isBlank(value)

// Folding once here keeps `toLowerCase` out of the per-row loop.
const fold = filter.caseSensitive
? (text: string) => text
: (text: string) => text.toLowerCase()
const query = fold(filter.value.trim())
const read = (value: unknown) => fold(String(value))
const { query, read } = foldedQuery(filter)

switch (filter.op) {
case 'equals':
Expand All @@ -52,12 +69,16 @@ function textPredicate(
return (value) => !isBlank(value) && read(value).includes(query)
case 'notContains':
return (value) => isBlank(value) || !read(value).includes(query)
default:
return PASSES
}
}

type NumberComparator = (value: number, target: number) => boolean

const numberComparators: Record<
Exclude<NumberFilterOp, 'blank' | 'notBlank' | 'between'>,
(value: number, target: number) => boolean
NumberComparator
> = {
eq: (value, target) => value === target,
neq: (value, target) => value !== target,
Expand All @@ -83,7 +104,12 @@ function numberPredicate(
return numeric >= target && numeric <= to
}
}
const compare = numberComparators[filter.op]
// Widened on purpose: the key is exhaustive by type, and an operator that
// reached here from outside the type system is exactly what this catches.
const compare = (numberComparators as Partial<Record<NumberFilterOp, NumberComparator>>)[
filter.op
]
if (!compare) return PASSES
return (value) => !isBlank(value) && compare(Number(value), target)
}

Expand Down Expand Up @@ -134,10 +160,13 @@ function datePredicate(
const day = toEpochDay(value)
return day >= target && day <= to
}
default:
return PASSES
}
}

function setPredicate(filter: Extract<ColumnFilter, { kind: 'set' }>): (value: unknown) => boolean {
if (!Array.isArray(filter.values)) return PASSES
// Keyed on both sides by the same function the value list is built with, or
// the cell holding a Date is never the entry the user picked, and a filter
// that came back through a snapshot is never the one that went in.
Expand All @@ -163,6 +192,8 @@ export function valuePredicateFor(filter: ColumnFilter): (value: unknown) => boo
return setPredicate(filter)
case 'boolean':
return booleanPredicate(filter)
default:
return PASSES
}
}

Expand All @@ -174,7 +205,8 @@ function entryPredicate<TRow>(
const { join, conditions } = normalizeFilterEntry(entry)
const custom = customPredicateOf(def)

const tests = conditions.map((condition) => {
const listed = Array.isArray(conditions) ? conditions : []
const tests = listed.map((condition) => {
if (custom) return (value: unknown, row: TRow) => custom(value, row, condition)
const predicate = valuePredicateFor(condition)
return (value: unknown) => predicate(value)
Expand Down
224 changes: 224 additions & 0 deletions src/lib/features/filtering/filter-sanitize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import { describe, expect, it } from 'vitest'
import { createDataGrid } from '../../core/grid/index.js'
import type { ColumnDef, ColumnFilter } from '../../core/types/index.js'
import { filtering, getFiltering, sanitizeFilterModel } from './index.js'

interface Row {
id: number
total: number
name: string
when: string
ok: boolean
}

const columns: ColumnDef<Row>[] = [
{ id: 'total', header: 'Total', filter: 'number' },
{ id: 'name', header: 'Name', filter: 'text' },
{ id: 'when', header: 'When', filter: 'date' },
{ id: 'ok', header: 'Ok', filter: 'boolean' }
]

const data: Row[] = [
{ id: 1, total: 10, name: 'alpha', when: '2026-01-02', ok: true },
{ id: 2, total: 20, name: 'beta', when: '2026-02-02', ok: false }
]

function gridWith(columnId: string, filter: unknown) {
const grid = createDataGrid<Row>({
columns,
data,
getRowId: (row) => String(row.id),
features: [filtering()]
})
grid.setState({
version: 1,
features: { filtering: { quick: '', columns: { [columnId]: filter } } }
} as never)
return grid
}

describe('sanitizeFilterModel', () => {
it('keeps a model the editor could have built', () => {
const model = sanitizeFilterModel({
quick: 'ph',
columns: {
total: { kind: 'number', op: 'gt', value: 15 },
name: { kind: 'text', op: 'contains', value: 'al', caseSensitive: true }
}
})

expect(model).toEqual({
quick: 'ph',
columns: {
total: { kind: 'number', op: 'gt', value: 15 },
name: { kind: 'text', op: 'contains', value: 'al', caseSensitive: true }
}
})
})

it('reads a group, dropping only the conditions that are broken', () => {
const model = sanitizeFilterModel({
quick: '',
columns: {
name: {
kind: 'group',
join: 'or',
conditions: [
{ kind: 'text', op: 'contains', value: 'al' },
{ kind: 'text', op: 'eq', value: 'beta' }
]
}
}
})

expect(model?.columns.name).toEqual({
kind: 'group',
join: 'or',
conditions: [{ kind: 'text', op: 'contains', value: 'al' }]
})
})

it('drops a group whose conditions are not a list, and one left with none', () => {
const model = sanitizeFilterModel({
columns: {
name: { kind: 'group', join: 'and', conditions: 'nope' },
when: { kind: 'group', join: 'and', conditions: [{ kind: 'date', op: 'gt' }] }
}
})

expect(model).toEqual({ quick: '', columns: {} })
})

it('takes a presence operator with no value, in either spelling', () => {
const model = sanitizeFilterModel({
columns: {
name: { kind: 'text', op: 'blank' },
total: { kind: 'number', op: 'notBlank' }
}
})

expect(model?.columns).toEqual({
name: { kind: 'text', op: 'blank', value: '' },
total: { kind: 'number', op: 'notBlank' }
})
})

it('rejects a slice that is not an object', () => {
expect(sanitizeFilterModel(null)).toBeNull()
expect(sanitizeFilterModel('filtered')).toBeNull()
expect(sanitizeFilterModel([])).toBeNull()
})

it('falls back to an empty quick filter rather than carrying junk', () => {
expect(sanitizeFilterModel({ quick: 42, columns: 'nope' })).toEqual({
quick: '',
columns: {}
})
})
})

describe('hydrating a malformed filter snapshot', () => {
// One case per row of the table measured on 1.3.0, where six of the seven
// threw while the pipeline was reading them.
const cases: [string, string, unknown][] = [
[
'a number operator spelled as the text one',
'total',
{
kind: 'number',
op: 'equals',
value: 10
}
],
[
'a text operator spelled as the number one',
'name',
{
kind: 'text',
op: 'eq',
value: 'alpha'
}
],
[
'a date operator spelled as the number one',
'when',
{
kind: 'date',
op: 'gt',
value: '2026-01-01'
}
],
['a set whose values are not a list', 'name', { kind: 'set', values: {} }],
['a kind nothing knows', 'name', { kind: 'nonsense', op: 'eq' }],
['a text condition with no value', 'name', { kind: 'text', op: 'contains' }],
['a boolean value that is not a boolean', 'ok', { kind: 'boolean', value: 'yes' }]
]

for (const [label, columnId, filter] of cases) {
it(`reads every row and does not throw: ${label}`, () => {
const grid = gridWith(columnId, filter)

expect(() => grid.nodes).not.toThrow()
expect(grid.nodes).toHaveLength(2)
expect(getFiltering(grid)?.columnFilters).toEqual({})
})
}

it('keeps the readable column when another is dropped', () => {
const grid = createDataGrid<Row>({
columns,
data,
getRowId: (row) => String(row.id),
features: [filtering()]
})
grid.setState({
version: 1,
features: {
filtering: {
quick: '',
columns: {
total: { kind: 'number', op: 'gt', value: 15 },
name: { kind: 'text', op: 'eq', value: 'alpha' }
}
}
}
} as never)

expect(grid.nodes).toHaveLength(1)
expect(grid.nodes[0]?.row.name).toBe('beta')
})
})

describe('a broken condition set through the public API', () => {
// `applyFilterModel` does not sanitize: it is typed, and an app calling it
// has said what the model is. The predicate layer is what keeps a model
// that lied from reaching the pipeline as a throw.
const conditions: [string, string, ColumnFilter][] = [
['an operator from another kind', 'total', { kind: 'number', op: 'equals' } as never],
['a text operator from another kind', 'name', { kind: 'text', op: 'eq' } as never],
['a date operator from another kind', 'when', { kind: 'date', op: 'gt' } as never],
['a set whose values are not a list', 'name', { kind: 'set', values: {} } as never],
['a kind nothing knows', 'name', { kind: 'nonsense' } as never],
['a text condition with no value', 'name', { kind: 'text', op: 'contains' } as never],
[
'a group whose conditions are not a list',
'name',
{ kind: 'group', join: 'and', conditions: 'nope' } as never
]
]

for (const [label, columnId, condition] of conditions) {
it(`passes every row rather than throwing: ${label}`, () => {
const grid = createDataGrid<Row>({
columns,
data,
getRowId: (row) => String(row.id),
features: [filtering()]
})
getFiltering(grid)?.applyFilterModel({ quick: '', columns: { [columnId]: condition } })

expect(() => grid.nodes).not.toThrow()
expect(grid.nodes).toHaveLength(2)
})
}
})
Loading