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: 14 additions & 5 deletions docs/8.filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,9 @@ export const numberFilter: FilterFunction<Record<string, unknown>> = (
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);
};
Expand All @@ -129,9 +130,15 @@ export const booleanFilter: FilterFunction<Record<string, unknown>> = (
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);
};
```

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

Expand Down
3 changes: 3 additions & 0 deletions skills/data-api-interface/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/internal/default-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { ownEntry } from "./meta-records";

type ProxyFilterOperator = (
proxy: ValueProxy<unknown>,
value: string,
value: FilterValue[0],
) => ValueProxyOrValue<boolean>;

type DefaultFilterOperators = Record<Comparison, ProxyFilterOperator>;
Expand All @@ -36,7 +36,7 @@ const DEFAULT_FILTER_OPERATORS: DefaultFilterOperators = {
*/
function applyDefaultFilterMode(
proxy: ValueProxy<unknown>,
value: string,
value: FilterValue[0],
mode: FilterValue[1],
): ValueProxyOrValue<boolean> {
const operator = ownEntry(DEFAULT_FILTER_OPERATORS, mode);
Expand Down
10 changes: 6 additions & 4 deletions src/internal/field-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
124 changes: 124 additions & 0 deletions src/tests/components/boolean_filter.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, FieldFilter>) {
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"]);
});
});
4 changes: 3 additions & 1 deletion src/tests/components/computed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 3 additions & 1 deletion src/tests/components/foreign_computed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading