@gasboost/query 0.1.0 → 0.1.1

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 ADDED
@@ -0,0 +1,408 @@
1
+ # @gasboost/query
2
+
3
+ Zod Schema を利用した、型安全でストレージ非依存の Query Engine です。
4
+
5
+ `@gasboost/query` は Query の構築、Filter、Sort、Offset、Limit、JOIN、および再帰的な JOIN 解決を提供します。
6
+
7
+ Google Sheets、IndexedDB、その他のデータストアには依存せず、テーブルから Record を取得する `Loader` を渡すことで同じ Query を異なるストレージに対して利用できます。
8
+
9
+ ```text
10
+ @gasboost/query
11
+
12
+ Query / Join
13
+
14
+ Loader
15
+ ↙ ↘
16
+ SheetORM Replica
17
+ ↓ ↓
18
+ Sheets IndexedDB
19
+ ```
20
+
21
+ ---
22
+
23
+ ## Features
24
+
25
+ - Zod Schema による型安全な Query
26
+ - 型安全な Table 選択
27
+ - 型安全な Column / Operand
28
+ - AND / OR Filter
29
+ - Order By
30
+ - Limit / Offset
31
+ - JOIN
32
+ - Nested JOIN
33
+ - Recursive Query Resolution
34
+ - Storage Agnostic
35
+ - Async Loader
36
+ - Google Apps Script / Dexie / IndexedDB 非依存
37
+
38
+ ---
39
+
40
+ # Installation
41
+
42
+ ```bash
43
+ pnpm add @gasboost/query zod
44
+ ```
45
+
46
+ npm の場合:
47
+
48
+ ```bash
49
+ npm install @gasboost/query zod
50
+ ```
51
+
52
+ ---
53
+
54
+ # Table Definition
55
+
56
+ Query が必要とする Table 定義は `name` と Zod Schema だけです。
57
+
58
+ ```ts
59
+ import { z } from "zod";
60
+ import type { TableDefinition } from "@gasboost/query";
61
+
62
+ const UserSchema = z.object({
63
+ id: z.string(),
64
+ name: z.string(),
65
+ age: z.number(),
66
+ active: z.boolean(),
67
+ });
68
+
69
+ const ReservationSchema = z.object({
70
+ id: z.string(),
71
+ userId: z.string(),
72
+ staffId: z.string(),
73
+ });
74
+
75
+ const StaffSchema = z.object({
76
+ id: z.string(),
77
+ name: z.string(),
78
+ });
79
+
80
+ const tables = [
81
+ {
82
+ name: "users",
83
+ schema: UserSchema,
84
+ },
85
+ {
86
+ name: "reservations",
87
+ schema: ReservationSchema,
88
+ },
89
+ {
90
+ name: "staffs",
91
+ schema: StaffSchema,
92
+ },
93
+ ] as const satisfies readonly TableDefinition[];
94
+ ```
95
+
96
+ `primaryKey`、Database ID、IndexedDB index などのストレージ固有情報は `TableDefinition` に含みません。
97
+
98
+ ---
99
+
100
+ # Query
101
+
102
+ ```ts
103
+ import { Query } from "@gasboost/query";
104
+
105
+ const query = new Query<typeof tables, "users">({
106
+ tableName: "users",
107
+ });
108
+ ```
109
+
110
+ Query の対象 Table は型として保持されます。
111
+
112
+ そのため、Query で利用できる Column は対象 Table の Zod Schema から推論されます。
113
+
114
+ ---
115
+
116
+ # Filter
117
+
118
+ ## AND
119
+
120
+ ```ts
121
+ const query = new Query<typeof tables, "users">({
122
+ tableName: "users",
123
+ })
124
+ .and("active", "=", [true])
125
+ .and("age", ">=", [20]);
126
+ ```
127
+
128
+ すべての AND 条件を満たす Record が対象になります。
129
+
130
+ ## OR
131
+
132
+ ```ts
133
+ const query = new Query<typeof tables, "users">({
134
+ tableName: "users",
135
+ })
136
+ .or("name", "=", ["Alice"])
137
+ .or("name", "=", ["Bob"]);
138
+ ```
139
+
140
+ OR 条件のうち1つ以上を満たす Record が対象になります。
141
+
142
+ AND と OR は組み合わせられます。
143
+
144
+ ```ts
145
+ const query = new Query<typeof tables, "users">({
146
+ tableName: "users",
147
+ })
148
+ .and("active", "=", [true])
149
+ .or("name", "=", ["Alice"])
150
+ .or("name", "=", ["Bob"]);
151
+ ```
152
+
153
+ ---
154
+
155
+ # Filter Operators
156
+
157
+ 利用可能な Operator は以下です。
158
+
159
+ | Operator | 意味 |
160
+ | -------- | ------------------ |
161
+ | `=` | 等しい |
162
+ | `!=` | 等しくない |
163
+ | `<` | より小さい |
164
+ | `>` | より大きい |
165
+ | `<=` | 以下 |
166
+ | `>=` | 以上 |
167
+ | `*` | 文字列を含む |
168
+ | `!*` | 文字列を含まない |
169
+ | `^*` | 指定文字列で始まる |
170
+ | `*$` | 指定文字列で終わる |
171
+
172
+ ```ts
173
+ query.and("age", ">=", [20]);
174
+
175
+ query.and("name", "*", ["Ali"]);
176
+ ```
177
+
178
+ Operator と Operand の組み合わせは実行時にも検証されます。
179
+
180
+ ---
181
+
182
+ # Order By
183
+
184
+ ```ts
185
+ const query = new Query<typeof tables, "users">({
186
+ tableName: "users",
187
+ }).orderBy("name", "asc");
188
+ ```
189
+
190
+ 降順:
191
+
192
+ ```ts
193
+ query.orderBy("name", "desc");
194
+ ```
195
+
196
+ ---
197
+
198
+ # Limit / Offset
199
+
200
+ ```ts
201
+ const query = new Query<typeof tables, "users">({
202
+ tableName: "users",
203
+ })
204
+ .offset(20)
205
+ .limit(10);
206
+ ```
207
+
208
+ Query は次の順番で Record に適用されます。
209
+
210
+ ```text
211
+ Filter
212
+
213
+ Order By
214
+
215
+ Offset
216
+
217
+ Limit
218
+ ```
219
+
220
+ ---
221
+
222
+ # Apply
223
+
224
+ 取得済みの Record 配列へ Query を直接適用できます。
225
+
226
+ ```ts
227
+ const result = query.apply(records);
228
+ ```
229
+
230
+ `apply()` はストレージ I/O を行いません。
231
+
232
+ Record 配列に対する純粋な Query 処理のみを行います。
233
+
234
+ ---
235
+
236
+ # JOIN
237
+
238
+ 異なる Table の Record を型安全に JOIN できます。
239
+
240
+ ```ts
241
+ const query = new Query<typeof tables, "users">({
242
+ tableName: "users",
243
+ }).join("id", "reservations", "userId");
244
+ ```
245
+
246
+ この Query は、
247
+
248
+ ```text
249
+ users.id
250
+
251
+ reservations.userId
252
+ ```
253
+
254
+ で Record を関連付けます。
255
+
256
+ JOIN 結果は JOIN 先 Table 名をプロパティとして持ちます。
257
+
258
+ ```ts
259
+ {
260
+ id: "user-1",
261
+ name: "Alice",
262
+ reservations: [
263
+ {
264
+ id: "reservation-1",
265
+ userId: "user-1",
266
+ staffId: "staff-1"
267
+ }
268
+ ]
269
+ }
270
+ ```
271
+
272
+ `relations` などの特定の Record 表現には依存しません。
273
+
274
+ ---
275
+
276
+ # Nested JOIN
277
+
278
+ JOIN 先には別の Query を指定できます。
279
+
280
+ ```ts
281
+ const reservations = new Query<typeof tables, "reservations">({
282
+ tableName: "reservations",
283
+ }).join("staffId", "staffs", "id");
284
+
285
+ const users = new Query<typeof tables, "users">({
286
+ tableName: "users",
287
+ }).join("id", "reservations", "userId", reservations);
288
+ ```
289
+
290
+ Query 自身が JOIN の木構造を保持します。
291
+
292
+ ```text
293
+ users
294
+ └─ reservations
295
+ └─ staffs
296
+ ```
297
+
298
+ そのため、Table 側に Relation Tree を持たせなくても Query から必要な JOIN 構造を決定できます。
299
+
300
+ ---
301
+
302
+ # Resolve
303
+
304
+ `resolve()` に Loader を渡すことで、データ取得から Query 適用、再帰 JOIN までを一度に解決できます。
305
+
306
+ ```ts
307
+ const result = await users.resolve(load);
308
+ ```
309
+
310
+ Loader は Table 名を受け取り、その Table の Record を返します。
311
+
312
+ ```ts
313
+ const load = async (table: string): Promise<Record<string, unknown>[]> => {
314
+ // 任意の storage から Record を取得
315
+ };
316
+ ```
317
+
318
+ Query は JOIN を再帰的に解決します。
319
+
320
+ ```text
321
+ users
322
+ └─ reservations
323
+ └─ staffs
324
+ ```
325
+
326
+ の場合、概念的には bottom-up に処理されます。
327
+
328
+ ```text
329
+ staffs
330
+ ↓ JOIN
331
+ reservations
332
+ ↓ JOIN
333
+ users
334
+ ```
335
+
336
+ これにより、深い JOIN を持つ Query でも呼び出し側では、
337
+
338
+ ```ts
339
+ const result = await query.resolve(load);
340
+ ```
341
+
342
+ だけで解決できます。
343
+
344
+ ---
345
+
346
+ # Storage Agnostic
347
+
348
+ `@gasboost/query` は Record の取得元を知りません。
349
+
350
+ 例えば SheetORM では Google Sheets から取得できます。
351
+
352
+ ```ts
353
+ const result = await query.resolve(async (table) => {
354
+ return sheetStorage.read(table);
355
+ });
356
+ ```
357
+
358
+ Replica では IndexedDB から取得できます。
359
+
360
+ ```ts
361
+ const result = await query.resolve(async (table) => {
362
+ return replicaStorage.read(table);
363
+ });
364
+ ```
365
+
366
+ Query の意味とストレージ I/O を分離することで、同じ Query を複数のデータストアで共有できます。
367
+
368
+ ---
369
+
370
+ # Architecture
371
+
372
+ `@gasboost/query` の責務は、Record に対する問い合わせ処理です。
373
+
374
+ ```text
375
+ Query
376
+ ├─ Filter
377
+ │ ├─ FilterOperator
378
+ │ └─ FilterOperand
379
+ ├─ OrderBy
380
+ ├─ Join
381
+ └─ resolve()
382
+ ```
383
+
384
+ ストレージ固有の責務は外部に残します。
385
+
386
+ ```text
387
+ @gasboost/query
388
+ - Filter
389
+ - Sort
390
+ - Offset
391
+ - Limit
392
+ - Join
393
+ - Recursive resolution
394
+
395
+ @gasboost/sheetorm
396
+ - Google Sheets I/O
397
+
398
+ @gasboost/replica
399
+ - IndexedDB I/O
400
+ ```
401
+
402
+ この分離により、Query の評価ロジックを各ストレージ実装で重複させずに利用できます。
403
+
404
+ ---
405
+
406
+ # License
407
+
408
+ MIT
@@ -5,18 +5,24 @@ class FilterOperand {
5
5
  values;
6
6
  constructor(values) {
7
7
  this.values = values;
8
- if (values.length === 0)
8
+ if (values.length === 0) {
9
9
  throw new Error("values must not be empty");
10
- const firstType = typeof values[0];
11
- if (firstType === "object")
10
+ }
11
+ if (values[0] instanceof Date) {
12
+ const isSameType = values.every((value) => value instanceof Date);
13
+ if (!isSameType) {
14
+ throw new Error("values must be all the same type");
15
+ }
12
16
  return;
13
- const isSameType = values.every((v) => v !== null && v !== undefined && typeof v === firstType);
17
+ }
18
+ const firstType = typeof values[0];
19
+ const isSameType = values.every((value) => value !== null && value !== undefined && typeof value === firstType);
14
20
  if (!isSameType) {
15
21
  throw new Error("values must be all the same type");
16
22
  }
17
23
  }
18
24
  isDate() {
19
- return Object.prototype.toString.call(this.values[0]) === "[object Date]";
25
+ return this.values[0] instanceof Date;
20
26
  }
21
27
  getValue() {
22
28
  return this.values;
@@ -25,7 +31,7 @@ class FilterOperand {
25
31
  if (!this.isDate()) {
26
32
  throw new Error("values are not Date type");
27
33
  }
28
- return this.values.map((v) => v.getTime());
34
+ return this.values.map((value) => value.getTime());
29
35
  }
30
36
  isStringOrBoolean() {
31
37
  return (typeof this.values[0] === "string" || typeof this.values[0] === "boolean");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gasboost/query",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Type-safe, storage-agnostic query engine powered by Zod",
5
5
  "repository": {
6
6
  "type": "git",