@zerodep-dev/easy-query-ts-hana 0.0.0-stage → 0.2.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/src/index.ts ADDED
@@ -0,0 +1,365 @@
1
+ /**
2
+ * SAP HANA 2.0,驱动用 SAP 维护的纯 JS 驱动 hdb(不需要原生编译)。
3
+ * ```ts
4
+ * const db = createDb({ schema, adapter: hana({ host, port: 39041, user, password, schema: 'APP', max: 5 }) })
5
+ * ```
6
+ * 适配器自带一个小连接池(hdb 一个 client 就是一条连接)。自增主键在同一条连接上用 CURRENT_IDENTITY_VALUE() 取回。
7
+ */
8
+ import hdb, { type Client } from 'hdb';
9
+ import { hanaFunctions } from './functions.ts';
10
+ import {
11
+ assembleTables,
12
+ bareName,
13
+ Dialect,
14
+ SqlWriter,
15
+ type ColumnOptions,
16
+ type Adapter,
17
+ type Without,
18
+ type DbTable,
19
+ type Driver,
20
+ type QueryRunner,
21
+ type ExecuteResult,
22
+ type DatePart,
23
+ type FunctionRenderer,
24
+ type InsertNode,
25
+ type Node,
26
+ type Session,
27
+ type TransactionOptions,
28
+ } from '@zerodep-dev/easy-query-ts';
29
+
30
+ export { Hana } from './functions.ts';
31
+
32
+ /** HANA 的 LIMIT 不能省、OFFSET 前必须有 LIMIT */
33
+ const NO_LIMIT = 2147483647;
34
+
35
+ export class HanaDialect extends Dialect {
36
+ readonly name = 'hana';
37
+ /** HANA 没有 WITH RECURSIVE(层级查询用 HIERARCHY 函数) */
38
+ override readonly supportsRecursiveCte = false;
39
+
40
+ override columnType(kind: string, o: ColumnOptions): string {
41
+ if (o.dbType) return o.dbType;
42
+ switch (kind) {
43
+ case 'string':
44
+ return `NVARCHAR(${o.length ?? 255})`;
45
+ case 'double':
46
+ return 'DOUBLE';
47
+ case 'datetime':
48
+ return 'TIMESTAMP';
49
+ case 'json':
50
+ return 'NCLOB';
51
+ default:
52
+ return super.columnType(kind, o);
53
+ }
54
+ }
55
+
56
+ /** HANA 建列存表用 CREATE COLUMN TABLE,也没有 IF NOT EXISTS(表已存在会报错) */
57
+ override createTableStart(): string {
58
+ return 'CREATE COLUMN TABLE';
59
+ }
60
+
61
+ /** CREATE INDEX、DROP TABLE 都没有 IF [NOT] EXISTS */
62
+ override readonly indexIfNotExists = false;
63
+ override readonly dropIfExists = false;
64
+
65
+ override renameTable(from: string, to: string): string {
66
+ return `RENAME TABLE ${this.table(from)} TO ${this.quote(bareName(to))}`;
67
+ }
68
+
69
+ override renameColumn(table: string, from: string, to: string): string {
70
+ return `RENAME COLUMN ${this.table(table)}.${this.quote(from)} TO ${this.quote(to)}`;
71
+ }
72
+
73
+ /** 加列的列定义要放在括号里 */
74
+ override addColumn(table: string, name: string, kind: string, o: ColumnOptions): string {
75
+ return `ALTER TABLE ${this.table(table)} ADD (${this.columnDefinition(name, kind, o)})`;
76
+ }
77
+
78
+ /** 读 SYS 系统视图(CURRENT_SCHEMA) */
79
+ override async introspect(run: QueryRunner): Promise<DbTable[]> {
80
+ const schema = 'SCHEMA_NAME = CURRENT_SCHEMA';
81
+ return assembleTables({
82
+ tables: await run(
83
+ `SELECT TABLE_NAME AS "table_name", COMMENTS AS "comment" FROM SYS.TABLES WHERE ${schema} ORDER BY TABLE_NAME`,
84
+ ),
85
+ columns: await run(
86
+ `SELECT c.TABLE_NAME AS "table_name", c.COLUMN_NAME AS "column_name",
87
+ CASE WHEN c.DATA_TYPE_NAME IN ('VARCHAR', 'NVARCHAR', 'VARBINARY') THEN c.DATA_TYPE_NAME || '(' || c.LENGTH || ')'
88
+ WHEN c.DATA_TYPE_NAME = 'DECIMAL' AND c.SCALE IS NOT NULL THEN 'DECIMAL(' || c.LENGTH || ', ' || c.SCALE || ')'
89
+ ELSE c.DATA_TYPE_NAME END AS "column_type",
90
+ c.IS_NULLABLE AS "is_nullable", c.DEFAULT_VALUE AS "column_default", c.COMMENTS AS "comment",
91
+ CASE WHEN c.GENERATION_TYPE IS NULL THEN 0 ELSE 1 END AS "is_generated",
92
+ CASE WHEN EXISTS (SELECT 1 FROM SYS.CONSTRAINTS k WHERE k.SCHEMA_NAME = c.SCHEMA_NAME AND k.TABLE_NAME = c.TABLE_NAME
93
+ AND k.COLUMN_NAME = c.COLUMN_NAME AND k.IS_PRIMARY_KEY = 'TRUE') THEN 1 ELSE 0 END AS "primary_key"
94
+ FROM SYS.TABLE_COLUMNS c WHERE c.${schema} ORDER BY c.TABLE_NAME, c.POSITION`,
95
+ ),
96
+ indexes: await run(
97
+ `SELECT i.TABLE_NAME AS "table_name", i.INDEX_NAME AS "index_name",
98
+ CASE WHEN i.CONSTRAINT IN ('UNIQUE', 'NOT NULL UNIQUE') THEN 1 ELSE 0 END AS "is_unique", i.COLUMN_NAME AS "column_name"
99
+ FROM SYS.INDEX_COLUMNS i WHERE i.${schema} AND (i.CONSTRAINT IS NULL OR i.CONSTRAINT <> 'PRIMARY KEY')
100
+ ORDER BY i.TABLE_NAME, i.INDEX_NAME, i.POSITION`,
101
+ ),
102
+ foreignKeys: await run(
103
+ `SELECT TABLE_NAME AS "table_name", CONSTRAINT_NAME AS "constraint_name", COLUMN_NAME AS "column_name",
104
+ REFERENCED_TABLE_NAME AS "referenced_table", REFERENCED_COLUMN_NAME AS "referenced_column"
105
+ FROM SYS.REFERENTIAL_CONSTRAINTS WHERE ${schema} ORDER BY TABLE_NAME, CONSTRAINT_NAME, POSITION`,
106
+ ),
107
+ });
108
+ }
109
+
110
+ /** HANA 跳过被锁行的写法是 IGNORE LOCKED */
111
+ override forUpdate(wait?: 'nowait' | 'skip'): string {
112
+ return (
113
+ ' FOR UPDATE' + (wait === 'nowait' ? ' NOWAIT' : wait === 'skip' ? ' IGNORE LOCKED' : '')
114
+ );
115
+ }
116
+
117
+ /** HANA 没有 RELEASE SAVEPOINT:回滚到保存点即可,其余在事务结束时一起清掉 */
118
+ override releaseSavepoint(): string | undefined {
119
+ return undefined;
120
+ }
121
+
122
+ protected override functions(): Record<string, FunctionRenderer> {
123
+ return {
124
+ ...super.functions(),
125
+ length: (a) => `LENGTH(${a[0]})`,
126
+ // HANA 的 CONCAT 只收两个参数
127
+ concat: (a) => `(${a.join(' || ')})`,
128
+ ...hanaFunctions,
129
+ };
130
+ }
131
+
132
+ /** HANA 的格式化函数叫 TO_VARCHAR,格式符同 TO_CHAR */
133
+ protected override formatDate(value: string, parts: readonly DatePart[]): string {
134
+ return super.formatDate(value, parts).replace(/^TO_CHAR\(/, 'TO_VARCHAR(');
135
+ }
136
+
137
+ /** HANA 的 STRING_AGG 不支持 DISTINCT */
138
+ protected override stringAgg(n: Extract<Node, { k: 'string_agg' }>, w: SqlWriter): string {
139
+ if (n.distinct)
140
+ throw new Error('joining({ distinct: true }) is not supported by the hana dialect');
141
+ // STRING_AGG 只收文字,数字等先转
142
+ return super.stringAgg({ ...n, e: { k: 'raw', parts: ['TO_VARCHAR(', ')'], args: [n.e] } }, w);
143
+ }
144
+
145
+ /**
146
+ * upsert 写成 MERGE:USING 里每行一个 SELECT … FROM DUMMY(多行 UNION ALL),按 target 列匹配;ignore 时没有 WHEN MATCHED。
147
+ */
148
+ override insert(q: InsertNode, w: SqlWriter = new SqlWriter()): string {
149
+ if (!q.conflict && q.rows.length === 1) return super.insert(q, w);
150
+ const cols = q.columns.map((c) => this.quote(c));
151
+ // 渲染时参数按出现顺序收集,所以 source 只渲染一次
152
+ const source = q.rows
153
+ .map(
154
+ (r) =>
155
+ `SELECT ${r.map((n, i) => `${this.node(n, w)} AS ${cols[i]}`).join(', ')} FROM DUMMY`,
156
+ )
157
+ .join(' UNION ALL ');
158
+ // HANA 的 VALUES 只能一行:多行写成 INSERT … SELECT … FROM DUMMY UNION ALL …
159
+ if (!q.conflict) return `INSERT INTO ${this.table(q.table)} (${cols.join(', ')}) ${source}`;
160
+ const on = q.conflict.target
161
+ .map((t) => `eq_t.${this.quote(t)} = eq_s.${this.quote(t)}`)
162
+ .join(' AND ');
163
+ let sql = `MERGE INTO ${this.table(q.table)} eq_t USING (${source}) eq_s ON (${on})`;
164
+ if (q.conflict.update.length > 0) {
165
+ sql += ` WHEN MATCHED THEN UPDATE SET ${q.conflict.update.map((u) => `eq_t.${this.quote(u)} = eq_s.${this.quote(u)}`).join(', ')}`;
166
+ }
167
+ return `${sql} WHEN NOT MATCHED THEN INSERT (${cols.join(', ')}) VALUES (${cols.map((c) => `eq_s.${c}`).join(', ')})`;
168
+ }
169
+
170
+ /**
171
+ * hdb 按服务器推断的参数类型校验 JS 类型;{@code SELECT ? FROM DUMMY}(多行插入、MERGE 的来源)里的参数被推成 NVARCHAR,只收字符串。
172
+ * 数值类型的参数也接受字符串,所以数一律转成字符串传。
173
+ */
174
+ override encodeParam(value: unknown, kind?: string): unknown {
175
+ const v = super.encodeParam(value, kind);
176
+ return typeof v === 'number' || typeof v === 'bigint' ? String(v) : v;
177
+ }
178
+
179
+ override limitOffset(
180
+ limit: number | undefined,
181
+ offset: number | undefined,
182
+ ordered = false,
183
+ ): string {
184
+ if (limit === undefined && offset !== undefined && offset > 0)
185
+ return ` LIMIT ${NO_LIMIT} OFFSET ${offset}`;
186
+ return super.limitOffset(limit, offset, ordered);
187
+ }
188
+ }
189
+
190
+ function call<T>(fn: (cb: (error: Error | null, result: T) => void) => void): Promise<T> {
191
+ return new Promise((resolve, reject) =>
192
+ fn((error, result) => (error ? reject(error) : resolve(result))),
193
+ );
194
+ }
195
+
196
+ async function run(client: Client, sql: string, params: readonly unknown[]): Promise<unknown> {
197
+ if (params.length === 0) return call<unknown>((cb) => client.exec(sql, cb));
198
+ const statement = await call<import('hdb').Statement>((cb) => client.prepare(sql, cb));
199
+ try {
200
+ return await call<unknown>((cb) => statement.exec([...params], cb));
201
+ } finally {
202
+ statement.drop();
203
+ }
204
+ }
205
+
206
+ class HanaSession implements Session {
207
+ protected readonly connection: () => Promise<{ client: Client; release: () => void }>;
208
+
209
+ constructor(connection: () => Promise<{ client: Client; release: () => void }>) {
210
+ this.connection = connection;
211
+ }
212
+
213
+ async query(sql: string, params: readonly unknown[]): Promise<Record<string, unknown>[]> {
214
+ const { client, release } = await this.connection();
215
+ try {
216
+ return (await run(client, sql, params)) as Record<string, unknown>[];
217
+ } finally {
218
+ release();
219
+ }
220
+ }
221
+
222
+ async execute(sql: string, params: readonly unknown[]): Promise<ExecuteResult> {
223
+ const { client, release } = await this.connection();
224
+ try {
225
+ const affected = Number(await run(client, sql, params));
226
+ if (!/^\s*INSERT\b/i.test(sql)) return { affected, insertId: undefined };
227
+ // 同一条连接上取刚生成的 IDENTITY;表没有 IDENTITY 列时为 NULL
228
+ const rows = (await run(
229
+ client,
230
+ 'SELECT CURRENT_IDENTITY_VALUE() AS "id" FROM DUMMY',
231
+ [],
232
+ )) as { id: unknown }[];
233
+ const id = rows[0]?.id;
234
+ return { affected, insertId: id === null || id === undefined ? undefined : toKey(id) };
235
+ } finally {
236
+ release();
237
+ }
238
+ }
239
+ }
240
+
241
+ function toKey(value: unknown): number | bigint {
242
+ if (typeof value === 'number' || typeof value === 'bigint') return value;
243
+ const n = Number(value);
244
+ return Number.isSafeInteger(n) ? n : BigInt(String(value));
245
+ }
246
+
247
+ const ISOLATION = {
248
+ 'read uncommitted': 'READ COMMITTED',
249
+ 'read committed': 'READ COMMITTED',
250
+ 'repeatable read': 'REPEATABLE READ',
251
+ serializable: 'SERIALIZABLE',
252
+ } as const;
253
+
254
+ /** hdb 连接参数(hdb 自己没有类型,这里声明用到的字段;其余原样传给 hdb) */
255
+ export interface HdbClientOptions {
256
+ host: string;
257
+ port: number;
258
+ user: string;
259
+ password: string;
260
+ [option: string]: unknown;
261
+ }
262
+
263
+ export interface HanaOptions extends HdbClientOptions {
264
+ /** 连接后 SET SCHEMA */
265
+ schema?: string;
266
+ /** 连接池上限,默认 5 */
267
+ max?: number;
268
+ }
269
+
270
+ /** 最小连接池:空闲的复用,满了排队 */
271
+ class Pool {
272
+ private readonly options: HanaOptions;
273
+ private readonly idle: Client[] = [];
274
+ private readonly waiting: ((client: Client) => void)[] = [];
275
+ private size = 0;
276
+
277
+ constructor(options: HanaOptions) {
278
+ this.options = options;
279
+ }
280
+
281
+ async acquire(): Promise<Client> {
282
+ const idle = this.idle.pop();
283
+ if (idle) return idle;
284
+ if (this.size < (this.options.max ?? 5)) {
285
+ this.size++;
286
+ try {
287
+ return await this.open();
288
+ } catch (error) {
289
+ this.size--;
290
+ throw error;
291
+ }
292
+ }
293
+ return new Promise((resolve) => this.waiting.push(resolve));
294
+ }
295
+
296
+ release(client: Client): void {
297
+ const next = this.waiting.shift();
298
+ if (next) next(client);
299
+ else this.idle.push(client);
300
+ }
301
+
302
+ end(): void {
303
+ for (const client of this.idle.splice(0)) client.end();
304
+ }
305
+
306
+ private async open(): Promise<Client> {
307
+ const { schema, max: _max, ...connect } = this.options;
308
+ const client = hdb.createClient(connect);
309
+ client.on('error', () => undefined);
310
+ await call<void>((cb) => client.connect((error) => cb(error, undefined)));
311
+ if (schema) await run(client, `SET SCHEMA "${schema.replace(/"/g, '""')}"`, []);
312
+ return client;
313
+ }
314
+ }
315
+
316
+ class HanaDriver extends HanaSession implements Driver {
317
+ private readonly pool: Pool;
318
+
319
+ constructor(pool: Pool) {
320
+ super(async () => {
321
+ const client = await pool.acquire();
322
+ return { client, release: () => pool.release(client) };
323
+ });
324
+ this.pool = pool;
325
+ }
326
+
327
+ async transaction<T>(
328
+ work: (session: Session) => Promise<T>,
329
+ options?: TransactionOptions,
330
+ ): Promise<T> {
331
+ const client = await this.pool.acquire();
332
+ client.setAutoCommit(false);
333
+ try {
334
+ if (options?.isolation)
335
+ await run(client, `SET TRANSACTION ISOLATION LEVEL ${ISOLATION[options.isolation]}`, []);
336
+ if (options?.readOnly) await run(client, 'SET TRANSACTION READ ONLY', []);
337
+ try {
338
+ const result = await work(
339
+ new HanaSession(async () => ({ client, release: () => undefined })),
340
+ );
341
+ await call<void>((cb) => client.commit((error) => cb(error, undefined)));
342
+ return result;
343
+ } catch (error) {
344
+ await call<void>((cb) => client.rollback((error) => cb(error, undefined)));
345
+ throw error;
346
+ }
347
+ } finally {
348
+ client.setAutoCommit(true);
349
+ this.pool.release(client);
350
+ }
351
+ }
352
+
353
+ end(): void {
354
+ this.pool.end();
355
+ }
356
+ }
357
+
358
+ /** 关闭空闲连接(测试与进程退出时) */
359
+ export function closeHana(adapter: Adapter): void {
360
+ if (adapter.driver instanceof HanaDriver) adapter.driver.end();
361
+ }
362
+
363
+ export function hana(options: HanaOptions): Adapter<Without<'returning'>> {
364
+ return { dialect: new HanaDialect(), driver: new HanaDriver(new Pool(options)) };
365
+ }