@spooky-sync/query-builder 0.0.1-canary.8 → 0.0.1-canary.81
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/AGENTS.md +63 -0
- package/dist/index.d.mts +40 -6
- package/dist/index.d.mts.map +1 -1
- package/dist/index.d.ts +40 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -9
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +16 -9
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -4
- package/skills/sp00ky-query-builder/SKILL.md +201 -0
- package/skills/sp00ky-query-builder/references/type-helpers.md +80 -0
- package/src/query-builder.test.ts +75 -4
- package/src/query-builder.ts +39 -16
- package/src/repro_relationship.test.ts +1 -1
- package/src/table-schema.ts +11 -1
- package/src/types.ts +37 -5
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sp00ky-query-builder
|
|
3
|
+
description: >-
|
|
4
|
+
Type-safe query builder for SurrealDB used by the Sp00ky framework. Use when
|
|
5
|
+
defining schemas (SchemaStructure), building queries with QueryBuilder, handling
|
|
6
|
+
relationships (one/many cardinality), or working with Sp00ky type helpers like
|
|
7
|
+
TableNames, GetTable, TableModel, and QueryResult.
|
|
8
|
+
metadata:
|
|
9
|
+
author: sp00ky-sync
|
|
10
|
+
version: "0.0.1"
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Sp00ky Query Builder
|
|
14
|
+
|
|
15
|
+
`@spooky-sync/query-builder` provides the type-safe schema definition format and query builder used throughout the Sp00ky framework.
|
|
16
|
+
|
|
17
|
+
## Schema Definition
|
|
18
|
+
|
|
19
|
+
Sp00ky schemas are defined as `const` objects satisfying `SchemaStructure`. They are typically generated by the Sp00ky CLI (`spooky generate`), but can be written by hand.
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
import type { SchemaStructure } from '@spooky-sync/query-builder';
|
|
23
|
+
|
|
24
|
+
export const schema = {
|
|
25
|
+
tables: [
|
|
26
|
+
{
|
|
27
|
+
name: 'user',
|
|
28
|
+
columns: {
|
|
29
|
+
id: { type: 'string', optional: false },
|
|
30
|
+
email: { type: 'string', optional: false },
|
|
31
|
+
name: { type: 'string', optional: true },
|
|
32
|
+
},
|
|
33
|
+
primaryKey: ['id'],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'post',
|
|
37
|
+
columns: {
|
|
38
|
+
id: { type: 'string', optional: false },
|
|
39
|
+
title: { type: 'string', optional: false },
|
|
40
|
+
body: { type: 'string', optional: false },
|
|
41
|
+
author: { type: 'string', optional: false, recordId: true },
|
|
42
|
+
},
|
|
43
|
+
primaryKey: ['id'],
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
relationships: [
|
|
47
|
+
{ from: 'post', field: 'author', to: 'user', cardinality: 'one' },
|
|
48
|
+
{ from: 'user', field: 'posts', to: 'post', cardinality: 'many' },
|
|
49
|
+
],
|
|
50
|
+
backends: {},
|
|
51
|
+
} as const satisfies SchemaStructure;
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Column Types
|
|
55
|
+
|
|
56
|
+
The `type` field maps to TypeScript types:
|
|
57
|
+
|
|
58
|
+
| ValueType | TypeScript Type |
|
|
59
|
+
|-------------|-----------------|
|
|
60
|
+
| `'string'` | `string` |
|
|
61
|
+
| `'number'` | `number` |
|
|
62
|
+
| `'boolean'` | `boolean` |
|
|
63
|
+
| `'null'` | `null` |
|
|
64
|
+
| `'json'` | `unknown` |
|
|
65
|
+
|
|
66
|
+
Set `optional: true` to make a field nullable (`T | null`).
|
|
67
|
+
Set `recordId: true` to indicate a field stores a SurrealDB RecordId.
|
|
68
|
+
|
|
69
|
+
## QueryBuilder API
|
|
70
|
+
|
|
71
|
+
The `QueryBuilder` class provides a fluent, chainable API for constructing type-safe queries.
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
import { QueryBuilder } from '@spooky-sync/query-builder';
|
|
75
|
+
|
|
76
|
+
// Create a query builder for the "post" table
|
|
77
|
+
const query = new QueryBuilder(schema, 'post')
|
|
78
|
+
.where({ author: 'user:alice' })
|
|
79
|
+
.select('id', 'title', 'body')
|
|
80
|
+
.orderBy('title', 'desc')
|
|
81
|
+
.limit(10)
|
|
82
|
+
.offset(0)
|
|
83
|
+
.related('author')
|
|
84
|
+
.build();
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Chain Methods
|
|
88
|
+
|
|
89
|
+
| Method | Signature | Description |
|
|
90
|
+
|--------|-----------|-------------|
|
|
91
|
+
| `where` | `.where(conditions)` | Filter by field values. String IDs are auto-parsed to RecordId. |
|
|
92
|
+
| `select` | `.select(...fields)` | Pick specific fields. Can only be called once per query. |
|
|
93
|
+
| `orderBy` | `.orderBy(field, 'asc' \| 'desc')` | Sort results. Default direction is `'asc'`. |
|
|
94
|
+
| `limit` | `.limit(count)` | Limit the number of results. |
|
|
95
|
+
| `offset` | `.offset(count)` | Skip the first N results. |
|
|
96
|
+
| `one` | `.one()` | Return a single object instead of an array. Implicitly sets `limit(1)`. |
|
|
97
|
+
| `related` | `.related(field, modifier?)` | Include related data via subquery. See [Relationships](#relationships). |
|
|
98
|
+
| `build` | `.build()` | Finalize the query into a `FinalQuery` object. |
|
|
99
|
+
|
|
100
|
+
### Relationships
|
|
101
|
+
|
|
102
|
+
Use `.related()` to include related tables. Relationships must be declared in the schema.
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
// One-to-one: post has one author
|
|
106
|
+
new QueryBuilder(schema, 'post')
|
|
107
|
+
.related('author')
|
|
108
|
+
.build();
|
|
109
|
+
|
|
110
|
+
// One-to-many: user has many posts
|
|
111
|
+
new QueryBuilder(schema, 'user')
|
|
112
|
+
.related('posts')
|
|
113
|
+
.build();
|
|
114
|
+
|
|
115
|
+
// Nested relationship with modifier
|
|
116
|
+
new QueryBuilder(schema, 'user')
|
|
117
|
+
.related('posts', (q) => q.orderBy('title', 'asc').limit(5))
|
|
118
|
+
.build();
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Cardinality is inferred from the schema. For `'one'` relationships, the result is an object. For `'many'`, it is an array.
|
|
122
|
+
|
|
123
|
+
## Backend Schema
|
|
124
|
+
|
|
125
|
+
The `backends` field in `SchemaStructure` defines available HTTP backends, their outbox tables, and typed routes. This is generated by `spooky generate` from your `sp00ky.yml` config and OpenAPI spec.
|
|
126
|
+
|
|
127
|
+
### Schema Structure
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
export interface SchemaStructure {
|
|
131
|
+
// ...tables, relationships...
|
|
132
|
+
readonly backends: Record<string, HTTPOutboxBackendDefinition>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface HTTPOutboxBackendDefinition {
|
|
136
|
+
readonly outboxTable: string; // The SurrealDB table used as the outbox (e.g., 'job')
|
|
137
|
+
readonly routes: Record<string, HTTPBackendRouteDefinition>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface HTTPBackendRouteDefinition {
|
|
141
|
+
readonly args: Record<string, HTTPBackendRouteArgsDefinition>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface HTTPBackendRouteArgsDefinition {
|
|
145
|
+
readonly type: ValueType; // 'string' | 'number' | 'boolean' | 'null' | 'json'
|
|
146
|
+
readonly optional: boolean;
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Generated Example
|
|
151
|
+
|
|
152
|
+
Given a `sp00ky.yml` with an `api` backend and an OpenAPI spec defining a `/spookify` route with an `id` parameter, `spooky generate` produces:
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
export const schema = {
|
|
156
|
+
// ...tables, relationships...
|
|
157
|
+
backends: {
|
|
158
|
+
"api": {
|
|
159
|
+
outboxTable: 'job' as const,
|
|
160
|
+
routes: {
|
|
161
|
+
"/spookify": {
|
|
162
|
+
args: {
|
|
163
|
+
"id": {
|
|
164
|
+
type: 'string' as const,
|
|
165
|
+
optional: false as const
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
} as const satisfies SchemaStructure;
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Backend Type Helpers
|
|
176
|
+
|
|
177
|
+
- `BackendNames<S>` — Union of all backend name strings (e.g., `'api'`)
|
|
178
|
+
- `BackendRoutes<S, B>` — Union of all route paths for backend `B` (e.g., `'/spookify'`)
|
|
179
|
+
- `RoutePayload<S, B, R>` — Typed payload object for route `R` on backend `B`. Required args become required fields, optional args become optional fields. Types are mapped from `ValueType` to TypeScript types.
|
|
180
|
+
|
|
181
|
+
These types are used by `db.run()` to provide full type safety — backend name, route path, and payload are all checked at compile time.
|
|
182
|
+
|
|
183
|
+
## Type Helpers
|
|
184
|
+
|
|
185
|
+
See [references/type-helpers.md](references/type-helpers.md) for a full reference of all type utilities.
|
|
186
|
+
|
|
187
|
+
Key types:
|
|
188
|
+
|
|
189
|
+
- `TableNames<S>` — Union of all table name strings
|
|
190
|
+
- `GetTable<S, Name>` — Extract a table definition by name
|
|
191
|
+
- `TableModel<T>` — Convert a table's columns to a TypeScript object type
|
|
192
|
+
- `QueryResult<S, TableName, RelatedFields, IsOne>` — The full result type including related fields
|
|
193
|
+
- `BackendNames<S>`, `BackendRoutes<S, B>`, `RoutePayload<S, B, R>` — Backend/run type helpers
|
|
194
|
+
|
|
195
|
+
## Common Pitfalls
|
|
196
|
+
|
|
197
|
+
1. **String IDs are auto-converted**: When you pass `'user:alice'` in a `where()`, it is automatically parsed into a SurrealDB `RecordId`. If the string does not contain `:`, and the field is named `id`, the table name is prepended.
|
|
198
|
+
|
|
199
|
+
2. **`select()` can only be called once**: Calling it twice throws an error. Combine all fields in one call.
|
|
200
|
+
|
|
201
|
+
3. **Schema must use `as const satisfies SchemaStructure`**: Without `as const`, TypeScript cannot infer literal types for table names and relationships, and type safety is lost.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Type Helpers Reference
|
|
2
|
+
|
|
3
|
+
## Schema Types
|
|
4
|
+
|
|
5
|
+
### `SchemaStructure`
|
|
6
|
+
|
|
7
|
+
The top-level schema interface:
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
interface SchemaStructure {
|
|
11
|
+
readonly tables: readonly {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly columns: Record<string, ColumnSchema>;
|
|
14
|
+
readonly primaryKey: readonly string[];
|
|
15
|
+
}[];
|
|
16
|
+
readonly relationships: readonly {
|
|
17
|
+
readonly from: string;
|
|
18
|
+
readonly field: string;
|
|
19
|
+
readonly to: string;
|
|
20
|
+
readonly cardinality: 'one' | 'many';
|
|
21
|
+
}[];
|
|
22
|
+
readonly backends: Record<string, HTTPOutboxBackendDefinition>;
|
|
23
|
+
readonly access?: Record<string, AccessDefinition>;
|
|
24
|
+
readonly buckets?: readonly BucketDefinitionSchema[];
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### `ColumnSchema`
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
interface ColumnSchema {
|
|
32
|
+
readonly type: 'string' | 'number' | 'boolean' | 'null' | 'json';
|
|
33
|
+
readonly optional: boolean;
|
|
34
|
+
readonly dateTime?: boolean;
|
|
35
|
+
readonly recordId?: boolean;
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Table Type Helpers
|
|
40
|
+
|
|
41
|
+
| Type | Description |
|
|
42
|
+
|------|-------------|
|
|
43
|
+
| `TableNames<S>` | Union of all table name strings from the schema |
|
|
44
|
+
| `GetTable<S, Name>` | Extract a specific table definition by name |
|
|
45
|
+
| `TableModel<T>` | Convert a table's `columns` record into a TypeScript object type |
|
|
46
|
+
| `TableFieldNames<T>` | Union of all column names for a table |
|
|
47
|
+
|
|
48
|
+
### Example
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
type MySchema = typeof schema;
|
|
52
|
+
type Tables = TableNames<MySchema>; // 'user' | 'post'
|
|
53
|
+
type UserTable = GetTable<MySchema, 'user'>; // The user table definition
|
|
54
|
+
type UserModel = TableModel<UserTable>; // { id: string; email: string; name: string | null }
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Relationship Type Helpers
|
|
58
|
+
|
|
59
|
+
| Type | Description |
|
|
60
|
+
|------|-------------|
|
|
61
|
+
| `TableRelationships<S, TableName>` | All relationship definitions originating from a table |
|
|
62
|
+
| `RelationshipFields<S, TableName>` | Union of relationship field names for a table |
|
|
63
|
+
| `GetRelationship<S, TableName, Field>` | Get a specific relationship by table and field name |
|
|
64
|
+
|
|
65
|
+
## Result Type Helpers
|
|
66
|
+
|
|
67
|
+
| Type | Description |
|
|
68
|
+
|------|-------------|
|
|
69
|
+
| `QueryResult<S, TableName, RelatedFields, IsOne>` | The full query result type. If `IsOne` is `true`, returns a single object; otherwise an array. Includes related fields merged into the base model. |
|
|
70
|
+
| `RelatedFieldsMap` | A record mapping field names to `{ to, cardinality, relatedFields }` |
|
|
71
|
+
|
|
72
|
+
## Backend Type Helpers
|
|
73
|
+
|
|
74
|
+
| Type | Description |
|
|
75
|
+
|------|-------------|
|
|
76
|
+
| `BackendNames<S>` | Union of all backend names |
|
|
77
|
+
| `BackendRoutes<S, B>` | Union of all route paths for a backend |
|
|
78
|
+
| `RoutePayload<S, B, R>` | The typed payload object for a specific route |
|
|
79
|
+
| `BucketNames<S>` | Union of all bucket names |
|
|
80
|
+
| `BucketConfig<S, B>` | Configuration for a specific bucket |
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, expectTypeOf } from 'vitest';
|
|
2
2
|
import { QueryBuilder, buildQueryFromOptions } from './query-builder';
|
|
3
3
|
import { RecordId } from 'surrealdb';
|
|
4
|
-
import type {
|
|
4
|
+
import type { TableModel } from './table-schema';
|
|
5
5
|
|
|
6
6
|
// Schema for testing the new array-based API
|
|
7
7
|
const testSchema = {
|
|
@@ -92,6 +92,76 @@ describe('QueryBuilder', () => {
|
|
|
92
92
|
});
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
+
it('should build a comparison operator condition via { _op, _val }', () => {
|
|
96
|
+
const builder = new QueryBuilder(testSchema, 'user', (q) => q.selectQuery);
|
|
97
|
+
builder.where({ created_at: { _op: '<=', _val: 5 } });
|
|
98
|
+
const result = builder.build().run();
|
|
99
|
+
|
|
100
|
+
expect(result.query).toBe('SELECT * FROM user WHERE created_at <= $created_at;');
|
|
101
|
+
expect(result.vars).toEqual({ created_at: 5 });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should build an OR group via _or with position-indexed params', () => {
|
|
105
|
+
const builder = new QueryBuilder(testSchema, 'user', (q) => q.selectQuery);
|
|
106
|
+
builder.where({ _or: [{ username: 'x' }, { email: 'x' }] });
|
|
107
|
+
const result = builder.build().run();
|
|
108
|
+
|
|
109
|
+
expect(result.query).toBe('SELECT * FROM user WHERE (username = $or0 OR email = $or1);');
|
|
110
|
+
expect(result.vars).toEqual({ or0: 'x', or1: 'x' });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should not collide an _or branch with a top-level condition on the same field', () => {
|
|
114
|
+
// Mirrors the game filter where a color filter (white = me) coexists with an
|
|
115
|
+
// opponent OR on white/black: the OR branch must use its own param name.
|
|
116
|
+
const builder = new QueryBuilder(testSchema, 'user', (q) => q.selectQuery);
|
|
117
|
+
builder.where({ username: 'me', _or: [{ username: 'opp' }, { email: 'opp' }] });
|
|
118
|
+
const result = builder.build().run();
|
|
119
|
+
|
|
120
|
+
expect(result.query).toBe(
|
|
121
|
+
'SELECT * FROM user WHERE username = $username AND (username = $or0 OR email = $or1);'
|
|
122
|
+
);
|
|
123
|
+
expect(result.vars).toEqual({ username: 'me', or0: 'opp', or1: 'opp' });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should combine equality + comparison + OR group + order/limit/offset', () => {
|
|
127
|
+
// The shape the filtered game list produces: scope equality, a date floor as
|
|
128
|
+
// an integer sort_index comparison, an opponent OR group, paginated.
|
|
129
|
+
const builder = new QueryBuilder(testSchema, 'user', (q) => q.selectQuery);
|
|
130
|
+
builder
|
|
131
|
+
.where({ email: 'e', created_at: { _op: '<=', _val: 5 }, _or: [{ username: 'p' }, { email: 'p' }] })
|
|
132
|
+
.orderBy('created_at', 'asc')
|
|
133
|
+
.limit(50)
|
|
134
|
+
.offset(0);
|
|
135
|
+
const result = builder.build().run();
|
|
136
|
+
|
|
137
|
+
expect(result.query).toBe(
|
|
138
|
+
'SELECT * FROM user WHERE email = $email AND created_at <= $created_at AND ' +
|
|
139
|
+
'(username = $or0 OR email = $or1) ORDER BY created_at asc LIMIT 50 START 0;'
|
|
140
|
+
);
|
|
141
|
+
expect(result.vars).toEqual({ email: 'e', created_at: 5, or0: 'p', or1: 'p' });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should produce a stable hash for the same logical filtered query', () => {
|
|
145
|
+
const make = () =>
|
|
146
|
+
new QueryBuilder(testSchema, 'user', (q) => q.selectQuery)
|
|
147
|
+
.where({ email: 'e', _or: [{ username: 'p' }, { email: 'p' }] })
|
|
148
|
+
.orderBy('created_at', 'asc')
|
|
149
|
+
.limit(50)
|
|
150
|
+
.offset(0)
|
|
151
|
+
.build()
|
|
152
|
+
.run();
|
|
153
|
+
expect(make().hash).toBe(make().hash);
|
|
154
|
+
|
|
155
|
+
const different = new QueryBuilder(testSchema, 'user', (q) => q.selectQuery)
|
|
156
|
+
.where({ email: 'e', _or: [{ username: 'q' }, { email: 'q' }] })
|
|
157
|
+
.orderBy('created_at', 'asc')
|
|
158
|
+
.limit(50)
|
|
159
|
+
.offset(0)
|
|
160
|
+
.build()
|
|
161
|
+
.run();
|
|
162
|
+
expect(different.hash).not.toBe(make().hash);
|
|
163
|
+
});
|
|
164
|
+
|
|
95
165
|
it('should build query with select fields', () => {
|
|
96
166
|
const builder = new QueryBuilder(testSchema, 'user', (q) => q.selectQuery);
|
|
97
167
|
builder.select('username', 'email');
|
|
@@ -270,11 +340,15 @@ describe('Edge Cases', () => {
|
|
|
270
340
|
describe('Type Tests', () => {
|
|
271
341
|
it('should enforce correct table names', () => {
|
|
272
342
|
// Valid table names should work
|
|
343
|
+
// oxlint-disable-next-line no-new
|
|
273
344
|
new QueryBuilder(testSchema, 'user');
|
|
345
|
+
// oxlint-disable-next-line no-new
|
|
274
346
|
new QueryBuilder(testSchema, 'thread');
|
|
347
|
+
// oxlint-disable-next-line no-new
|
|
275
348
|
new QueryBuilder(testSchema, 'comment');
|
|
276
349
|
|
|
277
350
|
// @ts-expect-error - invalid table name should not compile
|
|
351
|
+
// oxlint-disable-next-line no-new
|
|
278
352
|
new QueryBuilder(testSchema, 'invalid_table');
|
|
279
353
|
});
|
|
280
354
|
|
|
@@ -389,9 +463,6 @@ describe('Type Tests', () => {
|
|
|
389
463
|
});
|
|
390
464
|
|
|
391
465
|
describe('Schema Metadata Integration', () => {
|
|
392
|
-
// Using testSchema from top-level scope
|
|
393
|
-
type TestSchemaMetadata = typeof testSchema;
|
|
394
|
-
|
|
395
466
|
it('should accept testSchema in constructor', () => {
|
|
396
467
|
const builder = new QueryBuilder(testSchema, 'thread', (q) => q.selectQuery);
|
|
397
468
|
|
package/src/query-builder.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
RelatedQuery,
|
|
8
8
|
SchemaAwareQueryModifier,
|
|
9
9
|
SchemaAwareQueryModifierBuilder,
|
|
10
|
+
WhereInput,
|
|
10
11
|
} from './types';
|
|
11
12
|
import type {
|
|
12
13
|
TableNames,
|
|
@@ -172,7 +173,7 @@ export class InnerQuery<
|
|
|
172
173
|
/**
|
|
173
174
|
* Helper type to get the model type for a related table
|
|
174
175
|
*/
|
|
175
|
-
type
|
|
176
|
+
type _GetRelatedModel<S extends SchemaStructure, RelatedTableName extends string> =
|
|
176
177
|
RelatedTableName extends TableNames<S> ? TableModel<GetTable<S, RelatedTableName>> : never;
|
|
177
178
|
|
|
178
179
|
/**
|
|
@@ -234,6 +235,7 @@ export class FinalQuery<
|
|
|
234
235
|
S extends SchemaStructure,
|
|
235
236
|
TableName extends TableNames<S>,
|
|
236
237
|
T extends { columns: Record<string, ColumnSchema> },
|
|
238
|
+
// oxlint-disable-next-line no-unused-vars -- RelatedFields is used externally for type inference
|
|
237
239
|
RelatedFields extends RelatedFieldsMap,
|
|
238
240
|
IsOne extends boolean,
|
|
239
241
|
R = void,
|
|
@@ -299,7 +301,7 @@ class SchemaAwareQueryModifierBuilderImpl<
|
|
|
299
301
|
private readonly schema: S
|
|
300
302
|
) {}
|
|
301
303
|
|
|
302
|
-
where(conditions:
|
|
304
|
+
where(conditions: WhereInput<TableModel<GetTable<S, TableName>>>): this {
|
|
303
305
|
this.options.where = { ...this.options.where, ...conditions };
|
|
304
306
|
return this;
|
|
305
307
|
}
|
|
@@ -412,7 +414,7 @@ export class QueryBuilder<
|
|
|
412
414
|
* Add additional where conditions
|
|
413
415
|
*/
|
|
414
416
|
where(
|
|
415
|
-
conditions:
|
|
417
|
+
conditions: WhereInput<TableModel<GetTable<S, TableName>>>
|
|
416
418
|
): QueryBuilder<S, TableName, R, RelatedFields, IsOne> {
|
|
417
419
|
this.options.where = { ...this.options.where, ...conditions };
|
|
418
420
|
return this;
|
|
@@ -641,6 +643,7 @@ export function extractSubqueryQueryInfos<S extends SchemaStructure>(
|
|
|
641
643
|
if (relationship) {
|
|
642
644
|
// Determine foreign key field
|
|
643
645
|
// rel.alias is guaranteed to be defined if relationship is found (matched r.field)
|
|
646
|
+
// oxlint-disable-next-line no-non-null-assertion -- alias is guaranteed defined when relationship is found
|
|
644
647
|
let foreignKeyField = rel.alias!;
|
|
645
648
|
|
|
646
649
|
if (relationship.cardinality === 'many') {
|
|
@@ -751,32 +754,52 @@ export function buildQueryFromOptions<TModel extends GenericModel, IsOne extends
|
|
|
751
754
|
const vars: Record<string, unknown> = {};
|
|
752
755
|
if (parsedWhere && Object.keys(parsedWhere).length > 0) {
|
|
753
756
|
const conditions: string[] = [];
|
|
754
|
-
for (const [key, value] of Object.entries(parsedWhere)) {
|
|
755
|
-
const varName = key;
|
|
756
757
|
|
|
757
|
-
|
|
758
|
+
// Build a single condition for `field`, binding its value under `varName`.
|
|
759
|
+
// Supports operator objects `{ _op, _val, _swap }` (e.g. `{ _op: '<=', _val:
|
|
760
|
+
// 5 }`); a `$`-prefixed string `_val` references an existing param verbatim.
|
|
761
|
+
// Plain values mean equality (`field = $varName`).
|
|
762
|
+
const buildCondition = (field: string, value: unknown, varName: string): string => {
|
|
758
763
|
if (value && typeof value === 'object' && '_op' in value && '_val' in value) {
|
|
759
764
|
const { _op, _val, _swap } = value as { _op: string; _val: unknown; _swap?: boolean };
|
|
760
|
-
|
|
761
|
-
let rightSide = '';
|
|
765
|
+
let rightSide: string;
|
|
762
766
|
if (typeof _val === 'string' && _val.startsWith('$')) {
|
|
763
767
|
rightSide = _val;
|
|
764
768
|
} else {
|
|
765
769
|
vars[varName] = _val;
|
|
766
770
|
rightSide = `$${varName}`;
|
|
767
771
|
}
|
|
772
|
+
return _swap ? `${rightSide} ${_op} ${field}` : `${field} ${_op} ${rightSide}`;
|
|
773
|
+
}
|
|
774
|
+
vars[varName] = value;
|
|
775
|
+
return `${field} = $${varName}`;
|
|
776
|
+
};
|
|
768
777
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
778
|
+
for (const [key, value] of Object.entries(parsedWhere)) {
|
|
779
|
+
// OR-group: `{ _or: [ {field: val}, {field: {_op,_val}}, ... ] }` compiles
|
|
780
|
+
// to one parenthesised `(c1 OR c2 ...)` conjunct. Each branch condition gets
|
|
781
|
+
// a unique, position-indexed param name (`or0`, `or1`, …) so it never
|
|
782
|
+
// collides with a top-level condition on the same field (e.g. a `white =
|
|
783
|
+
// $white` filter alongside an opponent `_or` on white/black) — keeping the
|
|
784
|
+
// surql + vars, and thus the query hash, stable and deterministic.
|
|
785
|
+
if (key === '_or' && Array.isArray(value)) {
|
|
786
|
+
const orParts: string[] = [];
|
|
787
|
+
let i = 0;
|
|
788
|
+
for (const branch of value) {
|
|
789
|
+
if (branch && typeof branch === 'object') {
|
|
790
|
+
for (const [bField, bVal] of Object.entries(branch as Record<string, unknown>)) {
|
|
791
|
+
orParts.push(buildCondition(bField, bVal, `or${i++}`));
|
|
792
|
+
}
|
|
793
|
+
}
|
|
773
794
|
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
conditions.push(`${key} = $${varName}`);
|
|
795
|
+
if (orParts.length > 0) conditions.push(`(${orParts.join(' OR ')})`);
|
|
796
|
+
continue;
|
|
777
797
|
}
|
|
798
|
+
|
|
799
|
+
conditions.push(buildCondition(key, value, key));
|
|
778
800
|
}
|
|
779
|
-
|
|
801
|
+
|
|
802
|
+
if (conditions.length > 0) query += ` WHERE ${conditions.join(' AND ')}`;
|
|
780
803
|
}
|
|
781
804
|
|
|
782
805
|
// Add PATCH for UPDATE
|
package/src/table-schema.ts
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Supported value types in the schema
|
|
3
3
|
*/
|
|
4
|
-
export type ValueType = 'string' | 'number' | 'boolean' | 'null' | 'json';
|
|
4
|
+
export type ValueType = 'string' | 'number' | 'boolean' | 'null' | 'json' | 'Uint8Array';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Column metadata defining the type and optionality of a field
|
|
8
8
|
*/
|
|
9
|
+
/**
|
|
10
|
+
* CRDT types supported by Sp00ky's Loro integration
|
|
11
|
+
*/
|
|
12
|
+
export type CrdtType = 'text' | 'map' | 'list' | 'counter';
|
|
13
|
+
|
|
9
14
|
export interface ColumnSchema {
|
|
10
15
|
readonly type: ValueType;
|
|
11
16
|
readonly optional: boolean;
|
|
12
17
|
readonly dateTime?: boolean;
|
|
13
18
|
readonly recordId?: boolean;
|
|
19
|
+
readonly crdt?: CrdtType;
|
|
20
|
+
readonly cursor?: boolean;
|
|
21
|
+
/** True for `TYPE bytes` columns. Runtime values are `Uint8Array`. */
|
|
22
|
+
readonly bytes?: boolean;
|
|
14
23
|
}
|
|
15
24
|
|
|
16
25
|
/**
|
|
@@ -62,6 +71,7 @@ export type TypeNameToTypeMap = {
|
|
|
62
71
|
boolean: boolean;
|
|
63
72
|
null: null;
|
|
64
73
|
json: unknown;
|
|
74
|
+
Uint8Array: Uint8Array;
|
|
65
75
|
};
|
|
66
76
|
|
|
67
77
|
/**
|
package/src/types.ts
CHANGED
|
@@ -36,9 +36,40 @@ export interface RelatedQuery {
|
|
|
36
36
|
cardinality: 'one' | 'many';
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Comparison-operator descriptor for a single WHERE field, e.g.
|
|
41
|
+
* `{ _op: '<=', _val: 5 }` → `field <= $field`. A `$`-prefixed string `_val`
|
|
42
|
+
* references an existing query param verbatim; `_swap: true` flips the operands
|
|
43
|
+
* (`$val _op field`). Plain values still mean equality (`field = $field`).
|
|
44
|
+
*/
|
|
45
|
+
export interface ComparisonOp {
|
|
46
|
+
_op: '=' | '!=' | '>' | '>=' | '<' | '<=' | (string & {});
|
|
47
|
+
_val: unknown;
|
|
48
|
+
_swap?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A single WHERE field value: an equality value or a comparison descriptor. */
|
|
52
|
+
export type WhereFieldValue<V> = V | ComparisonOp;
|
|
53
|
+
|
|
54
|
+
/** A flat conjunction of field conditions (equality or comparison). */
|
|
55
|
+
export type WhereConditions<TModel extends GenericModel> = {
|
|
56
|
+
[K in keyof TModel]?: WhereFieldValue<TModel[K]>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* WHERE input for `.where()`. Supports equality (`{ field: value }`), comparison
|
|
61
|
+
* operators (`{ field: { _op, _val } }`), and a single top-level `_or` group of
|
|
62
|
+
* condition fragments that compile to a parenthesised `(... OR ...)` conjunct —
|
|
63
|
+
* e.g. `{ _or: [{ white: x }, { black: x }] }` → `(white = $or0 OR black = $or1)`.
|
|
64
|
+
* Backward-compatible with plain `Partial<TModel>` equality objects.
|
|
65
|
+
*/
|
|
66
|
+
export type WhereInput<TModel extends GenericModel> = WhereConditions<TModel> & {
|
|
67
|
+
_or?: WhereConditions<TModel>[];
|
|
68
|
+
};
|
|
69
|
+
|
|
39
70
|
export interface QueryOptions<TModel extends GenericModel, IsOne extends boolean> {
|
|
40
71
|
select?: ((keyof TModel & string) | '*')[];
|
|
41
|
-
where?:
|
|
72
|
+
where?: WhereInput<TModel>;
|
|
42
73
|
limit?: number;
|
|
43
74
|
offset?: number;
|
|
44
75
|
orderBy?: Partial<Record<keyof TModel, 'asc' | 'desc'>>;
|
|
@@ -47,10 +78,10 @@ export interface QueryOptions<TModel extends GenericModel, IsOne extends boolean
|
|
|
47
78
|
isOne?: IsOne;
|
|
48
79
|
}
|
|
49
80
|
|
|
50
|
-
export
|
|
81
|
+
export type LiveQueryOptions<TModel extends GenericModel> = Omit<
|
|
51
82
|
QueryOptions<TModel, boolean>,
|
|
52
83
|
'orderBy'
|
|
53
|
-
|
|
84
|
+
>;
|
|
54
85
|
|
|
55
86
|
// Import schema types for schema-aware modifiers
|
|
56
87
|
import type {
|
|
@@ -78,7 +109,7 @@ export type SchemaAwareQueryModifier<
|
|
|
78
109
|
|
|
79
110
|
// Simplified query builder interface for modifying subqueries
|
|
80
111
|
export interface QueryModifierBuilder<TModel extends GenericModel> {
|
|
81
|
-
where(conditions:
|
|
112
|
+
where(conditions: WhereInput<TModel>): this;
|
|
82
113
|
select(...fields: ((keyof TModel & string) | '*')[]): this;
|
|
83
114
|
limit(count: number): this;
|
|
84
115
|
offset(count: number): this;
|
|
@@ -93,7 +124,7 @@ export interface SchemaAwareQueryModifierBuilder<
|
|
|
93
124
|
TableName extends TableNames<S>,
|
|
94
125
|
RelatedFields extends Record<string, any> = {},
|
|
95
126
|
> {
|
|
96
|
-
where(conditions:
|
|
127
|
+
where(conditions: WhereInput<TableModel<GetTable<S, TableName>>>): this;
|
|
97
128
|
select(...fields: ((keyof TableModel<GetTable<S, TableName>> & string) | '*')[]): this;
|
|
98
129
|
limit(count: number): this;
|
|
99
130
|
offset(count: number): this;
|
|
@@ -139,6 +170,7 @@ export type RelationshipFields<TModel extends GenericModel> = {
|
|
|
139
170
|
* Simplified to directly access the nested structure
|
|
140
171
|
*/
|
|
141
172
|
export type InferRelatedModelFromMetadata<
|
|
173
|
+
// oxlint-disable-next-line no-unused-vars -- Schema is used as a generic constraint
|
|
142
174
|
Schema extends GenericSchema,
|
|
143
175
|
TableName extends string,
|
|
144
176
|
FieldName extends string,
|