tsense 0.1.0 → 0.2.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -104
- package/dist/filters/filter-state.d.ts +22 -0
- package/dist/filters/filter-state.js +145 -0
- package/dist/filters/index.d.ts +40 -0
- package/dist/filters/index.js +128 -0
- package/dist/filters/url.d.ts +3 -0
- package/dist/filters/url.js +93 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/migrator.d.ts +1 -1
- package/dist/migrator.js +2 -2
- package/dist/rank.d.ts +7 -0
- package/dist/rank.js +12 -0
- package/dist/react/filter-builder.d.ts +60 -0
- package/dist/react/filter-builder.js +102 -0
- package/dist/react/index.d.ts +4 -0
- package/dist/react/index.js +2 -0
- package/dist/react/use-filter-builder.d.ts +23 -0
- package/dist/react/use-filter-builder.js +22 -0
- package/dist/tsense.d.ts +5 -4
- package/dist/tsense.js +151 -92
- package/dist/types.d.ts +37 -10
- package/package.json +69 -50
package/README.md
CHANGED
|
@@ -1,168 +1,163 @@
|
|
|
1
1
|
# TSense
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Fully-typed Typesense client powered by [Arktype](https://arktype.io/).
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Define your schema once. Get type-safe search, filtering, and automatic filter UIs — all from the same source of truth.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
bun add tsense arktype
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## Define a collection
|
|
12
12
|
|
|
13
13
|
```typescript
|
|
14
14
|
import { type } from "arktype";
|
|
15
15
|
import { TSense } from "tsense";
|
|
16
16
|
|
|
17
|
-
const
|
|
17
|
+
const Users = new TSense({
|
|
18
18
|
name: "users",
|
|
19
19
|
schema: type({
|
|
20
20
|
"id?": "string",
|
|
21
|
-
|
|
21
|
+
name: type("string").configure({ sort: true, index: true }),
|
|
22
22
|
age: type("number.integer").configure({ type: "int32", sort: true }),
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
date: "string",
|
|
29
|
-
})
|
|
30
|
-
.array()
|
|
31
|
-
.configure({ type: "object[]", index: false }),
|
|
23
|
+
company: type
|
|
24
|
+
.enumerated("netflix", "google", "apple")
|
|
25
|
+
.configure({ facet: true }),
|
|
26
|
+
active: type("boolean").configure({ facet: true }),
|
|
27
|
+
"joined_at?": "Date",
|
|
32
28
|
}),
|
|
33
29
|
connection: {
|
|
34
30
|
host: "127.0.0.1",
|
|
35
31
|
port: 8108,
|
|
36
32
|
protocol: "http",
|
|
37
|
-
apiKey: "
|
|
33
|
+
apiKey: "xyz123",
|
|
38
34
|
},
|
|
39
35
|
defaultSearchField: "name",
|
|
40
|
-
validateOnUpsert: true,
|
|
41
36
|
});
|
|
37
|
+
```
|
|
42
38
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
await UsersCollection.create();
|
|
39
|
+
## Search with typed filters
|
|
46
40
|
|
|
47
|
-
|
|
48
|
-
{ id: "1", email: "john@example.com", age: 30, name: "John Doe", company: "netflix" },
|
|
49
|
-
{ id: "2", email: "jane@example.com", age: 25, name: "Jane Smith", company: "google" },
|
|
50
|
-
]);
|
|
41
|
+
Operators are restricted per field type — the compiler catches invalid combinations.
|
|
51
42
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
queryBy: ["name", "email"],
|
|
55
|
-
sortBy: ["age:desc", "name:asc"],
|
|
43
|
+
```typescript
|
|
44
|
+
await Users.search({
|
|
56
45
|
filter: {
|
|
57
|
-
age: {
|
|
58
|
-
|
|
46
|
+
age: { gte: 18, lte: 40 },
|
|
47
|
+
company: ["netflix", "google"],
|
|
48
|
+
name: { not: "admin" },
|
|
59
49
|
},
|
|
60
50
|
});
|
|
51
|
+
```
|
|
61
52
|
|
|
62
|
-
|
|
63
|
-
query: "john",
|
|
64
|
-
facetBy: ["company"],
|
|
65
|
-
});
|
|
53
|
+
## Paginated lists
|
|
66
54
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
55
|
+
```typescript
|
|
56
|
+
const page1 = await Users.searchList({
|
|
57
|
+
sortBy: "age:asc",
|
|
58
|
+
filter: { active: true },
|
|
59
|
+
limit: 20,
|
|
70
60
|
});
|
|
71
61
|
|
|
72
|
-
await
|
|
62
|
+
const page2 = await Users.searchList({
|
|
63
|
+
sortBy: "age:asc",
|
|
64
|
+
filter: { active: true },
|
|
65
|
+
limit: 20,
|
|
66
|
+
cursor: page1.nextCursor!,
|
|
67
|
+
});
|
|
73
68
|
```
|
|
74
69
|
|
|
75
|
-
##
|
|
70
|
+
## Count
|
|
76
71
|
|
|
77
|
-
|
|
72
|
+
```typescript
|
|
73
|
+
const total = await Users.count();
|
|
74
|
+
const active = await Users.count({ active: true });
|
|
75
|
+
```
|
|
78
76
|
|
|
79
|
-
|
|
77
|
+
## Multi-tenancy with scoped
|
|
78
|
+
|
|
79
|
+
`scoped()` returns a narrowed instance where a base filter is merged into every operation. The caller cannot override it.
|
|
80
80
|
|
|
81
81
|
```typescript
|
|
82
|
-
|
|
83
|
-
type: "string",
|
|
84
|
-
facet: true,
|
|
85
|
-
sort: true,
|
|
86
|
-
index: true,
|
|
87
|
-
});
|
|
88
|
-
```
|
|
82
|
+
const myUsers = Users.scoped({ owner_id: currentUser.id });
|
|
89
83
|
|
|
90
|
-
|
|
84
|
+
await myUsers.search({ filter: { active: true } });
|
|
85
|
+
// filter_by = "active:=true && owner_id:=`user-1`"
|
|
91
86
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
| `drop()` | Deletes the collection |
|
|
96
|
-
| `get(id)` | Retrieves a document by ID |
|
|
97
|
-
| `delete(id)` | Deletes a document by ID |
|
|
98
|
-
| `deleteMany(filter)` | Deletes documents matching filter |
|
|
99
|
-
| `update(id, data)` | Updates a document by ID |
|
|
100
|
-
| `updateMany(filter, data)` | Updates documents matching filter |
|
|
101
|
-
| `upsert(docs)` | Inserts or updates documents |
|
|
102
|
-
| `search(options)` | Searches the collection |
|
|
103
|
-
| `syncSchema()` | Syncs schema (creates/patches collection) |
|
|
104
|
-
| `syncData(options)` | Syncs data from external source |
|
|
105
|
-
| `fields` | Array of generated field schemas |
|
|
87
|
+
await myUsers.count({ active: true });
|
|
88
|
+
await myUsers.deleteMany({ active: false });
|
|
89
|
+
```
|
|
106
90
|
|
|
107
|
-
|
|
91
|
+
## Build filter UIs from the schema
|
|
108
92
|
|
|
109
|
-
|
|
93
|
+
Declare which fields are filterable. tsense introspects the arktype schema, detects enums, and produces a descriptor that travels to the frontend as JSON.
|
|
110
94
|
|
|
111
95
|
```typescript
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
96
|
+
import { createFilterBuilder } from "tsense/filters";
|
|
97
|
+
|
|
98
|
+
const filters = createFilterBuilder(Users, {
|
|
99
|
+
name: { label: "Name" },
|
|
100
|
+
age: {
|
|
101
|
+
label: "Age",
|
|
102
|
+
presets: { "Over 18": { age: { gte: 18 } } },
|
|
103
|
+
},
|
|
104
|
+
company: {
|
|
105
|
+
label: "Company",
|
|
106
|
+
labels: { netflix: "Netflix", google: "Google", apple: "Apple" },
|
|
107
|
+
},
|
|
108
|
+
active: { label: "Active" },
|
|
109
|
+
joined_at: { label: "Joined At" },
|
|
115
110
|
});
|
|
111
|
+
|
|
112
|
+
const descriptor = filters.describe();
|
|
116
113
|
```
|
|
117
114
|
|
|
118
|
-
|
|
115
|
+
## Render it
|
|
119
116
|
|
|
120
|
-
|
|
121
|
-
|
|
117
|
+
Drop the descriptor into the React component. The user picks columns, conditions, and values. You get a typed `FilterFor<User>` back.
|
|
118
|
+
|
|
119
|
+
```tsx
|
|
120
|
+
import { FilterBuilder } from "tsense/react";
|
|
121
|
+
|
|
122
|
+
<FilterBuilder descriptor={descriptor} onChange={(filter) => search(filter)} />;
|
|
122
123
|
```
|
|
123
124
|
|
|
124
|
-
|
|
125
|
+
Every slot is replaceable via render props — or use the headless `useFilterBuilder` hook for full control.
|
|
126
|
+
|
|
127
|
+
## Wire it end-to-end
|
|
125
128
|
|
|
126
|
-
|
|
129
|
+
Backend validates input with the filter builder schema, scopes results by user, and exposes the descriptor. Frontend renders the builder and fires queries.
|
|
127
130
|
|
|
128
131
|
```typescript
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
.select("id")
|
|
136
|
-
.execute()
|
|
137
|
-
.then((rows) => rows.map((r) => r.id));
|
|
138
|
-
},
|
|
139
|
-
getItems: async (ids) => {
|
|
140
|
-
return db.selectFrom("users").where("id", "in", ids).execute();
|
|
141
|
-
},
|
|
142
|
-
chunkSize: 100, // optional, default 500
|
|
143
|
-
},
|
|
132
|
+
// backend
|
|
133
|
+
const filters = createFilterBuilder(Users, config);
|
|
134
|
+
|
|
135
|
+
const authed = base.use(({ context, next }) => {
|
|
136
|
+
if (!context.user) throw new ORPCError("UNAUTHORIZED");
|
|
137
|
+
return next({ context: { user: context.user } });
|
|
144
138
|
});
|
|
145
139
|
|
|
146
|
-
|
|
147
|
-
|
|
140
|
+
const router = base.router({
|
|
141
|
+
search: authed
|
|
142
|
+
.input(filters.schema())
|
|
143
|
+
.handler(({ input, context }) =>
|
|
144
|
+
Users.scoped({ owner_id: context.user.id }).search(input),
|
|
145
|
+
),
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
148
|
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
```tsx
|
|
150
|
+
// frontend
|
|
151
|
+
import { describe } from "./api/describe" with { type: "macro" };
|
|
151
152
|
|
|
152
|
-
|
|
153
|
-
|
|
153
|
+
const descriptor = describe();
|
|
154
|
+
const [filter, setFilter] = useState<typeof descriptor.infer>({});
|
|
154
155
|
|
|
155
|
-
|
|
156
|
-
|
|
156
|
+
<FilterBuilder descriptor={descriptor} onChange={setFilter} />;
|
|
157
|
+
|
|
158
|
+
const { data: results } = useQuery(api.search, { filter });
|
|
157
159
|
```
|
|
158
160
|
|
|
159
|
-
|
|
161
|
+
The descriptor is inlined at build time via [Bun macros](https://bun.sh/docs/bundler/macros) — no RPC call needed.
|
|
160
162
|
|
|
161
|
-
|
|
162
|
-
filter: { name: "John" } // Exact match
|
|
163
|
-
filter: { age: 30 } // Numeric match
|
|
164
|
-
filter: { age: [25, 30, 35] } // IN
|
|
165
|
-
filter: { age: { min: 20, max: 40 } } // Range
|
|
166
|
-
filter: { name: { not: "John" } } // Not equal
|
|
167
|
-
filter: { OR: [{ age: 25 }, { age: 30 }] } // OR conditions
|
|
168
|
-
```
|
|
163
|
+
A working example with docker-compose, seed data, and a full UI lives in [`examples/`](./examples).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { FilterDescriptor } from "./index.js";
|
|
2
|
+
export type FilterValue = string | string[] | number | boolean | Date | [FilterValue | undefined, FilterValue | undefined] | undefined;
|
|
3
|
+
export type FilterRow = {
|
|
4
|
+
id: number;
|
|
5
|
+
field?: string;
|
|
6
|
+
condition?: string;
|
|
7
|
+
value?: FilterValue;
|
|
8
|
+
};
|
|
9
|
+
export type FilterState = {
|
|
10
|
+
rows: FilterRow[];
|
|
11
|
+
};
|
|
12
|
+
export declare function createInitialState(): FilterState;
|
|
13
|
+
export declare function addRow(state: FilterState): FilterState;
|
|
14
|
+
export declare function removeRow(state: FilterState, index: number): FilterState;
|
|
15
|
+
export declare function setRowField(state: FilterState, index: number, field: string): FilterState;
|
|
16
|
+
export declare function setRowCondition(state: FilterState, index: number, condition: string): FilterState;
|
|
17
|
+
export declare function setRowValue(state: FilterState, index: number, value: FilterValue): FilterState;
|
|
18
|
+
export declare function clearState(): FilterState;
|
|
19
|
+
export declare function applyPreset<T>(state: FilterState, descriptor: FilterDescriptor<T>, field: string, name: string): FilterState;
|
|
20
|
+
export declare function conditionsFor<T>(descriptor: FilterDescriptor<T>, field: string): FilterDescriptor<T>["columns"][number]["conditions"];
|
|
21
|
+
export declare function columnFor<T>(descriptor: FilterDescriptor<T>, field: string): FilterDescriptor<T>["columns"][number];
|
|
22
|
+
export declare function buildResult(state: FilterState): Record<string, unknown>;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
const conditionToOperator = {
|
|
2
|
+
equals: null,
|
|
3
|
+
not_equals: "not",
|
|
4
|
+
gt: "gt",
|
|
5
|
+
gte: "gte",
|
|
6
|
+
lt: "lt",
|
|
7
|
+
lte: "lte",
|
|
8
|
+
between: null,
|
|
9
|
+
is_in: null,
|
|
10
|
+
is_not_in: "notIn",
|
|
11
|
+
};
|
|
12
|
+
let nextRowId = 0;
|
|
13
|
+
export function createInitialState() {
|
|
14
|
+
return { rows: [{ id: ++nextRowId }] };
|
|
15
|
+
}
|
|
16
|
+
export function addRow(state) {
|
|
17
|
+
return { rows: [...state.rows, { id: ++nextRowId }] };
|
|
18
|
+
}
|
|
19
|
+
export function removeRow(state, index) {
|
|
20
|
+
return { rows: state.rows.filter((_, i) => i !== index) };
|
|
21
|
+
}
|
|
22
|
+
export function setRowField(state, index, field) {
|
|
23
|
+
return {
|
|
24
|
+
rows: state.rows.map((row, i) => i === index ? { id: row.id, field } : row),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function setRowCondition(state, index, condition) {
|
|
28
|
+
return {
|
|
29
|
+
rows: state.rows.map((row, i) => i === index ? { ...row, condition, value: undefined } : row),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function setRowValue(state, index, value) {
|
|
33
|
+
return {
|
|
34
|
+
rows: state.rows.map((row, i) => (i === index ? { ...row, value } : row)),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function clearState() {
|
|
38
|
+
return { rows: [] };
|
|
39
|
+
}
|
|
40
|
+
export function applyPreset(state, descriptor, field, name) {
|
|
41
|
+
const column = descriptor.columns.find((c) => c.key === field);
|
|
42
|
+
const preset = column?.presets?.find((p) => p.name === name);
|
|
43
|
+
if (!preset)
|
|
44
|
+
return state;
|
|
45
|
+
const filterValue = preset.filter[field];
|
|
46
|
+
if (filterValue == null)
|
|
47
|
+
return state;
|
|
48
|
+
if (Array.isArray(filterValue)) {
|
|
49
|
+
return {
|
|
50
|
+
rows: [
|
|
51
|
+
...state.rows,
|
|
52
|
+
{ id: ++nextRowId, field, condition: "is_in", value: filterValue },
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (typeof filterValue !== "object" || filterValue instanceof Date) {
|
|
57
|
+
return {
|
|
58
|
+
rows: [
|
|
59
|
+
...state.rows,
|
|
60
|
+
{ id: ++nextRowId, field, condition: "equals", value: filterValue },
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const ops = filterValue;
|
|
65
|
+
const keys = Object.keys(ops);
|
|
66
|
+
if (keys.includes("gte") && keys.includes("lte")) {
|
|
67
|
+
return {
|
|
68
|
+
rows: [
|
|
69
|
+
...state.rows,
|
|
70
|
+
{
|
|
71
|
+
id: ++nextRowId,
|
|
72
|
+
field,
|
|
73
|
+
condition: "between",
|
|
74
|
+
value: [ops.gte, ops.lte],
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (keys[0]) {
|
|
80
|
+
return {
|
|
81
|
+
rows: [
|
|
82
|
+
...state.rows,
|
|
83
|
+
{ id: ++nextRowId, field, condition: keys[0], value: ops[keys[0]] },
|
|
84
|
+
],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return state;
|
|
88
|
+
}
|
|
89
|
+
export function conditionsFor(descriptor, field) {
|
|
90
|
+
const column = descriptor.columns.find((c) => c.key === field);
|
|
91
|
+
return column?.conditions ?? [];
|
|
92
|
+
}
|
|
93
|
+
export function columnFor(descriptor, field) {
|
|
94
|
+
const column = descriptor.columns.find((c) => c.key === field);
|
|
95
|
+
if (!column) {
|
|
96
|
+
throw new Error(`Column not found: ${field}`);
|
|
97
|
+
}
|
|
98
|
+
return column;
|
|
99
|
+
}
|
|
100
|
+
function isRowComplete(row) {
|
|
101
|
+
if (!row.field || !row.condition || row.value == null)
|
|
102
|
+
return false;
|
|
103
|
+
if (row.condition === "between") {
|
|
104
|
+
return (Array.isArray(row.value) && row.value[0] != null && row.value[1] != null);
|
|
105
|
+
}
|
|
106
|
+
if (row.condition === "is_in" || row.condition === "is_not_in") {
|
|
107
|
+
return Array.isArray(row.value) && row.value.length > 0;
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
export function buildResult(state) {
|
|
112
|
+
const result = {};
|
|
113
|
+
for (const row of state.rows) {
|
|
114
|
+
if (!isRowComplete(row))
|
|
115
|
+
continue;
|
|
116
|
+
if (row.condition === "equals" || row.condition === "is_in") {
|
|
117
|
+
result[row.field] = row.value;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (row.condition === "between") {
|
|
121
|
+
const [min, max] = row.value;
|
|
122
|
+
const existing = typeof result[row.field] === "object" && result[row.field] !== null
|
|
123
|
+
? result[row.field]
|
|
124
|
+
: {};
|
|
125
|
+
result[row.field] = { ...existing, gte: min, lte: max };
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const operator = conditionToOperator[row.condition];
|
|
129
|
+
if (!operator)
|
|
130
|
+
continue;
|
|
131
|
+
const existing = result[row.field];
|
|
132
|
+
if (typeof existing === "object" &&
|
|
133
|
+
existing !== null &&
|
|
134
|
+
!Array.isArray(existing)) {
|
|
135
|
+
result[row.field] = {
|
|
136
|
+
...existing,
|
|
137
|
+
[operator]: row.value,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
result[row.field] = { [operator]: row.value };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export { deserializeFilter, serializeFilter } from "./url.js";
|
|
2
|
+
import type { Type } from "arktype";
|
|
3
|
+
import type { TSense } from "../tsense.js";
|
|
4
|
+
import type { FilterFor, SearchInput } from "../types.js";
|
|
5
|
+
export type FilterDescriptor<T = Record<string, unknown>> = {
|
|
6
|
+
infer: FilterFor<T>;
|
|
7
|
+
columns: {
|
|
8
|
+
key: keyof T & string;
|
|
9
|
+
label: string;
|
|
10
|
+
type: "string" | "number" | "boolean" | "date";
|
|
11
|
+
conditions: {
|
|
12
|
+
key: string;
|
|
13
|
+
label: string;
|
|
14
|
+
}[];
|
|
15
|
+
values?: {
|
|
16
|
+
value: string;
|
|
17
|
+
label: string;
|
|
18
|
+
}[];
|
|
19
|
+
presets?: {
|
|
20
|
+
name: string;
|
|
21
|
+
filter: Record<string, unknown>;
|
|
22
|
+
}[];
|
|
23
|
+
}[];
|
|
24
|
+
};
|
|
25
|
+
type FilterBuilderFieldConfig<T> = {
|
|
26
|
+
label: string;
|
|
27
|
+
labels?: Record<string, string>;
|
|
28
|
+
presets?: Record<string, FilterFor<T> | (() => FilterFor<T>)>;
|
|
29
|
+
};
|
|
30
|
+
type FilterBuilderReturn<T> = {
|
|
31
|
+
describe(): FilterDescriptor<T>;
|
|
32
|
+
schema(): Type<SearchInput<T>>;
|
|
33
|
+
};
|
|
34
|
+
type ColumnType = FilterDescriptor["columns"][number]["type"];
|
|
35
|
+
type FilterBuilderOptions = {
|
|
36
|
+
conditionLabels?: Partial<Record<ColumnType | "enum", Partial<Record<string, string>>>>;
|
|
37
|
+
};
|
|
38
|
+
export declare function createFilterBuilder<T extends Type>(collection: TSense<T>, config: {
|
|
39
|
+
[K in keyof T["infer"]]?: FilterBuilderFieldConfig<T["infer"]>;
|
|
40
|
+
}, options?: FilterBuilderOptions): FilterBuilderReturn<T["infer"]>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
export { deserializeFilter, serializeFilter } from "./url.js";
|
|
2
|
+
import { type } from "arktype";
|
|
3
|
+
const tsenseTypeMap = {
|
|
4
|
+
string: "string",
|
|
5
|
+
"string*": "string",
|
|
6
|
+
"string[]": "string",
|
|
7
|
+
int32: "number",
|
|
8
|
+
int64: "number",
|
|
9
|
+
float: "number",
|
|
10
|
+
"int32[]": "number",
|
|
11
|
+
"int64[]": "number",
|
|
12
|
+
"float[]": "number",
|
|
13
|
+
bool: "boolean",
|
|
14
|
+
"bool[]": "boolean",
|
|
15
|
+
auto: "string",
|
|
16
|
+
image: "string",
|
|
17
|
+
};
|
|
18
|
+
const enumConditions = [
|
|
19
|
+
{ key: "is_in", label: "is in" },
|
|
20
|
+
{ key: "is_not_in", label: "is not in" },
|
|
21
|
+
];
|
|
22
|
+
const conditionsByType = {
|
|
23
|
+
string: [
|
|
24
|
+
{ key: "equals", label: "equals" },
|
|
25
|
+
{ key: "not_equals", label: "not equals" },
|
|
26
|
+
],
|
|
27
|
+
number: [
|
|
28
|
+
{ key: "equals", label: "equals" },
|
|
29
|
+
{ key: "not_equals", label: "not equals" },
|
|
30
|
+
{ key: "gt", label: "greater than" },
|
|
31
|
+
{ key: "gte", label: "greater than or equal" },
|
|
32
|
+
{ key: "lt", label: "less than" },
|
|
33
|
+
{ key: "lte", label: "less than or equal" },
|
|
34
|
+
{ key: "between", label: "between" },
|
|
35
|
+
],
|
|
36
|
+
boolean: [{ key: "equals", label: "equals" }],
|
|
37
|
+
date: [
|
|
38
|
+
{ key: "equals", label: "equals" },
|
|
39
|
+
{ key: "not_equals", label: "not equals" },
|
|
40
|
+
{ key: "gt", label: "after" },
|
|
41
|
+
{ key: "lt", label: "before" },
|
|
42
|
+
{ key: "between", label: "between" },
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
export function createFilterBuilder(collection, config, options) {
|
|
46
|
+
const fields = collection.fields;
|
|
47
|
+
return {
|
|
48
|
+
schema() {
|
|
49
|
+
const numberOps = type.raw({
|
|
50
|
+
"not?": "number",
|
|
51
|
+
"gt?": "number",
|
|
52
|
+
"gte?": "number",
|
|
53
|
+
"lt?": "number",
|
|
54
|
+
"lte?": "number",
|
|
55
|
+
"notIn?": "number[]",
|
|
56
|
+
});
|
|
57
|
+
const stringOps = type.raw({
|
|
58
|
+
"not?": "string",
|
|
59
|
+
"notIn?": "string[]",
|
|
60
|
+
});
|
|
61
|
+
const fieldSchemas = {
|
|
62
|
+
number: type.raw("number").or(type.raw("number[]")).or(numberOps),
|
|
63
|
+
string: type.raw("string").or(type.raw("string[]")).or(stringOps),
|
|
64
|
+
boolean: type.raw("boolean"),
|
|
65
|
+
date: type.raw("number").or(type.raw("number[]")).or(numberOps),
|
|
66
|
+
};
|
|
67
|
+
const descriptor = this.describe();
|
|
68
|
+
const filterDef = {};
|
|
69
|
+
for (const column of descriptor.columns) {
|
|
70
|
+
filterDef[`${column.key}?`] = fieldSchemas[column.type];
|
|
71
|
+
}
|
|
72
|
+
return type
|
|
73
|
+
.raw({
|
|
74
|
+
"query?": "string",
|
|
75
|
+
"filter?": type.raw(filterDef),
|
|
76
|
+
"page?": "number",
|
|
77
|
+
"limit?": "number",
|
|
78
|
+
})
|
|
79
|
+
.as();
|
|
80
|
+
},
|
|
81
|
+
describe() {
|
|
82
|
+
const columns = [];
|
|
83
|
+
const withLabels = (conditions, typeKey) => {
|
|
84
|
+
const overrides = options?.conditionLabels?.[typeKey];
|
|
85
|
+
if (!overrides) {
|
|
86
|
+
return conditions;
|
|
87
|
+
}
|
|
88
|
+
return conditions.map((c) => ({
|
|
89
|
+
key: c.key,
|
|
90
|
+
label: overrides[c.key] ?? c.label,
|
|
91
|
+
}));
|
|
92
|
+
};
|
|
93
|
+
for (const field of fields) {
|
|
94
|
+
const fieldConfig = config[field.name];
|
|
95
|
+
if (!fieldConfig)
|
|
96
|
+
continue;
|
|
97
|
+
const columnType = field.sourceExpression === "Date"
|
|
98
|
+
? "date"
|
|
99
|
+
: tsenseTypeMap[field.type];
|
|
100
|
+
if (!columnType)
|
|
101
|
+
continue;
|
|
102
|
+
const column = {
|
|
103
|
+
key: field.name,
|
|
104
|
+
label: fieldConfig.label,
|
|
105
|
+
type: columnType,
|
|
106
|
+
conditions: withLabels(conditionsByType[columnType], columnType),
|
|
107
|
+
};
|
|
108
|
+
if (field.enumValues?.length) {
|
|
109
|
+
column.values = field.enumValues.map((v) => ({
|
|
110
|
+
value: v,
|
|
111
|
+
label: fieldConfig.labels?.[v] ?? v,
|
|
112
|
+
}));
|
|
113
|
+
column.conditions = withLabels(enumConditions, "enum");
|
|
114
|
+
}
|
|
115
|
+
if (fieldConfig.presets) {
|
|
116
|
+
column.presets = Object.entries(fieldConfig.presets).map(([name, filterOrFn]) => ({
|
|
117
|
+
name,
|
|
118
|
+
filter: (typeof filterOrFn === "function"
|
|
119
|
+
? filterOrFn()
|
|
120
|
+
: filterOrFn),
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
columns.push(column);
|
|
124
|
+
}
|
|
125
|
+
return { infer: undefined, columns };
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { FilterDescriptor } from "./index.js";
|
|
2
|
+
export declare function serializeFilter(filter: Record<string, unknown>, descriptor: FilterDescriptor): URLSearchParams;
|
|
3
|
+
export declare function deserializeFilter(params: URLSearchParams, descriptor: FilterDescriptor): Record<string, unknown>;
|