diff --git a/docs/8.filters.md b/docs/8.filters.md index c8fa086..2adf9f4 100644 --- a/docs/8.filters.md +++ b/docs/8.filters.md @@ -116,8 +116,9 @@ export const numberFilter: FilterFunction> = ( raw, mode, ) => { - const value = Number(raw); - assert(raw.trim() !== "" && Number.isFinite(value), 400, "Invalid number"); + const text = typeof raw === "string" ? raw.trim() : ""; + const value = Number(text); + assert(text !== "" && Number.isFinite(value), 400, "Invalid number"); assert(isComparison(mode), 400, "Invalid comparison"); return proxy[mode](value); }; @@ -129,9 +130,15 @@ export const booleanFilter: FilterFunction> = ( raw, mode, ) => { - assert(raw === "true" || raw === "false", 400, "Invalid boolean"); + const value = + raw === true || raw === "true" + ? true + : raw === false || raw === "false" + ? false + : undefined; + assert(value !== undefined, 400, "Invalid boolean"); assert(mode === "eq" || mode === "ne", 400, "Invalid comparison"); - return proxy[mode](raw === "true"); + return proxy[mode](value); }; ``` @@ -145,7 +152,9 @@ declare age: number; declare isActive: boolean; ``` -With these custom filters, `?filter_age=gt:30&filter_isActive=true` compares against `30` and `true`, not `"30"` and `"true"`. Keep `useIndex` false for a filter that parses its value. An indexed equality lookup uses the raw string and skips the filter function. +With these custom filters, `?filter_age=gt:30&filter_isActive=true` compares against `30` and `true`, not `"30"` and `"true"`. Keep `useIndex` false for a filter that parses its value. An indexed equality lookup uses the raw value and skips the filter function. + +A filter value is `string | boolean` (`FilterValue`). A request always sends text. A route may write a boolean into the filters it passes on, as an archive filter does, and every reader hands that value to the database unchanged: the field's filter, the default filter and the indexed lookup. ## Custom filters diff --git a/skills/data-api-interface/SKILL.md b/skills/data-api-interface/SKILL.md index 3547f53..70f2a82 100644 --- a/skills/data-api-interface/SKILL.md +++ b/skills/data-api-interface/SKILL.md @@ -125,6 +125,9 @@ This exposes `GET /tasks/get?id=`, `GET /tasks/list`, `POST /tasks/new`, `PUT /t filterable. Repeating a field's filter (`filter_age=ge:18&filter_age=lt:65`) keeps the rows passing them all; the route then receives a list of tuples for it (`FieldFilter`, read with `FilterValuesOf`). + A tuple is `FilterValue = [value: string | boolean, mode: string]`: a request sends text, and a + route may write a boolean (an archive filter does), which reaches the filter function, the default + filter and the indexed lookup unchanged. A custom filter narrows `value` before it parses text. - `@Joined` and `@Computed` fields are materialized in-database, so `@Sortable` and `@Filter()` work efficiently on them — but those decorators must still be applied explicitly. Such fields are forced read-only and to non-indexed sorting. diff --git a/src/internal/default-filter.ts b/src/internal/default-filter.ts index 5ee6110..414db8b 100644 --- a/src/internal/default-filter.ts +++ b/src/internal/default-filter.ts @@ -12,7 +12,7 @@ import { ownEntry } from "./meta-records"; type ProxyFilterOperator = ( proxy: ValueProxy, - value: string, + value: FilterValue[0], ) => ValueProxyOrValue; type DefaultFilterOperators = Record; @@ -36,7 +36,7 @@ const DEFAULT_FILTER_OPERATORS: DefaultFilterOperators = { */ function applyDefaultFilterMode( proxy: ValueProxy, - value: string, + value: FilterValue[0], mode: FilterValue[1], ): ValueProxyOrValue { const operator = ownEntry(DEFAULT_FILTER_OPERATORS, mode); diff --git a/src/internal/field-types.ts b/src/internal/field-types.ts index 4fb2a55..a8d7fb6 100644 --- a/src/internal/field-types.ts +++ b/src/internal/field-types.ts @@ -236,11 +236,13 @@ export type Comparison = "eq" | "ne" | "gt" | "ge" | "lt" | "le"; /** * One filter of a list request: the value, and the mode to compare it with. - * The mode is text: what the request sent, or what the route wrote. The - * default filter refuses a mode that is not a {@link Comparison}. A custom - * filter defines its own modes. + * A request sends the value as text; a route may write a boolean, as an + * archive filter does, and every reader hands the value to the database as + * it is. The mode is text: what the request sent, or what the route wrote. + * The default filter refuses a mode that is not a {@link Comparison}. A + * custom filter defines its own modes. */ -export type FilterValue = [value: string, mode: string]; +export type FilterValue = [value: string | boolean, mode: string]; /** * The filters of one field in a list request: one {@link FilterValue}, or diff --git a/src/tests/components/boolean_filter.test.ts b/src/tests/components/boolean_filter.test.ts new file mode 100644 index 0000000..fb94e52 --- /dev/null +++ b/src/tests/components/boolean_filter.test.ts @@ -0,0 +1,124 @@ +import { expect } from "chai"; +import { Controller } from "@antelopejs/interface-api"; +import { Schema } from "@antelopejs/interface-database"; +import { + BasicDataModel, + Field, + Index, + RegisterSchema, + RegisterTable, + Table, +} from "@antelopejs/interface-database-decorators"; + +import { Query } from "../../components"; +import { DataController, RegisterDataController } from "../../index"; +import { + Access, + AccessMode, + Filter, + type FieldFilter, + GetDataControllerMeta, + Listable, +} from "../../metadata"; +import { createRequestContext } from "../internal/http"; +import { getSchemaInstance } from "../utils"; + +const tableName = "boolean-filter-documents"; +const schemaName = "default"; + +@RegisterTable(tableName, schemaName) +class Document extends Table { + declare _id: string; + + @Field("string") + declare title: string; + + @Index() + @Field("boolean") + declare archived: boolean; + + @Field("boolean") + declare pinned: boolean; +} +class DocumentModel extends BasicDataModel(Document, tableName) {} + +@RegisterDataController() +class _BooleanFilterAPI extends DataController( + Document, + {}, + Controller("/boolean-filter"), +) { + @Listable() + @Access(AccessMode.ReadOnly) + declare title: string; + + @Listable() + @Access(AccessMode.ReadOnly) + @Filter() + declare archived: boolean; + + @Listable() + @Access(AccessMode.ReadOnly) + @Filter((_context, proxy, _key, value) => proxy.eq(value), false) + declare pinned: boolean; +} + +const reqCtx = createRequestContext(); + +async function seedDocuments() { + await RegisterSchema(schemaName); + await Schema.get(schemaName)?.instance().table(tableName).delete().run(); + const model = new DocumentModel(getSchemaInstance(schemaName)); + await model.insert([ + { title: "kept", archived: false, pinned: true }, + { title: "old", archived: true, pinned: false }, + { title: "older", archived: true, pinned: true }, + ]); + return model; +} + +/** The titles a list filtered by `filters` answers, and the stages of its source. */ +async function listTitles(filters: Record) { + const model = await seedDocuments(); + const thisObj = new _BooleanFilterAPI(); + const meta = GetDataControllerMeta(thisObj); + const [query] = Query.List( + thisObj, + meta, + model.table, + reqCtx, + undefined, + filters, + ); + const rows = await query.run(); + return { + titles: rows.map((row) => row.title).sort((a, b) => a.localeCompare(b)), + stages: query.build().map((entry) => entry.stage), + }; +} + +describe("A boolean filter value a route writes", () => { + it("reaches the indexed lookup of an equality filter", async () => { + const { titles, stages } = await listTitles({ archived: [true, "eq"] }); + + expect({ titles, indexed: stages.includes("getAll") }).to.eql({ + titles: ["old", "older"], + indexed: true, + }); + }); + + it("reaches the default filter of a comparison", async () => { + const { titles, stages } = await listTitles({ archived: [true, "ne"] }); + + expect({ titles, indexed: stages.includes("getAll") }).to.eql({ + titles: ["kept"], + indexed: false, + }); + }); + + it("reaches a field's own filter", async () => { + const { titles } = await listTitles({ pinned: [true, "eq"] }); + + expect(titles).to.eql(["kept", "older"]); + }); +}); diff --git a/src/tests/components/computed.test.ts b/src/tests/components/computed.test.ts index 3e2526c..929e808 100644 --- a/src/tests/components/computed.test.ts +++ b/src/tests/components/computed.test.ts @@ -142,7 +142,9 @@ async function _createDataController(testName: string) { { default: 0 }, ) @Sortable() - @Filter((_context, proxy, _key, value) => proxy.eq(parseFloat(value))) + @Filter((_context, proxy, _key, value) => + proxy.eq(parseFloat(String(value))), + ) declare devices_count: number; @Listable() diff --git a/src/tests/components/foreign_computed.test.ts b/src/tests/components/foreign_computed.test.ts index 071f88e..a84cece 100644 --- a/src/tests/components/foreign_computed.test.ts +++ b/src/tests/components/foreign_computed.test.ts @@ -109,7 +109,9 @@ async function _createDataController(testName: string) { @Listable() @Access(AccessMode.ReadWrite) @Filter((_context, proxy, _key, value, mode) => - mode === "ge" ? proxy.ge(parseFloat(value)) : proxy.lt(parseFloat(value)), + mode === "ge" + ? proxy.ge(parseFloat(String(value))) + : proxy.lt(parseFloat(String(value))), ) declare age: number;