@h-ai/reldb 0.1.0-alpha.56 → 0.1.0-alpha.58
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.en.md +520 -0
- package/README.ja.md +520 -0
- package/README.md +4 -0
- package/package.json +13 -13
package/README.en.md
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
# @h-ai/reldb
|
|
2
|
+
|
|
3
|
+
[简体中文](./README.md) | [English](./README.en.md) | [日本語](./README.ja.md)
|
|
4
|
+
|
|
5
|
+
[Framework home](../../README.en.md) · Related modules: [@h-ai/core](../core/README.en.md)
|
|
6
|
+
|
|
7
|
+
A relational database module with a unified `reldb` object for SQLite, PostgreSQL, and MySQL.
|
|
8
|
+
|
|
9
|
+
## Supported databases
|
|
10
|
+
|
|
11
|
+
- SQLite
|
|
12
|
+
- PostgreSQL
|
|
13
|
+
- MySQL
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { HaiReldbError, reldb } from '@h-ai/reldb'
|
|
19
|
+
|
|
20
|
+
// 初始化
|
|
21
|
+
await reldb.init({ type: 'sqlite', database: ':memory:' })
|
|
22
|
+
|
|
23
|
+
// DDL
|
|
24
|
+
await reldb.ddl.createTable('users', {
|
|
25
|
+
id: { type: 'INTEGER', primaryKey: true, autoIncrement: true },
|
|
26
|
+
name: { type: 'TEXT', notNull: true },
|
|
27
|
+
email: { type: 'TEXT', unique: true },
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// SQL
|
|
31
|
+
await reldb.sql.execute('INSERT INTO users (name, email) VALUES (?, ?)', ['张三', 'test@example.com'])
|
|
32
|
+
const users = await reldb.sql.query<{ id: number, name: string }>('SELECT * FROM users')
|
|
33
|
+
|
|
34
|
+
// 分页查询
|
|
35
|
+
const page = await reldb.sql.queryPage<{ id: number, name: string }>({
|
|
36
|
+
sql: 'SELECT id, name FROM users ORDER BY id',
|
|
37
|
+
pagination: { page: 1, pageSize: 20 },
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// CRUD
|
|
41
|
+
const userCrud = reldb.crud.table({
|
|
42
|
+
table: 'users',
|
|
43
|
+
idColumn: 'id',
|
|
44
|
+
select: ['id', 'name', 'email'],
|
|
45
|
+
createColumns: ['name', 'email'],
|
|
46
|
+
updateColumns: ['name', 'email'],
|
|
47
|
+
dbType: 'sqlite',
|
|
48
|
+
})
|
|
49
|
+
await userCrud.create({ name: '李四', email: 'li@test.com' })
|
|
50
|
+
const user = await userCrud.findById(1)
|
|
51
|
+
|
|
52
|
+
// 关闭
|
|
53
|
+
await reldb.close()
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`reldb.config` returns a **redacted configuration snapshot**. Database `password` and credentials embedded in URLs are hidden automatically. Use it to inspect state, not to replay original connection details.
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
Use either a connection string (`url`) or separate fields (`host/port/database/user/password`).
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
// SQLite
|
|
64
|
+
await reldb.init({ type: 'sqlite', database: './data.db' })
|
|
65
|
+
|
|
66
|
+
// PostgreSQL
|
|
67
|
+
await reldb.init({ type: 'postgresql', url: 'postgres://user:pass@localhost:5432/mydb' })
|
|
68
|
+
|
|
69
|
+
// MySQL
|
|
70
|
+
await reldb.init({
|
|
71
|
+
type: 'mysql',
|
|
72
|
+
host: 'localhost',
|
|
73
|
+
port: 3306,
|
|
74
|
+
database: 'mydb',
|
|
75
|
+
user: 'admin',
|
|
76
|
+
password: 'secret',
|
|
77
|
+
mysql: { charset: 'utf8mb4' },
|
|
78
|
+
})
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### SQLite execution (asynchronous worker)
|
|
82
|
+
|
|
83
|
+
better-sqlite3 is synchronous, so queries block Node's main event loop. WAL improves database lock concurrency but cannot remove this blocking. To offload it, opt in to a dedicated SQLite worker thread (single connection; defaults to `sync`):
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
await reldb.init({
|
|
87
|
+
type: 'sqlite',
|
|
88
|
+
database: './data.db',
|
|
89
|
+
sqlite: {
|
|
90
|
+
executor: 'worker', // 'sync'(默认)| 'worker'
|
|
91
|
+
walMode: true, // 默认 true
|
|
92
|
+
workerConnectTimeoutMs: 10000, // worker 打开连接超时,默认 10000
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
In worker mode, queries, writes, and transactions run serially on the worker thread. Public APIs and return values remain identical to synchronous mode.
|
|
98
|
+
|
|
99
|
+
### Operation logging
|
|
100
|
+
|
|
101
|
+
Enable SQL operation logs with `operationLog` in the database configuration. Logs are emitted at the DML layer of reldb provider-base, without duplicate wrappers in `reldb-main.ts` or raw providers.
|
|
102
|
+
|
|
103
|
+
```yaml
|
|
104
|
+
# config/_db.yml
|
|
105
|
+
type: sqlite
|
|
106
|
+
database: ./data/app.db
|
|
107
|
+
operationLog:
|
|
108
|
+
read: false # query / get / queryPage
|
|
109
|
+
write: false # DDL, execute / batch, tx.begin / tx.commit / tx.rollback
|
|
110
|
+
maxLength: 1000 # 参数序列化后的最大输出长度,超出会截断
|
|
111
|
+
level: debug # info | debug | trace,默认 debug
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
When loading through `core.config.load('db', ...)`, convention-based variables such as `HAI_DB_TYPE`,
|
|
115
|
+
`HAI_DB_DATABASE`, and `HAI_DB_OPERATIONLOG_LEVEL` override YAML values.
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
await reldb.init({
|
|
119
|
+
type: 'sqlite',
|
|
120
|
+
database: './data.db',
|
|
121
|
+
operationLog: {
|
|
122
|
+
read: true,
|
|
123
|
+
write: true,
|
|
124
|
+
maxLength: 500,
|
|
125
|
+
level: 'debug',
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Transactions
|
|
131
|
+
|
|
132
|
+
Three approaches:
|
|
133
|
+
|
|
134
|
+
### wrap (automatic commit/rollback)
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
const result = await reldb.tx.wrap(async (tx) => {
|
|
138
|
+
await tx.execute('INSERT INTO users (name) VALUES (?)', ['用户1'])
|
|
139
|
+
await tx.execute('INSERT INTO users (name) VALUES (?)', ['用户2'])
|
|
140
|
+
return 'ok'
|
|
141
|
+
})
|
|
142
|
+
// 回调正常返回 → 自动提交;抛异常 → 自动回滚
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### begin + commit (explicit commit)
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
const txResult = await reldb.tx.begin()
|
|
149
|
+
if (!txResult.success) { /* 处理错误 */ }
|
|
150
|
+
const tx = txResult.data
|
|
151
|
+
|
|
152
|
+
await tx.execute('INSERT INTO users (name) VALUES (?)', ['用户1'])
|
|
153
|
+
await tx.execute('INSERT INTO users (name) VALUES (?)', ['用户2'])
|
|
154
|
+
await tx.commit()
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### begin + rollback (explicit rollback)
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
const txResult = await reldb.tx.begin()
|
|
161
|
+
if (!txResult.success) { /* 处理错误 */ }
|
|
162
|
+
const tx = txResult.data
|
|
163
|
+
|
|
164
|
+
await tx.execute('INSERT INTO users (name) VALUES (?)', ['用户1'])
|
|
165
|
+
// 业务检查失败,手动回滚
|
|
166
|
+
await tx.rollback()
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### batch / queryPage inside transactions
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
await reldb.tx.wrap(async (tx) => {
|
|
173
|
+
await tx.batch([
|
|
174
|
+
{ sql: 'INSERT INTO users (name) VALUES (?)', params: ['用户1'] },
|
|
175
|
+
{ sql: 'INSERT INTO users (name) VALUES (?)', params: ['用户2'] },
|
|
176
|
+
])
|
|
177
|
+
|
|
178
|
+
const page = await tx.queryPage<{ id: number, name: string }>({
|
|
179
|
+
sql: 'SELECT id, name FROM users ORDER BY id',
|
|
180
|
+
pagination: { page: 1, pageSize: 10 },
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Crud
|
|
186
|
+
|
|
187
|
+
Create a single-table CRUD repository with `reldb.crud.table()`, without writing SQL:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
const userCrud = reldb.crud.table<{ id: number, name: string, email: string }>({
|
|
191
|
+
table: 'users',
|
|
192
|
+
idColumn: 'id', // 主键列,默认 'id'
|
|
193
|
+
select: ['id', 'name', 'email'], // 查询列,默认 '*'
|
|
194
|
+
createColumns: ['name', 'email'], // 允许插入的列
|
|
195
|
+
updateColumns: ['name', 'email'], // 允许更新的列
|
|
196
|
+
dbType: 'sqlite', // 数据库类型
|
|
197
|
+
})
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### create / createMany
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
// 单条插入
|
|
204
|
+
const result = await userCrud.create({ name: '张三', email: 'test@example.com' })
|
|
205
|
+
// result.data → { changes: 1, lastInsertRowid: 1 }
|
|
206
|
+
|
|
207
|
+
// 批量插入
|
|
208
|
+
await userCrud.createMany([
|
|
209
|
+
{ name: '用户A', email: 'a@test.com' },
|
|
210
|
+
{ name: '用户B', email: 'b@test.com' },
|
|
211
|
+
])
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### createOrUpdate
|
|
215
|
+
|
|
216
|
+
Update when the primary key exists; otherwise create (upsert):
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
const userCrud = reldb.crud.table<{ id: number, name: string, email: string }>({
|
|
220
|
+
table: 'users',
|
|
221
|
+
idColumn: 'id',
|
|
222
|
+
createColumns: ['id', 'name', 'email'],
|
|
223
|
+
updateColumns: ['name', 'email'],
|
|
224
|
+
dbType: 'sqlite',
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
await userCrud.createOrUpdate({ id: 1, name: '张三', email: 'test@example.com' })
|
|
228
|
+
// 不存在 → 插入;已存在 → 更新 name、email
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`BaseReldbCrudRepository` obtains `dbType` automatically; no manual argument is needed.
|
|
232
|
+
|
|
233
|
+
### findById / getById / findAll / findPage
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
// 主键查找(不存在时返回 null)
|
|
237
|
+
const user = await userCrud.findById(1)
|
|
238
|
+
// user.data → { id: 1, name: '张三', email: 'test@example.com' } | null
|
|
239
|
+
|
|
240
|
+
// 主键获取(不存在时返回 RECORD_NOT_FOUND 错误)
|
|
241
|
+
const user2 = await userCrud.getById(1)
|
|
242
|
+
// user2.data → { id: 1, name: '张三', email: 'test@example.com' }
|
|
243
|
+
|
|
244
|
+
// 条件查询(where + params 占位符)
|
|
245
|
+
const actives = await userCrud.findAll({
|
|
246
|
+
where: 'name LIKE ?',
|
|
247
|
+
params: ['%张%'],
|
|
248
|
+
orderBy: 'id DESC',
|
|
249
|
+
limit: 10,
|
|
250
|
+
offset: 0,
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
// 分页查询
|
|
254
|
+
const page = await userCrud.findPage({
|
|
255
|
+
where: 'name LIKE ?',
|
|
256
|
+
params: ['%张%'],
|
|
257
|
+
orderBy: 'id DESC',
|
|
258
|
+
pagination: { page: 1, pageSize: 20 },
|
|
259
|
+
})
|
|
260
|
+
// page.data → { items: [...], total: 100, page: 1, pageSize: 20 }
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### updateById / deleteById
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
await userCrud.updateById(1, { name: '李四' })
|
|
267
|
+
await userCrud.deleteById(1)
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### count / exists / existsById
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
const total = await userCrud.count({ where: 'name LIKE ?', params: ['%张%'] })
|
|
274
|
+
// total.data → 5
|
|
275
|
+
|
|
276
|
+
const has = await userCrud.exists({ where: 'email = ?', params: ['test@example.com'] })
|
|
277
|
+
// has.data → true
|
|
278
|
+
|
|
279
|
+
const found = await userCrud.existsById(1)
|
|
280
|
+
// found.data → true
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Crud inside transactions
|
|
284
|
+
|
|
285
|
+
All methods accept `tx` and automatically route operations to that transaction context:
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
await reldb.tx.wrap(async (tx) => {
|
|
289
|
+
await userCrud.create({ name: '用户A', email: 'a@test.com' }, tx)
|
|
290
|
+
await userCrud.updateById(1, { name: '新名字' }, tx)
|
|
291
|
+
const user = await userCrud.findById(1, tx)
|
|
292
|
+
})
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
## BaseReldbCrudRepository
|
|
296
|
+
|
|
297
|
+
Business repositories extend `BaseReldbCrudRepository` and define field mappings with `fields` for automatic table creation and type conversion:
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
import type { DmlWithTxOperations, ReldbCrudFieldDefinition } from '@h-ai/reldb'
|
|
301
|
+
import { BaseReldbCrudRepository, reldb } from '@h-ai/reldb'
|
|
302
|
+
|
|
303
|
+
interface UserRow {
|
|
304
|
+
id: number
|
|
305
|
+
name: string
|
|
306
|
+
email: string
|
|
307
|
+
createdAt: Date
|
|
308
|
+
updatedAt: Date
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const USER_FIELDS: ReldbCrudFieldDefinition[] = [
|
|
312
|
+
{ fieldName: 'id', columnName: 'id', def: { type: 'INTEGER', primaryKey: true, autoIncrement: true }, select: true, create: false, update: false },
|
|
313
|
+
{ fieldName: 'name', columnName: 'name', def: { type: 'TEXT', notNull: true }, select: true, create: true, update: true },
|
|
314
|
+
{ fieldName: 'email', columnName: 'email', def: { type: 'TEXT', notNull: true }, select: true, create: true, update: true },
|
|
315
|
+
{ fieldName: 'createdAt', columnName: 'created_at', def: { type: 'TIMESTAMP', notNull: true }, select: true, create: true, update: false },
|
|
316
|
+
{ fieldName: 'updatedAt', columnName: 'updated_at', def: { type: 'TIMESTAMP', notNull: true }, select: true, create: true, update: false },
|
|
317
|
+
]
|
|
318
|
+
|
|
319
|
+
class UserRepository extends BaseReldbCrudRepository<UserRow> {
|
|
320
|
+
constructor() {
|
|
321
|
+
super(reldb, { table: 'users', idColumn: 'id', fields: USER_FIELDS })
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** 自定义查询方法 */
|
|
325
|
+
async findByEmail(email: string) {
|
|
326
|
+
return this.findAll({ where: 'email = ?', params: [email], limit: 1 })
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** 自定义方法中使用 this.sql(tx) 自动路由到事务 */
|
|
330
|
+
async insertRaw(data: { name: string, email: string }, tx?: DmlWithTxOperations) {
|
|
331
|
+
// 自定义原始写操作应先确保表已就绪(避免刚建库/建表较慢时的竞态)
|
|
332
|
+
const ready = await this.ensureReady()
|
|
333
|
+
if (!ready.success)
|
|
334
|
+
return ready
|
|
335
|
+
const now = Date.now()
|
|
336
|
+
return this.sql(tx).execute(
|
|
337
|
+
'INSERT INTO users (name, email, created_at, updated_at) VALUES (?, ?, ?, ?)',
|
|
338
|
+
[data.name, data.email, now, now],
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 使用
|
|
344
|
+
const repo = new UserRepository()
|
|
345
|
+
await repo.create({ name: '张三', email: 'test@example.com' })
|
|
346
|
+
const user = await repo.findById(1)
|
|
347
|
+
|
|
348
|
+
// 创建或更新(upsert)
|
|
349
|
+
await repo.createOrUpdate({ id: 1, name: '李四', email: 'test@example.com' })
|
|
350
|
+
// id=1 已存在 → 更新 name;不存在 → 插入新记录
|
|
351
|
+
|
|
352
|
+
// 跨仓库事务
|
|
353
|
+
const txResult = await reldb.tx.begin()
|
|
354
|
+
if (txResult.success) {
|
|
355
|
+
const tx = txResult.data
|
|
356
|
+
await repo.create({ name: '用户A', email: 'a@test.com' }, tx)
|
|
357
|
+
await repo.create({ name: '用户B', email: 'b@test.com' }, tx)
|
|
358
|
+
await tx.commit()
|
|
359
|
+
}
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
## JSON path operations
|
|
363
|
+
|
|
364
|
+
Use `reldb.json` to build SQL expressions for consistent JSON path operations across databases.
|
|
365
|
+
|
|
366
|
+
Paths follow SQL/JSON Path (`$.key` / `$.key.subkey` / `$[0]`); each backend maps them to its own functions or operators.
|
|
367
|
+
|
|
368
|
+
| Operation | SQLite | PostgreSQL | MySQL |
|
|
369
|
+
| --------- | -------------- | -------------- | ------------------ |
|
|
370
|
+
| `extract` | `json_extract` | `#>` (text[]) | `JSON_EXTRACT` |
|
|
371
|
+
| `set` | `json_set` | `jsonb_set` | `JSON_SET` |
|
|
372
|
+
| `insert` | `json_insert` | `jsonb_insert` | `JSON_INSERT` |
|
|
373
|
+
| `remove` | `json_remove` | `#-` (text[]) | `JSON_REMOVE` |
|
|
374
|
+
| `merge` | `json_patch` | `\|\| ::jsonb` | `JSON_MERGE_PATCH` |
|
|
375
|
+
|
|
376
|
+
All methods return `{ sql, params }` fragments that can be embedded in `reldb.sql.query` / `reldb.sql.execute`.
|
|
377
|
+
|
|
378
|
+
### extract — read a JSON field value
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
// 用于 WHERE 条件
|
|
382
|
+
const { sql, params } = reldb.json.extract('settings', '$.theme')
|
|
383
|
+
const rows = await reldb.sql.query(
|
|
384
|
+
`SELECT * FROM users WHERE ${sql} = ?`,
|
|
385
|
+
[...params, '"dark"'],
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
// 用于 SELECT 列
|
|
389
|
+
const { sql, params } = reldb.json.extract('profile', '$.name')
|
|
390
|
+
const result = await reldb.sql.query(
|
|
391
|
+
`SELECT id, ${sql} as display_name FROM users`,
|
|
392
|
+
params,
|
|
393
|
+
)
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
### set — set a JSON path (create or replace)
|
|
397
|
+
|
|
398
|
+
```ts
|
|
399
|
+
const { sql, params } = reldb.json.set('settings', '$.theme', 'dark')
|
|
400
|
+
await reldb.sql.execute(
|
|
401
|
+
`UPDATE users SET settings = ${sql} WHERE id = ?`,
|
|
402
|
+
[...params, userId],
|
|
403
|
+
)
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### insert — insert at a JSON path (only when absent)
|
|
407
|
+
|
|
408
|
+
```ts
|
|
409
|
+
const { sql, params } = reldb.json.insert('data', '$.firstSeen', new Date().toISOString())
|
|
410
|
+
await reldb.sql.execute(
|
|
411
|
+
`UPDATE events SET data = ${sql} WHERE id = ?`,
|
|
412
|
+
[...params, eventId],
|
|
413
|
+
)
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
### remove — remove a JSON path
|
|
417
|
+
|
|
418
|
+
```ts
|
|
419
|
+
const { sql, params } = reldb.json.remove('settings', '$.deprecated')
|
|
420
|
+
await reldb.sql.execute(
|
|
421
|
+
`UPDATE users SET settings = ${sql} WHERE id = ?`,
|
|
422
|
+
[...params, userId],
|
|
423
|
+
)
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
### merge — merge JSON objects (RFC 7396 Merge Patch)
|
|
427
|
+
|
|
428
|
+
```ts
|
|
429
|
+
// 覆盖已有键、保留未提及键、将 null 键删除
|
|
430
|
+
const { sql, params } = reldb.json.merge('profile', {
|
|
431
|
+
bio: '新简介',
|
|
432
|
+
avatar: null, // 传入 null 表示删除该键
|
|
433
|
+
})
|
|
434
|
+
await reldb.sql.execute(
|
|
435
|
+
`UPDATE users SET profile = ${sql} WHERE id = ?`,
|
|
436
|
+
[...params, userId],
|
|
437
|
+
)
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### Complete example
|
|
441
|
+
|
|
442
|
+
```ts
|
|
443
|
+
// 建表(含 JSON 列)
|
|
444
|
+
await reldb.ddl.createTable('products', {
|
|
445
|
+
id: { type: 'INTEGER', primaryKey: true, autoIncrement: true },
|
|
446
|
+
meta: { type: 'JSON' },
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
// 插入初始 JSON
|
|
450
|
+
await reldb.sql.execute(
|
|
451
|
+
`INSERT INTO products (meta) VALUES (?)`,
|
|
452
|
+
[JSON.stringify({ status: 'draft', tags: ['new'], version: 1 })],
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
// 设置字段
|
|
456
|
+
const { sql: setSql, params: setParams } = reldb.json.set('meta', '$.status', 'published')
|
|
457
|
+
await reldb.sql.execute(`UPDATE products SET meta = ${setSql} WHERE id = ?`, [...setParams, 1])
|
|
458
|
+
|
|
459
|
+
// 提取字段(用于过滤)
|
|
460
|
+
const { sql: exSql, params: exParams } = reldb.json.extract('meta', '$.status')
|
|
461
|
+
const published = await reldb.sql.query(
|
|
462
|
+
`SELECT * FROM products WHERE ${exSql} = ?`,
|
|
463
|
+
[...exParams, '"published"'],
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
// 合并更新
|
|
467
|
+
const { sql: mSql, params: mParams } = reldb.json.merge('meta', { version: 2, reviewer: 'admin' })
|
|
468
|
+
await reldb.sql.execute(`UPDATE products SET meta = ${mSql} WHERE id = ?`, [...mParams, 1])
|
|
469
|
+
|
|
470
|
+
// 删除字段
|
|
471
|
+
const { sql: rmSql, params: rmParams } = reldb.json.remove('meta', '$.reviewer')
|
|
472
|
+
await reldb.sql.execute(`UPDATE products SET meta = ${rmSql} WHERE id = ?`, [...rmParams, 1])
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
## Error handling
|
|
476
|
+
|
|
477
|
+
All operations return `HaiResult<T>`; check `result.success` for success or failure.
|
|
478
|
+
|
|
479
|
+
```ts
|
|
480
|
+
import { HaiReldbError, reldb } from '@h-ai/reldb'
|
|
481
|
+
|
|
482
|
+
const result = await reldb.sql.query('SELECT * FROM users')
|
|
483
|
+
if (!result.success) {
|
|
484
|
+
switch (result.error.code) {
|
|
485
|
+
case HaiReldbError.NOT_INITIALIZED.code:
|
|
486
|
+
// 请先调用 reldb.init()
|
|
487
|
+
break
|
|
488
|
+
case HaiReldbError.QUERY_FAILED.code:
|
|
489
|
+
// SQL 执行错误
|
|
490
|
+
break
|
|
491
|
+
case HaiReldbError.CONSTRAINT_VIOLATION.code:
|
|
492
|
+
// 约束违反(如唯一约束、外键等)
|
|
493
|
+
break
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
Common error codes:
|
|
499
|
+
|
|
500
|
+
| Error code | code | Description |
|
|
501
|
+
| ------------------------------------ | --------------- | --------------------- |
|
|
502
|
+
| `HaiReldbError.NOT_INITIALIZED` | `hai:reldb:010` | Not initialized |
|
|
503
|
+
| `HaiReldbError.CONNECTION_FAILED` | `hai:reldb:001` | Connection failed |
|
|
504
|
+
| `HaiReldbError.QUERY_FAILED` | `hai:reldb:002` | Query failed |
|
|
505
|
+
| `HaiReldbError.CONSTRAINT_VIOLATION` | `hai:reldb:003` | Constraint violation |
|
|
506
|
+
| `HaiReldbError.TRANSACTION_FAILED` | `hai:reldb:004` | Transaction failed |
|
|
507
|
+
| `HaiReldbError.DDL_FAILED` | `hai:reldb:020` | DDL operation failed |
|
|
508
|
+
| `HaiReldbError.CONFIG_ERROR` | `hai:reldb:013` | Invalid configuration |
|
|
509
|
+
|
|
510
|
+
## Tests
|
|
511
|
+
|
|
512
|
+
```bash
|
|
513
|
+
pnpm --filter @h-ai/reldb test
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
> MySQL/PostgreSQL tests require Docker.
|
|
517
|
+
|
|
518
|
+
## License
|
|
519
|
+
|
|
520
|
+
Apache-2.0
|
package/README.ja.md
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
# @h-ai/reldb
|
|
2
|
+
|
|
3
|
+
[简体中文](./README.md) | [English](./README.en.md) | [日本語](./README.ja.md)
|
|
4
|
+
|
|
5
|
+
[フレームワークのホーム](../../README.ja.md) · 関連モジュール:[@h-ai/core](../core/README.ja.md)
|
|
6
|
+
|
|
7
|
+
共通の `reldb` オブジェクトで SQLite、PostgreSQL、MySQL を操作するリレーショナルデータベースモジュールです。
|
|
8
|
+
|
|
9
|
+
## 対応データベース
|
|
10
|
+
|
|
11
|
+
- SQLite
|
|
12
|
+
- PostgreSQL
|
|
13
|
+
- MySQL
|
|
14
|
+
|
|
15
|
+
## クイックスタート
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { HaiReldbError, reldb } from '@h-ai/reldb'
|
|
19
|
+
|
|
20
|
+
// 初始化
|
|
21
|
+
await reldb.init({ type: 'sqlite', database: ':memory:' })
|
|
22
|
+
|
|
23
|
+
// DDL
|
|
24
|
+
await reldb.ddl.createTable('users', {
|
|
25
|
+
id: { type: 'INTEGER', primaryKey: true, autoIncrement: true },
|
|
26
|
+
name: { type: 'TEXT', notNull: true },
|
|
27
|
+
email: { type: 'TEXT', unique: true },
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// SQL
|
|
31
|
+
await reldb.sql.execute('INSERT INTO users (name, email) VALUES (?, ?)', ['张三', 'test@example.com'])
|
|
32
|
+
const users = await reldb.sql.query<{ id: number, name: string }>('SELECT * FROM users')
|
|
33
|
+
|
|
34
|
+
// 分页查询
|
|
35
|
+
const page = await reldb.sql.queryPage<{ id: number, name: string }>({
|
|
36
|
+
sql: 'SELECT id, name FROM users ORDER BY id',
|
|
37
|
+
pagination: { page: 1, pageSize: 20 },
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// CRUD
|
|
41
|
+
const userCrud = reldb.crud.table({
|
|
42
|
+
table: 'users',
|
|
43
|
+
idColumn: 'id',
|
|
44
|
+
select: ['id', 'name', 'email'],
|
|
45
|
+
createColumns: ['name', 'email'],
|
|
46
|
+
updateColumns: ['name', 'email'],
|
|
47
|
+
dbType: 'sqlite',
|
|
48
|
+
})
|
|
49
|
+
await userCrud.create({ name: '李四', email: 'li@test.com' })
|
|
50
|
+
const user = await userCrud.findById(1)
|
|
51
|
+
|
|
52
|
+
// 关闭
|
|
53
|
+
await reldb.close()
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`reldb.config` は**機密情報をマスクした設定スナップショット**を返します。DB の `password` と URL 内の認証情報は自動的に非表示になります。状態確認用であり、元の接続情報の再利用には使わないでください。
|
|
57
|
+
|
|
58
|
+
## 設定
|
|
59
|
+
|
|
60
|
+
接続文字列(`url`)または個別フィールド(`host/port/database/user/password`)を指定できます。
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
// SQLite
|
|
64
|
+
await reldb.init({ type: 'sqlite', database: './data.db' })
|
|
65
|
+
|
|
66
|
+
// PostgreSQL
|
|
67
|
+
await reldb.init({ type: 'postgresql', url: 'postgres://user:pass@localhost:5432/mydb' })
|
|
68
|
+
|
|
69
|
+
// MySQL
|
|
70
|
+
await reldb.init({
|
|
71
|
+
type: 'mysql',
|
|
72
|
+
host: 'localhost',
|
|
73
|
+
port: 3306,
|
|
74
|
+
database: 'mydb',
|
|
75
|
+
user: 'admin',
|
|
76
|
+
password: 'secret',
|
|
77
|
+
mysql: { charset: 'utf8mb4' },
|
|
78
|
+
})
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### SQLite の実行方式(非同期 worker)
|
|
82
|
+
|
|
83
|
+
better-sqlite3 は同期ドライバーなので、クエリは Node のメインイベントループをブロックします。WAL は DB ロックの並行性を改善しますが、同期呼び出しによるブロックは解消しません。専用 worker スレッドで SQLite を実行して処理を移せます(単一接続、任意設定、既定は `sync`):
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
await reldb.init({
|
|
87
|
+
type: 'sqlite',
|
|
88
|
+
database: './data.db',
|
|
89
|
+
sqlite: {
|
|
90
|
+
executor: 'worker', // 'sync'(默认)| 'worker'
|
|
91
|
+
walMode: true, // 默认 true
|
|
92
|
+
workerConnectTimeoutMs: 10000, // worker 打开连接超时,默认 10000
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
worker モードではクエリ、書き込み、トランザクションを worker スレッドで順番に実行します。公開 API と戻り値は同期モードと同じです。
|
|
98
|
+
|
|
99
|
+
### 操作ログ
|
|
100
|
+
|
|
101
|
+
DB 設定の `operationLog` で SQL 操作ログを有効にできます。ログは reldb provider-base の DML 層で出力され、`reldb-main.ts` や各 raw provider で重複してラップしません。
|
|
102
|
+
|
|
103
|
+
```yaml
|
|
104
|
+
# config/_db.yml
|
|
105
|
+
type: sqlite
|
|
106
|
+
database: ./data/app.db
|
|
107
|
+
operationLog:
|
|
108
|
+
read: false # query / get / queryPage
|
|
109
|
+
write: false # DDL, execute / batch, tx.begin / tx.commit / tx.rollback
|
|
110
|
+
maxLength: 1000 # 参数序列化后的最大输出长度,超出会截断
|
|
111
|
+
level: debug # info | debug | trace,默认 debug
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`core.config.load('db', ...)` で読み込む場合、`HAI_DB_TYPE`、
|
|
115
|
+
`HAI_DB_DATABASE`、`HAI_DB_OPERATIONLOG_LEVEL` などの規約に沿う変数が YAML より優先されます。
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
await reldb.init({
|
|
119
|
+
type: 'sqlite',
|
|
120
|
+
database: './data.db',
|
|
121
|
+
operationLog: {
|
|
122
|
+
read: true,
|
|
123
|
+
write: true,
|
|
124
|
+
maxLength: 500,
|
|
125
|
+
level: 'debug',
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## トランザクション
|
|
131
|
+
|
|
132
|
+
3 種類の使用方法:
|
|
133
|
+
|
|
134
|
+
### wrap(自動コミット/ロールバック)
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
const result = await reldb.tx.wrap(async (tx) => {
|
|
138
|
+
await tx.execute('INSERT INTO users (name) VALUES (?)', ['用户1'])
|
|
139
|
+
await tx.execute('INSERT INTO users (name) VALUES (?)', ['用户2'])
|
|
140
|
+
return 'ok'
|
|
141
|
+
})
|
|
142
|
+
// 回调正常返回 → 自动提交;抛异常 → 自动回滚
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### begin + commit(手動コミット)
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
const txResult = await reldb.tx.begin()
|
|
149
|
+
if (!txResult.success) { /* 处理错误 */ }
|
|
150
|
+
const tx = txResult.data
|
|
151
|
+
|
|
152
|
+
await tx.execute('INSERT INTO users (name) VALUES (?)', ['用户1'])
|
|
153
|
+
await tx.execute('INSERT INTO users (name) VALUES (?)', ['用户2'])
|
|
154
|
+
await tx.commit()
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### begin + rollback(手動ロールバック)
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
const txResult = await reldb.tx.begin()
|
|
161
|
+
if (!txResult.success) { /* 处理错误 */ }
|
|
162
|
+
const tx = txResult.data
|
|
163
|
+
|
|
164
|
+
await tx.execute('INSERT INTO users (name) VALUES (?)', ['用户1'])
|
|
165
|
+
// 业务检查失败,手动回滚
|
|
166
|
+
await tx.rollback()
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### トランザクション内での batch / queryPage
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
await reldb.tx.wrap(async (tx) => {
|
|
173
|
+
await tx.batch([
|
|
174
|
+
{ sql: 'INSERT INTO users (name) VALUES (?)', params: ['用户1'] },
|
|
175
|
+
{ sql: 'INSERT INTO users (name) VALUES (?)', params: ['用户2'] },
|
|
176
|
+
])
|
|
177
|
+
|
|
178
|
+
const page = await tx.queryPage<{ id: number, name: string }>({
|
|
179
|
+
sql: 'SELECT id, name FROM users ORDER BY id',
|
|
180
|
+
pagination: { page: 1, pageSize: 10 },
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Crud
|
|
186
|
+
|
|
187
|
+
`reldb.crud.table()` で単一テーブルの CRUD リポジトリーを作成できます。SQL の記述は不要です:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
const userCrud = reldb.crud.table<{ id: number, name: string, email: string }>({
|
|
191
|
+
table: 'users',
|
|
192
|
+
idColumn: 'id', // 主键列,默认 'id'
|
|
193
|
+
select: ['id', 'name', 'email'], // 查询列,默认 '*'
|
|
194
|
+
createColumns: ['name', 'email'], // 允许插入的列
|
|
195
|
+
updateColumns: ['name', 'email'], // 允许更新的列
|
|
196
|
+
dbType: 'sqlite', // 数据库类型
|
|
197
|
+
})
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### create / createMany
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
// 单条插入
|
|
204
|
+
const result = await userCrud.create({ name: '张三', email: 'test@example.com' })
|
|
205
|
+
// result.data → { changes: 1, lastInsertRowid: 1 }
|
|
206
|
+
|
|
207
|
+
// 批量插入
|
|
208
|
+
await userCrud.createMany([
|
|
209
|
+
{ name: '用户A', email: 'a@test.com' },
|
|
210
|
+
{ name: '用户B', email: 'b@test.com' },
|
|
211
|
+
])
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### createOrUpdate
|
|
215
|
+
|
|
216
|
+
主キーが存在すれば更新し、存在しなければ作成します(upsert):
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
const userCrud = reldb.crud.table<{ id: number, name: string, email: string }>({
|
|
220
|
+
table: 'users',
|
|
221
|
+
idColumn: 'id',
|
|
222
|
+
createColumns: ['id', 'name', 'email'],
|
|
223
|
+
updateColumns: ['name', 'email'],
|
|
224
|
+
dbType: 'sqlite',
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
await userCrud.createOrUpdate({ id: 1, name: '张三', email: 'test@example.com' })
|
|
228
|
+
// 不存在 → 插入;已存在 → 更新 name、email
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`BaseReldbCrudRepository` は `dbType` を自動取得するため、手動指定は不要です。
|
|
232
|
+
|
|
233
|
+
### findById / getById / findAll / findPage
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
// 主键查找(不存在时返回 null)
|
|
237
|
+
const user = await userCrud.findById(1)
|
|
238
|
+
// user.data → { id: 1, name: '张三', email: 'test@example.com' } | null
|
|
239
|
+
|
|
240
|
+
// 主键获取(不存在时返回 RECORD_NOT_FOUND 错误)
|
|
241
|
+
const user2 = await userCrud.getById(1)
|
|
242
|
+
// user2.data → { id: 1, name: '张三', email: 'test@example.com' }
|
|
243
|
+
|
|
244
|
+
// 条件查询(where + params 占位符)
|
|
245
|
+
const actives = await userCrud.findAll({
|
|
246
|
+
where: 'name LIKE ?',
|
|
247
|
+
params: ['%张%'],
|
|
248
|
+
orderBy: 'id DESC',
|
|
249
|
+
limit: 10,
|
|
250
|
+
offset: 0,
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
// 分页查询
|
|
254
|
+
const page = await userCrud.findPage({
|
|
255
|
+
where: 'name LIKE ?',
|
|
256
|
+
params: ['%张%'],
|
|
257
|
+
orderBy: 'id DESC',
|
|
258
|
+
pagination: { page: 1, pageSize: 20 },
|
|
259
|
+
})
|
|
260
|
+
// page.data → { items: [...], total: 100, page: 1, pageSize: 20 }
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### updateById / deleteById
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
await userCrud.updateById(1, { name: '李四' })
|
|
267
|
+
await userCrud.deleteById(1)
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### count / exists / existsById
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
const total = await userCrud.count({ where: 'name LIKE ?', params: ['%张%'] })
|
|
274
|
+
// total.data → 5
|
|
275
|
+
|
|
276
|
+
const has = await userCrud.exists({ where: 'email = ?', params: ['test@example.com'] })
|
|
277
|
+
// has.data → true
|
|
278
|
+
|
|
279
|
+
const found = await userCrud.existsById(1)
|
|
280
|
+
// found.data → true
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### トランザクション内での Crud
|
|
284
|
+
|
|
285
|
+
すべてのメソッドに `tx` を渡すことができ、対応するトランザクションのコンテキストへ自動的に切り替わります:
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
await reldb.tx.wrap(async (tx) => {
|
|
289
|
+
await userCrud.create({ name: '用户A', email: 'a@test.com' }, tx)
|
|
290
|
+
await userCrud.updateById(1, { name: '新名字' }, tx)
|
|
291
|
+
const user = await userCrud.findById(1, tx)
|
|
292
|
+
})
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
## BaseReldbCrudRepository
|
|
296
|
+
|
|
297
|
+
業務リポジトリーは `BaseReldbCrudRepository` を継承し、`fields` でフィールドの対応を定義します。テーブル作成と型変換は自動です:
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
import type { DmlWithTxOperations, ReldbCrudFieldDefinition } from '@h-ai/reldb'
|
|
301
|
+
import { BaseReldbCrudRepository, reldb } from '@h-ai/reldb'
|
|
302
|
+
|
|
303
|
+
interface UserRow {
|
|
304
|
+
id: number
|
|
305
|
+
name: string
|
|
306
|
+
email: string
|
|
307
|
+
createdAt: Date
|
|
308
|
+
updatedAt: Date
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const USER_FIELDS: ReldbCrudFieldDefinition[] = [
|
|
312
|
+
{ fieldName: 'id', columnName: 'id', def: { type: 'INTEGER', primaryKey: true, autoIncrement: true }, select: true, create: false, update: false },
|
|
313
|
+
{ fieldName: 'name', columnName: 'name', def: { type: 'TEXT', notNull: true }, select: true, create: true, update: true },
|
|
314
|
+
{ fieldName: 'email', columnName: 'email', def: { type: 'TEXT', notNull: true }, select: true, create: true, update: true },
|
|
315
|
+
{ fieldName: 'createdAt', columnName: 'created_at', def: { type: 'TIMESTAMP', notNull: true }, select: true, create: true, update: false },
|
|
316
|
+
{ fieldName: 'updatedAt', columnName: 'updated_at', def: { type: 'TIMESTAMP', notNull: true }, select: true, create: true, update: false },
|
|
317
|
+
]
|
|
318
|
+
|
|
319
|
+
class UserRepository extends BaseReldbCrudRepository<UserRow> {
|
|
320
|
+
constructor() {
|
|
321
|
+
super(reldb, { table: 'users', idColumn: 'id', fields: USER_FIELDS })
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** 自定义查询方法 */
|
|
325
|
+
async findByEmail(email: string) {
|
|
326
|
+
return this.findAll({ where: 'email = ?', params: [email], limit: 1 })
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** 自定义方法中使用 this.sql(tx) 自动路由到事务 */
|
|
330
|
+
async insertRaw(data: { name: string, email: string }, tx?: DmlWithTxOperations) {
|
|
331
|
+
// 自定义原始写操作应先确保表已就绪(避免刚建库/建表较慢时的竞态)
|
|
332
|
+
const ready = await this.ensureReady()
|
|
333
|
+
if (!ready.success)
|
|
334
|
+
return ready
|
|
335
|
+
const now = Date.now()
|
|
336
|
+
return this.sql(tx).execute(
|
|
337
|
+
'INSERT INTO users (name, email, created_at, updated_at) VALUES (?, ?, ?, ?)',
|
|
338
|
+
[data.name, data.email, now, now],
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 使用
|
|
344
|
+
const repo = new UserRepository()
|
|
345
|
+
await repo.create({ name: '张三', email: 'test@example.com' })
|
|
346
|
+
const user = await repo.findById(1)
|
|
347
|
+
|
|
348
|
+
// 创建或更新(upsert)
|
|
349
|
+
await repo.createOrUpdate({ id: 1, name: '李四', email: 'test@example.com' })
|
|
350
|
+
// id=1 已存在 → 更新 name;不存在 → 插入新记录
|
|
351
|
+
|
|
352
|
+
// 跨仓库事务
|
|
353
|
+
const txResult = await reldb.tx.begin()
|
|
354
|
+
if (txResult.success) {
|
|
355
|
+
const tx = txResult.data
|
|
356
|
+
await repo.create({ name: '用户A', email: 'a@test.com' }, tx)
|
|
357
|
+
await repo.create({ name: '用户B', email: 'b@test.com' }, tx)
|
|
358
|
+
await tx.commit()
|
|
359
|
+
}
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
## JSON パス操作
|
|
363
|
+
|
|
364
|
+
`reldb.json` で、複数のデータベースに共通する JSON パス操作の SQL 式を作成します。
|
|
365
|
+
|
|
366
|
+
パスは SQL/JSON Path(`$.key` / `$.key.subkey` / `$[0]`)に従い、各 DB の関数・演算子に自動変換されます。
|
|
367
|
+
|
|
368
|
+
| 操作 | SQLite | PostgreSQL | MySQL |
|
|
369
|
+
| --------- | -------------- | -------------- | ------------------ |
|
|
370
|
+
| `extract` | `json_extract` | `#>` (text[]) | `JSON_EXTRACT` |
|
|
371
|
+
| `set` | `json_set` | `jsonb_set` | `JSON_SET` |
|
|
372
|
+
| `insert` | `json_insert` | `jsonb_insert` | `JSON_INSERT` |
|
|
373
|
+
| `remove` | `json_remove` | `#-` (text[]) | `JSON_REMOVE` |
|
|
374
|
+
| `merge` | `json_patch` | `\|\| ::jsonb` | `JSON_MERGE_PATCH` |
|
|
375
|
+
|
|
376
|
+
すべてのメソッドは `{ sql, params }` の断片を返し、`reldb.sql.query` / `reldb.sql.execute` に直接組み込めます。
|
|
377
|
+
|
|
378
|
+
### extract — JSON フィールド値の取得
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
// 用于 WHERE 条件
|
|
382
|
+
const { sql, params } = reldb.json.extract('settings', '$.theme')
|
|
383
|
+
const rows = await reldb.sql.query(
|
|
384
|
+
`SELECT * FROM users WHERE ${sql} = ?`,
|
|
385
|
+
[...params, '"dark"'],
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
// 用于 SELECT 列
|
|
389
|
+
const { sql, params } = reldb.json.extract('profile', '$.name')
|
|
390
|
+
const result = await reldb.sql.query(
|
|
391
|
+
`SELECT id, ${sql} as display_name FROM users`,
|
|
392
|
+
params,
|
|
393
|
+
)
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
### set — JSON パスの設定(作成または置換)
|
|
397
|
+
|
|
398
|
+
```ts
|
|
399
|
+
const { sql, params } = reldb.json.set('settings', '$.theme', 'dark')
|
|
400
|
+
await reldb.sql.execute(
|
|
401
|
+
`UPDATE users SET settings = ${sql} WHERE id = ?`,
|
|
402
|
+
[...params, userId],
|
|
403
|
+
)
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### insert — JSON パスへの挿入(未存在の場合のみ)
|
|
407
|
+
|
|
408
|
+
```ts
|
|
409
|
+
const { sql, params } = reldb.json.insert('data', '$.firstSeen', new Date().toISOString())
|
|
410
|
+
await reldb.sql.execute(
|
|
411
|
+
`UPDATE events SET data = ${sql} WHERE id = ?`,
|
|
412
|
+
[...params, eventId],
|
|
413
|
+
)
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
### remove — JSON パスの削除
|
|
417
|
+
|
|
418
|
+
```ts
|
|
419
|
+
const { sql, params } = reldb.json.remove('settings', '$.deprecated')
|
|
420
|
+
await reldb.sql.execute(
|
|
421
|
+
`UPDATE users SET settings = ${sql} WHERE id = ?`,
|
|
422
|
+
[...params, userId],
|
|
423
|
+
)
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
### merge — JSON オブジェクトのマージ(RFC 7396 Merge Patch)
|
|
427
|
+
|
|
428
|
+
```ts
|
|
429
|
+
// 覆盖已有键、保留未提及键、将 null 键删除
|
|
430
|
+
const { sql, params } = reldb.json.merge('profile', {
|
|
431
|
+
bio: '新简介',
|
|
432
|
+
avatar: null, // 传入 null 表示删除该键
|
|
433
|
+
})
|
|
434
|
+
await reldb.sql.execute(
|
|
435
|
+
`UPDATE users SET profile = ${sql} WHERE id = ?`,
|
|
436
|
+
[...params, userId],
|
|
437
|
+
)
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### 完全な使用例
|
|
441
|
+
|
|
442
|
+
```ts
|
|
443
|
+
// 建表(含 JSON 列)
|
|
444
|
+
await reldb.ddl.createTable('products', {
|
|
445
|
+
id: { type: 'INTEGER', primaryKey: true, autoIncrement: true },
|
|
446
|
+
meta: { type: 'JSON' },
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
// 插入初始 JSON
|
|
450
|
+
await reldb.sql.execute(
|
|
451
|
+
`INSERT INTO products (meta) VALUES (?)`,
|
|
452
|
+
[JSON.stringify({ status: 'draft', tags: ['new'], version: 1 })],
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
// 设置字段
|
|
456
|
+
const { sql: setSql, params: setParams } = reldb.json.set('meta', '$.status', 'published')
|
|
457
|
+
await reldb.sql.execute(`UPDATE products SET meta = ${setSql} WHERE id = ?`, [...setParams, 1])
|
|
458
|
+
|
|
459
|
+
// 提取字段(用于过滤)
|
|
460
|
+
const { sql: exSql, params: exParams } = reldb.json.extract('meta', '$.status')
|
|
461
|
+
const published = await reldb.sql.query(
|
|
462
|
+
`SELECT * FROM products WHERE ${exSql} = ?`,
|
|
463
|
+
[...exParams, '"published"'],
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
// 合并更新
|
|
467
|
+
const { sql: mSql, params: mParams } = reldb.json.merge('meta', { version: 2, reviewer: 'admin' })
|
|
468
|
+
await reldb.sql.execute(`UPDATE products SET meta = ${mSql} WHERE id = ?`, [...mParams, 1])
|
|
469
|
+
|
|
470
|
+
// 删除字段
|
|
471
|
+
const { sql: rmSql, params: rmParams } = reldb.json.remove('meta', '$.reviewer')
|
|
472
|
+
await reldb.sql.execute(`UPDATE products SET meta = ${rmSql} WHERE id = ?`, [...rmParams, 1])
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
## エラー処理
|
|
476
|
+
|
|
477
|
+
すべての操作は `HaiResult<T>` を返し、`result.success` で成功・失敗を判定します。
|
|
478
|
+
|
|
479
|
+
```ts
|
|
480
|
+
import { HaiReldbError, reldb } from '@h-ai/reldb'
|
|
481
|
+
|
|
482
|
+
const result = await reldb.sql.query('SELECT * FROM users')
|
|
483
|
+
if (!result.success) {
|
|
484
|
+
switch (result.error.code) {
|
|
485
|
+
case HaiReldbError.NOT_INITIALIZED.code:
|
|
486
|
+
// 请先调用 reldb.init()
|
|
487
|
+
break
|
|
488
|
+
case HaiReldbError.QUERY_FAILED.code:
|
|
489
|
+
// SQL 执行错误
|
|
490
|
+
break
|
|
491
|
+
case HaiReldbError.CONSTRAINT_VIOLATION.code:
|
|
492
|
+
// 约束违反(如唯一约束、外键等)
|
|
493
|
+
break
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
主なエラーコード:
|
|
499
|
+
|
|
500
|
+
| エラーコード | code | 説明 |
|
|
501
|
+
| ------------------------------------ | --------------- | ---------------------- |
|
|
502
|
+
| `HaiReldbError.NOT_INITIALIZED` | `hai:reldb:010` | 未初期化 |
|
|
503
|
+
| `HaiReldbError.CONNECTION_FAILED` | `hai:reldb:001` | 接続に失敗 |
|
|
504
|
+
| `HaiReldbError.QUERY_FAILED` | `hai:reldb:002` | 検索に失敗 |
|
|
505
|
+
| `HaiReldbError.CONSTRAINT_VIOLATION` | `hai:reldb:003` | 制約違反 |
|
|
506
|
+
| `HaiReldbError.TRANSACTION_FAILED` | `hai:reldb:004` | トランザクションに失敗 |
|
|
507
|
+
| `HaiReldbError.DDL_FAILED` | `hai:reldb:020` | DDL 操作に失敗 |
|
|
508
|
+
| `HaiReldbError.CONFIG_ERROR` | `hai:reldb:013` | 設定エラー |
|
|
509
|
+
|
|
510
|
+
## テスト
|
|
511
|
+
|
|
512
|
+
```bash
|
|
513
|
+
pnpm --filter @h-ai/reldb test
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
> MySQL/PostgreSQL のテストには Docker が必要です。
|
|
517
|
+
|
|
518
|
+
## License
|
|
519
|
+
|
|
520
|
+
Apache-2.0
|
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@h-ai/reldb",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.1.0-alpha.
|
|
4
|
+
"version": "0.1.0-alpha.58",
|
|
5
5
|
"description": "Hai Framework relational database module for SQLite, PostgreSQL, and MySQL.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"exports": {
|
|
@@ -20,33 +20,33 @@
|
|
|
20
20
|
"access": "public"
|
|
21
21
|
},
|
|
22
22
|
"engines": {
|
|
23
|
-
"node": "
|
|
23
|
+
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
26
|
+
"@h-ai/core": "0.1.0-alpha.58",
|
|
27
|
+
"better-sqlite3": "^12.11.1",
|
|
28
|
+
"zod": "^4.6.5"
|
|
29
29
|
},
|
|
30
30
|
"optionalDependencies": {
|
|
31
|
-
"mysql2": "^3.
|
|
32
|
-
"pg": "^8.
|
|
31
|
+
"mysql2": "^3.24.5",
|
|
32
|
+
"pg": "^8.23.1"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
|
-
"@types/better-sqlite3": "^
|
|
36
|
-
"@types/node": "^
|
|
37
|
-
"@types/pg": "^8.
|
|
35
|
+
"@types/better-sqlite3": "^9.6.0",
|
|
36
|
+
"@types/node": "^26.6.5",
|
|
37
|
+
"@types/pg": "^8.23.1",
|
|
38
38
|
"rimraf": "^6.1.3",
|
|
39
|
-
"testcontainers": "^
|
|
39
|
+
"testcontainers": "^12.2.0",
|
|
40
40
|
"tsup": "^8.5.1",
|
|
41
41
|
"typescript": "^6.0.3",
|
|
42
|
-
"vitest": "^4.1.
|
|
42
|
+
"vitest": "^4.1.11"
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
45
45
|
"build": "tsup",
|
|
46
46
|
"dev": "tsup --watch",
|
|
47
47
|
"test": "vitest run",
|
|
48
48
|
"test:watch": "vitest",
|
|
49
|
-
"typecheck": "
|
|
49
|
+
"typecheck": "node ../../scripts/typecheck.mjs --noEmit",
|
|
50
50
|
"lint": "eslint .",
|
|
51
51
|
"lint:fix": "eslint . --fix",
|
|
52
52
|
"clean": "rimraf dist .turbo",
|