@travetto/model-sqlite 8.0.0-alpha.25 → 8.0.0-alpha.27
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 +20 -38
- package/__index__.ts +2 -1
- package/package.json +6 -6
- package/src/config.ts +30 -0
- package/src/connection.ts +121 -51
- package/src/dialect.ts +150 -107
- package/src/service.ts +28 -0
package/README.md
CHANGED
|
@@ -13,13 +13,14 @@ npm install @travetto/model-sqlite
|
|
|
13
13
|
yarn add @travetto/model-sqlite
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
This module provides a [SQLite](https://www.sqlite.org/)-based implementation for the [Data Modeling Support](https://github.com/travetto/travetto/tree/main/module/model#readme "Datastore abstraction for core operations.") module. This source allows the [Data Modeling Support](https://github.com/travetto/travetto/tree/main/module/model#readme "Datastore abstraction for core operations.") module to read, write and query against [SQL](https://en.wikipedia.org/wiki/SQL) databases. In development mode, the [
|
|
16
|
+
This module provides a [SQLite](https://www.sqlite.org/)-based implementation for the [Data Modeling Support](https://github.com/travetto/travetto/tree/main/module/model#readme "Datastore abstraction for core operations.") module. This source allows the [Data Modeling Support](https://github.com/travetto/travetto/tree/main/module/model#readme "Datastore abstraction for core operations.") module to read, write and query against [SQL](https://en.wikipedia.org/wiki/SQL) databases. In development mode, the [SqliteModelService](https://github.com/travetto/travetto/tree/main/module/model-sqlite/src/service.ts#L12) will also modify the database schema in real time to minimize impact to development.
|
|
17
17
|
|
|
18
|
-
The schema generated will not generally map to existing tables as it is attempting to produce a document store like experience on top of a [SQL](https://en.wikipedia.org/wiki/SQL) database. Every table generated
|
|
18
|
+
The schema generated will not generally map to existing tables as it is attempting to produce a document store like experience on top of a [SQL](https://en.wikipedia.org/wiki/SQL) database. Every table generated maps to a model, with simple fields mapped as individual columns and complex fields/arrays mapped as serialized `TEXT` columns.
|
|
19
19
|
|
|
20
20
|
Supported features:
|
|
21
21
|
* [Bulk](https://github.com/travetto/travetto/tree/main/module/model/src/types/bulk.ts#L60)
|
|
22
22
|
* [CRUD](https://github.com/travetto/travetto/tree/main/module/model/src/types/crud.ts#L10)
|
|
23
|
+
* [Expiry](https://github.com/travetto/travetto/tree/main/module/model/src/types/expiry.ts#L10)
|
|
23
24
|
* [Indexed](https://github.com/travetto/travetto/tree/main/module/model-indexed/src/types/service.ts#L21)
|
|
24
25
|
* [Query Crud](https://github.com/travetto/travetto/tree/main/module/model-query/src/types/crud.ts#L11)
|
|
25
26
|
* [Facet](https://github.com/travetto/travetto/tree/main/module/model-query/src/types/facet.ts#L14)
|
|
@@ -30,61 +31,42 @@ Out of the box, by installing the module, everything should be wired up by defau
|
|
|
30
31
|
|
|
31
32
|
**Code: Wiring up a custom Model Source**
|
|
32
33
|
```typescript
|
|
33
|
-
import type { AsyncContext } from '@travetto/context';
|
|
34
34
|
import { InjectableFactory } from '@travetto/di';
|
|
35
|
-
import { type
|
|
36
|
-
import { SqliteDialect } from '@travetto/model-sqlite';
|
|
35
|
+
import { type SqliteConnection, SqliteModelService } from '@travetto/model-sqlite';
|
|
37
36
|
|
|
38
37
|
export class Init {
|
|
39
38
|
@InjectableFactory({ primary: true })
|
|
40
|
-
static getModelService(
|
|
41
|
-
return new
|
|
39
|
+
static getModelService(connection: SqliteConnection) {
|
|
40
|
+
return new SqliteModelService(connection);
|
|
42
41
|
}
|
|
43
42
|
}
|
|
44
43
|
```
|
|
45
44
|
|
|
46
|
-
where the [
|
|
45
|
+
where the [SqliteModelConfig](https://github.com/travetto/travetto/tree/main/module/model-sqlite/src/config.ts#L10) is defined by:
|
|
47
46
|
|
|
48
|
-
**Code: Structure of
|
|
47
|
+
**Code: Structure of SqliteModelConfig**
|
|
49
48
|
```typescript
|
|
50
|
-
@Config('model.
|
|
51
|
-
export class
|
|
49
|
+
@Config('model.sqlite')
|
|
50
|
+
export class SqliteModelConfig {
|
|
52
51
|
/**
|
|
53
|
-
*
|
|
54
|
-
*/
|
|
55
|
-
host = '127.0.0.1';
|
|
56
|
-
/**
|
|
57
|
-
* Default port
|
|
58
|
-
*/
|
|
59
|
-
port = 0;
|
|
60
|
-
/**
|
|
61
|
-
* Username
|
|
62
|
-
*/
|
|
63
|
-
user = Runtime.production ? '' : 'travetto';
|
|
64
|
-
/**
|
|
65
|
-
* Password
|
|
66
|
-
*/
|
|
67
|
-
password = Runtime.production ? '' : 'travetto';
|
|
68
|
-
/**
|
|
69
|
-
* Table prefix
|
|
52
|
+
* Namespace/schema prefix for table names
|
|
70
53
|
*/
|
|
71
54
|
namespace = '';
|
|
55
|
+
|
|
72
56
|
/**
|
|
73
|
-
*
|
|
74
|
-
*/
|
|
75
|
-
database = 'app';
|
|
76
|
-
/**
|
|
77
|
-
* Allow storage modification at runtime
|
|
57
|
+
* Allow storage modifications (like table auto-creation and schema updates) at runtime
|
|
78
58
|
*/
|
|
79
|
-
modifyStorage
|
|
59
|
+
modifyStorage = !Runtime.production;
|
|
60
|
+
|
|
80
61
|
/**
|
|
81
|
-
*
|
|
62
|
+
* SQLite file location
|
|
82
63
|
*/
|
|
83
|
-
|
|
64
|
+
file?: string;
|
|
65
|
+
|
|
84
66
|
/**
|
|
85
|
-
*
|
|
67
|
+
* Custom options
|
|
86
68
|
*/
|
|
87
|
-
options
|
|
69
|
+
options?: DatabaseSyncOptions;
|
|
88
70
|
}
|
|
89
71
|
```
|
|
90
72
|
|
package/__index__.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@travetto/model-sqlite",
|
|
3
|
-
"version": "8.0.0-alpha.
|
|
3
|
+
"version": "8.0.0-alpha.27",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SQLite backing for the travetto model module, with real-time modeling support for SQL schemas.",
|
|
6
6
|
"keywords": [
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
"directory": "module/model-sqlite"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@travetto/config": "^8.0.0-alpha.
|
|
31
|
-
"@travetto/context": "^8.0.0-alpha.
|
|
32
|
-
"@travetto/model": "^8.0.0-alpha.
|
|
33
|
-
"@travetto/model-query": "^8.0.0-alpha.
|
|
34
|
-
"@travetto/model-sql": "^8.0.0-alpha.
|
|
30
|
+
"@travetto/config": "^8.0.0-alpha.23",
|
|
31
|
+
"@travetto/context": "^8.0.0-alpha.21",
|
|
32
|
+
"@travetto/model": "^8.0.0-alpha.24",
|
|
33
|
+
"@travetto/model-query": "^8.0.0-alpha.25",
|
|
34
|
+
"@travetto/model-sql": "^8.0.0-alpha.27"
|
|
35
35
|
},
|
|
36
36
|
"travetto": {
|
|
37
37
|
"displayName": "SQLite Model Service"
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { DatabaseSyncOptions } from 'node:sqlite';
|
|
2
|
+
|
|
3
|
+
import { Config } from '@travetto/config';
|
|
4
|
+
import { Runtime } from '@travetto/runtime';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SQLite Model Configuration
|
|
8
|
+
*/
|
|
9
|
+
@Config('model.sqlite')
|
|
10
|
+
export class SqliteModelConfig {
|
|
11
|
+
/**
|
|
12
|
+
* Namespace/schema prefix for table names
|
|
13
|
+
*/
|
|
14
|
+
namespace = '';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Allow storage modifications (like table auto-creation and schema updates) at runtime
|
|
18
|
+
*/
|
|
19
|
+
modifyStorage = !Runtime.production;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* SQLite file location
|
|
23
|
+
*/
|
|
24
|
+
file?: string;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Custom options
|
|
28
|
+
*/
|
|
29
|
+
options?: DatabaseSyncOptions;
|
|
30
|
+
}
|
package/src/connection.ts
CHANGED
|
@@ -1,33 +1,51 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { DatabaseSync, type
|
|
3
|
+
import { DatabaseSync, type SQLInputValue } from 'node:sqlite';
|
|
4
4
|
|
|
5
5
|
import { createPool, type Pool } from 'generic-pool';
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
7
|
+
import type { AsyncContext } from '@travetto/context';
|
|
8
|
+
import { Injectable } from '@travetto/di';
|
|
9
|
+
import { ExistsError, UniqueError } from '@travetto/model';
|
|
10
|
+
import { SQLConnection } from '@travetto/model-sql';
|
|
11
|
+
import { castTo, JSONUtil, Runtime, RuntimeError, ShutdownManager, Util } from '@travetto/runtime';
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
import type { SqliteModelConfig } from './config.ts';
|
|
14
|
+
import { SqliteDialect } from './dialect.ts';
|
|
13
15
|
|
|
16
|
+
const RECOVERABLE_MESSAGE = /database( table| schema)? is (locked|busy)/;
|
|
14
17
|
const isRecoverableError = (error: unknown): error is Error => error instanceof Error && RECOVERABLE_MESSAGE.test(error.message);
|
|
15
18
|
|
|
19
|
+
const normalizeParameter = (val: unknown) => {
|
|
20
|
+
if (val === null) {
|
|
21
|
+
return val;
|
|
22
|
+
} else if (val instanceof Date) {
|
|
23
|
+
return val.toISOString();
|
|
24
|
+
} else if (typeof val === 'object') {
|
|
25
|
+
return JSONUtil.toUTF8(val);
|
|
26
|
+
} else {
|
|
27
|
+
return castTo<SQLInputValue>(val);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
16
31
|
/**
|
|
17
|
-
* Connection
|
|
32
|
+
* SQLite Connection Manager.
|
|
33
|
+
* Operates on node:sqlite DatabaseSync using a pool with max=1.
|
|
18
34
|
*/
|
|
19
|
-
|
|
35
|
+
@Injectable()
|
|
36
|
+
export class SqliteConnection extends SQLConnection<DatabaseSync> {
|
|
37
|
+
readonly dialect = new SqliteDialect();
|
|
20
38
|
isolatedTransactions = false;
|
|
21
39
|
|
|
22
|
-
#config: SQLModelConfig<DatabaseSyncOptions & { file?: string }>;
|
|
23
40
|
#pool: Pool<DatabaseSync>;
|
|
41
|
+
readonly config: SqliteModelConfig;
|
|
24
42
|
|
|
25
|
-
constructor(context: AsyncContext, config:
|
|
43
|
+
constructor(context: AsyncContext, config: SqliteModelConfig) {
|
|
26
44
|
super(context);
|
|
27
|
-
this
|
|
45
|
+
this.config = config;
|
|
28
46
|
}
|
|
29
47
|
|
|
30
|
-
async #withRetries<T>(operation: () => Promise<T>, retries = 10, delay =
|
|
48
|
+
async #withRetries<T>(operation: () => Promise<T>, retries = 10, delay = 300): Promise<T> {
|
|
31
49
|
for (; retries > 1; retries -= 1) {
|
|
32
50
|
try {
|
|
33
51
|
return await operation();
|
|
@@ -43,23 +61,60 @@ export class SqliteConnection extends Connection<DatabaseSync> {
|
|
|
43
61
|
}
|
|
44
62
|
|
|
45
63
|
async #create(): Promise<DatabaseSync> {
|
|
46
|
-
const file = path.resolve(this
|
|
64
|
+
const file = path.resolve(this.config.file ?? Runtime.toolPath('@', 'sqlite_db'));
|
|
47
65
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
48
|
-
const db = new DatabaseSync(file, this
|
|
66
|
+
const db = new DatabaseSync(file, this.config.options ?? {});
|
|
49
67
|
for (const q of ['PRAGMA foreign_keys = ON', 'PRAGMA journal_mode = WAL', 'PRAGMA synchronous = NORMAL']) {
|
|
50
68
|
await this.#withRetries(async () => db.exec(q));
|
|
51
69
|
}
|
|
52
|
-
|
|
70
|
+
// Register custom regex function for SQL regex support
|
|
71
|
+
db.function('regexp', (pattern, value) => {
|
|
72
|
+
const patStr = String(pattern);
|
|
73
|
+
const valStr = String(value);
|
|
74
|
+
if (patStr.startsWith('(?i)')) {
|
|
75
|
+
return new RegExp(patStr.slice(4), 'i').test(valStr) ? 1 : 0;
|
|
76
|
+
}
|
|
77
|
+
return new RegExp(patStr).test(valStr) ? 1 : 0;
|
|
78
|
+
});
|
|
79
|
+
// Register custom json_contains function for JSON containment checks
|
|
80
|
+
db.function('json_contains', (target, candidate) => {
|
|
81
|
+
try {
|
|
82
|
+
const tgt = JSON.parse(String(target));
|
|
83
|
+
const cand = JSON.parse(String(candidate));
|
|
84
|
+
|
|
85
|
+
const matches = (t: unknown, c: unknown): boolean => {
|
|
86
|
+
if (c === null) {
|
|
87
|
+
return t === null;
|
|
88
|
+
}
|
|
89
|
+
if (typeof c === 'object') {
|
|
90
|
+
if (typeof t !== 'object' || t === null) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
if (Array.isArray(c)) {
|
|
94
|
+
if (!Array.isArray(t)) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
return c.every(cv => t.some(tv => matches(tv, cv)));
|
|
98
|
+
} else {
|
|
99
|
+
// @ts-expect-error
|
|
100
|
+
return Object.keys(c).every(k => matches(t[k], c[k]));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return t === c;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return matches(tgt, cand) ? 1 : 0;
|
|
107
|
+
} catch {
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
});
|
|
53
111
|
return db;
|
|
54
112
|
}
|
|
55
113
|
|
|
56
114
|
/**
|
|
57
|
-
* Initializes
|
|
115
|
+
* Initializes the pool and sets connection pragma configuration
|
|
58
116
|
*/
|
|
59
|
-
|
|
60
|
-
override async init(): Promise<void> {
|
|
61
|
-
this.transactionDialect = { ...this.transactionDialect, begin: 'BEGIN IMMEDIATE;' };
|
|
62
|
-
|
|
117
|
+
async init(): Promise<void> {
|
|
63
118
|
await this.#create();
|
|
64
119
|
|
|
65
120
|
this.#pool = createPool<DatabaseSync>(
|
|
@@ -72,64 +127,79 @@ export class SqliteConnection extends Connection<DatabaseSync> {
|
|
|
72
127
|
{ max: 1 }
|
|
73
128
|
);
|
|
74
129
|
|
|
75
|
-
// Close postgres
|
|
76
130
|
ShutdownManager.signal.addEventListener('abort', () => this.#pool.clear());
|
|
77
131
|
}
|
|
78
132
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Acquires a DB connection
|
|
135
|
+
*/
|
|
136
|
+
acquire(): Promise<DatabaseSync> {
|
|
137
|
+
return this.#withRetries(() => this.#pool.acquire());
|
|
138
|
+
}
|
|
83
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Releases a DB connection back to pool
|
|
142
|
+
*/
|
|
143
|
+
release(connection: DatabaseSync): void {
|
|
144
|
+
this.#pool.release(connection);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Executes a query on the active client or pool directly
|
|
149
|
+
*/
|
|
150
|
+
async execute<Type = unknown>(query: string, values?: unknown[]): Promise<{ count: number; records: Type[] }> {
|
|
151
|
+
const isSelect =
|
|
152
|
+
query.trim().startsWith('SELECT') ||
|
|
153
|
+
query.trim().startsWith('PRAGMA') ||
|
|
154
|
+
query.trim().startsWith('EXISTS') ||
|
|
155
|
+
query.includes('RETURNING');
|
|
156
|
+
|
|
157
|
+
const normalized = (values ?? []).map(normalizeParameter);
|
|
158
|
+
|
|
159
|
+
return this.#withRetries(async () => {
|
|
160
|
+
console.debug('Executing SQLite query', { query, values });
|
|
161
|
+
const client = this.active ?? (await this.acquire());
|
|
84
162
|
try {
|
|
85
|
-
const prepared =
|
|
163
|
+
const prepared = client.prepare(query);
|
|
86
164
|
prepared.setReadBigInts(true);
|
|
87
165
|
if (isSelect) {
|
|
88
|
-
const out = prepared.all(...
|
|
89
|
-
const records:
|
|
166
|
+
const out = prepared.all(...normalized);
|
|
167
|
+
const records: Type[] = out.map(item => ({ ...castTo<Type>(item) }));
|
|
90
168
|
return { count: out.length, records };
|
|
91
169
|
} else {
|
|
92
|
-
const out = prepared.run(...
|
|
170
|
+
const out = prepared.run(...normalized);
|
|
93
171
|
return { count: typeof out.changes === 'number' ? out.changes : +out.changes.toString(), records: [] };
|
|
94
172
|
}
|
|
95
173
|
} catch (error) {
|
|
96
174
|
const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined;
|
|
97
|
-
const message =
|
|
175
|
+
const message = error instanceof Error ? error.message : undefined;
|
|
98
176
|
switch (code) {
|
|
99
177
|
case 'ERR_SQLITE_ERROR': {
|
|
100
178
|
if (message?.startsWith('UNIQUE')) {
|
|
101
|
-
|
|
179
|
+
const match = message.match(/UNIQUE constraint failed: (.*)/);
|
|
180
|
+
const key = match ? match[1] : 'query';
|
|
181
|
+
throw new UniqueError('query', key, { message, query });
|
|
102
182
|
}
|
|
103
183
|
break;
|
|
104
184
|
}
|
|
105
|
-
case 'SQLITE_CONSTRAINT_PRIMARYKEY':
|
|
106
185
|
case 'SQLITE_CONSTRAINT_UNIQUE':
|
|
107
|
-
case 'SQLITE_CONSTRAINT_INDEX':
|
|
186
|
+
case 'SQLITE_CONSTRAINT_INDEX': {
|
|
187
|
+
const match = message?.match(/UNIQUE constraint failed: (.*)/);
|
|
188
|
+
const key = match ? match[1] : 'query';
|
|
189
|
+
throw new UniqueError('query', key, { message, query });
|
|
190
|
+
}
|
|
191
|
+
case 'SQLITE_CONSTRAINT_PRIMARYKEY':
|
|
108
192
|
throw new ExistsError('query', query);
|
|
109
193
|
}
|
|
110
194
|
if (/index.*?already exists/.test(message ?? '')) {
|
|
111
195
|
throw new ExistsError('index', query);
|
|
112
196
|
}
|
|
113
197
|
throw error;
|
|
198
|
+
} finally {
|
|
199
|
+
if (!this.active) {
|
|
200
|
+
this.release(client);
|
|
201
|
+
}
|
|
114
202
|
}
|
|
115
203
|
});
|
|
116
204
|
}
|
|
117
|
-
|
|
118
|
-
async acquire(): Promise<DatabaseSync> {
|
|
119
|
-
return await this.#pool.acquire();
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
async release(db: DatabaseSync): Promise<void> {
|
|
123
|
-
return this.#pool.release(db);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async pragma<T>(query: string): Promise<T> {
|
|
127
|
-
const db = await this.acquire();
|
|
128
|
-
try {
|
|
129
|
-
const result = this.#withRetries(async () => db.prepare(`PRAGMA ${query}`).get());
|
|
130
|
-
return castTo<T>(result);
|
|
131
|
-
} finally {
|
|
132
|
-
await this.release(db);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
205
|
}
|
package/src/dialect.ts
CHANGED
|
@@ -1,122 +1,165 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import {
|
|
3
|
-
import type { WhereClause } from '@travetto/model-query';
|
|
4
|
-
import { SQLDialect, type SQLModelConfig, type SQLTableDescription, type VisitStack } from '@travetto/model-sql';
|
|
5
|
-
import { castTo } from '@travetto/runtime';
|
|
1
|
+
import { AbstractANSI99Dialect, type ResolvedPathContext, type TableContext, type TransactionStatements } from '@travetto/model-sql';
|
|
2
|
+
import { type Class, castTo, JSONUtil } from '@travetto/runtime';
|
|
6
3
|
import type { SchemaFieldConfig } from '@travetto/schema';
|
|
7
4
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
export class SqliteDialect extends SQLDialect {
|
|
15
|
-
connection: SqliteConnection;
|
|
16
|
-
config: SQLModelConfig;
|
|
17
|
-
|
|
18
|
-
constructor(context: AsyncContext, config: SQLModelConfig) {
|
|
19
|
-
super(config.namespace);
|
|
20
|
-
this.connection = new SqliteConnection(context, config);
|
|
21
|
-
this.config = config;
|
|
22
|
-
|
|
23
|
-
// Special operators
|
|
24
|
-
Object.assign(this.SQL_OPS, {
|
|
25
|
-
$regex: 'REGEXP',
|
|
26
|
-
$ilike: undefined
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
// Special types
|
|
30
|
-
Object.assign(this.COLUMN_TYPES, {
|
|
31
|
-
JSON: 'TEXT',
|
|
32
|
-
TIMESTAMP: 'INTEGER'
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
override resolveDateValue(value: Date): string {
|
|
37
|
-
return `${value.getTime()}`;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* How to hash
|
|
42
|
-
*/
|
|
43
|
-
hash(value: string): string {
|
|
44
|
-
return `hex('${value}')`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
async describeTable(table: string): Promise<SQLTableDescription | undefined> {
|
|
48
|
-
const IGNORE_FIELDS = [this.pathField.name, this.parentPathField.name, this.idxField.name].map(field => `'${field}'`);
|
|
49
|
-
|
|
50
|
-
const [columns, foreignKeys, indices] = await Promise.all([
|
|
51
|
-
this.executeSQL<{ name: string; type: string; is_not_null: 1 | 0 }>(`
|
|
52
|
-
SELECT
|
|
53
|
-
name,
|
|
54
|
-
type,
|
|
55
|
-
${this.identifier('notnull')} <> 0 AS is_not_null
|
|
56
|
-
FROM PRAGMA_TABLE_INFO('${table}')
|
|
57
|
-
WHERE name NOT IN (${IGNORE_FIELDS.join(',')})
|
|
58
|
-
`),
|
|
59
|
-
this.executeSQL<{ name: string; to_table: string; from_column: string; to_column: string }>(`
|
|
60
|
-
SELECT
|
|
61
|
-
'fk_' || '${table}' || '_' || ${this.identifier('from')} AS name,
|
|
62
|
-
${this.identifier('from')} as from_column,
|
|
63
|
-
${this.identifier('to')} as to_column,
|
|
64
|
-
${this.identifier('table')} as to_table
|
|
65
|
-
FROM PRAGMA_FOREIGN_KEY_LIST('${table}')
|
|
66
|
-
`),
|
|
67
|
-
this.executeSQL<{ name: string; is_unique: boolean; columns: string }>(`
|
|
68
|
-
SELECT
|
|
69
|
-
il.name as name,
|
|
70
|
-
il.${this.identifier('unique')} = 1 as is_unique,
|
|
71
|
-
GROUP_CONCAT(ii.seqno || ' ' || ii.name || ' ' || ii.desc) AS columns
|
|
72
|
-
FROM PRAGMA_INDEX_LIST('${table}') il
|
|
73
|
-
JOIN PRAGMA_INDEX_XINFO(il.name) ii
|
|
74
|
-
WHERE il.name NOT LIKE 'sqlite_%'
|
|
75
|
-
GROUP BY 1, 2
|
|
76
|
-
`)
|
|
77
|
-
]);
|
|
5
|
+
export class SqliteDialect extends AbstractANSI99Dialect {
|
|
6
|
+
returningSupport = true;
|
|
7
|
+
transactionStatements: TransactionStatements = {
|
|
8
|
+
...AbstractANSI99Dialect.TRANSACTION_STATEMENTS,
|
|
9
|
+
begin: 'BEGIN IMMEDIATE;'
|
|
10
|
+
};
|
|
78
11
|
|
|
12
|
+
getComplexColumnType(field: SchemaFieldConfig): string {
|
|
13
|
+
return 'TEXT';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
getColumnType(fieldConfiguration: SchemaFieldConfig): string {
|
|
17
|
+
if (fieldConfiguration.type === castTo(BigInt)) {
|
|
18
|
+
return 'INTEGER';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (fieldConfiguration.type === Number) {
|
|
22
|
+
return 'NUMERIC';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (fieldConfiguration.type === Date) {
|
|
26
|
+
return 'TEXT';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (fieldConfiguration.type === Boolean) {
|
|
30
|
+
return 'INTEGER';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (fieldConfiguration.type === String) {
|
|
34
|
+
return 'TEXT';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return 'TEXT';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
compileJsonIndexPath(columnName: string, jsonPath: string[]): string {
|
|
41
|
+
return `json_extract(${columnName}, '$.${jsonPath.join('.')}')`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#getSqliteArrayContext(context: ResolvedPathContext): { jsonArrayExpr: string; valueExpr: string } {
|
|
45
|
+
if (!context.arrayPath || context.arrayPath.length === 0) {
|
|
46
|
+
return { jsonArrayExpr: context.sqlPath, valueExpr: 'elem.value' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const columnName = this.escapeIdentifier(context.arrayPath[0]);
|
|
50
|
+
const jsonArrayExpr =
|
|
51
|
+
context.arrayPath.length > 1 ? `json_extract(${columnName}, '$.${context.arrayPath.slice(1).join('.')}')` : columnName;
|
|
52
|
+
|
|
53
|
+
const valueExpr =
|
|
54
|
+
context.subPath && context.subPath.length > 0 ? `json_extract(elem.value, '$.${context.subPath.join('.')}')` : 'elem.value';
|
|
55
|
+
|
|
56
|
+
return { jsonArrayExpr, valueExpr };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
compileArrayAll(context: ResolvedPathContext, identifier: string, value: unknown[]): { sql: string; formatted: unknown } {
|
|
60
|
+
const { jsonArrayExpr, valueExpr } = this.#getSqliteArrayContext(context);
|
|
61
|
+
return {
|
|
62
|
+
sql: `NOT EXISTS (SELECT 1 FROM json_each(${identifier}) AS req WHERE req.value NOT IN (SELECT ${valueExpr} FROM json_each(${jsonArrayExpr}) AS elem))`,
|
|
63
|
+
formatted: JSONUtil.toUTF8(value)
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
compileArrayEquals(context: ResolvedPathContext, identifier: string, values: unknown): { sql: string; formatted: unknown } {
|
|
68
|
+
const { jsonArrayExpr, valueExpr } = this.#getSqliteArrayContext(context);
|
|
69
|
+
if (Array.isArray(values)) {
|
|
70
|
+
return {
|
|
71
|
+
sql: `NOT EXISTS (SELECT 1 FROM json_each(${identifier}) AS req WHERE req.value NOT IN (SELECT ${valueExpr} FROM json_each(${jsonArrayExpr}) AS elem))`,
|
|
72
|
+
formatted: JSONUtil.toUTF8(values)
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (typeof values === 'object' && values !== null) {
|
|
76
|
+
return {
|
|
77
|
+
sql: `EXISTS (SELECT 1 FROM json_each(${jsonArrayExpr}) AS elem WHERE NOT EXISTS (SELECT 1 FROM json_each(${identifier}) AS req WHERE json_extract(elem.value, '$.' || req.key) IS NOT req.value))`,
|
|
78
|
+
formatted: JSONUtil.toUTF8(values)
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
sql: `EXISTS (SELECT 1 FROM json_each(${jsonArrayExpr}) AS elem WHERE ${valueExpr} = ${identifier})`,
|
|
83
|
+
formatted: values
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
compileArrayAny(context: ResolvedPathContext, identifier: string, values: unknown[]): { sql: string; formatted: unknown } {
|
|
88
|
+
const { jsonArrayExpr, valueExpr } = this.#getSqliteArrayContext(context);
|
|
79
89
|
return {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
is_not_null: !!col.is_not_null
|
|
83
|
-
})),
|
|
84
|
-
foreignKeys: foreignKeys.records,
|
|
85
|
-
indices: indices.records.map(idx => ({
|
|
86
|
-
name: idx.name,
|
|
87
|
-
is_unique: idx.is_unique,
|
|
88
|
-
columns: idx.columns
|
|
89
|
-
.split(',')
|
|
90
|
-
.map(col => col.split(' '))
|
|
91
|
-
.map(([order, name, desc]) => [+order, { name, desc: desc === '1' }] as const)
|
|
92
|
-
.sort((a, b) => a[0] - b[0])
|
|
93
|
-
.map(([, item]) => item)
|
|
94
|
-
}))
|
|
90
|
+
sql: `EXISTS (SELECT 1 FROM json_each(${jsonArrayExpr}) AS elem WHERE ${valueExpr} IN (SELECT value FROM json_each(${identifier})))`,
|
|
91
|
+
formatted: JSONUtil.toUTF8(values)
|
|
95
92
|
};
|
|
96
93
|
}
|
|
97
94
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
95
|
+
compileArrayExists(context: ResolvedPathContext, identifier?: string): { sql: string } {
|
|
96
|
+
const { jsonArrayExpr } = this.#getSqliteArrayContext(context);
|
|
97
|
+
return { sql: `(${jsonArrayExpr} IS NOT NULL AND json_array_length(${jsonArrayExpr}) > 0)` };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
getRegexOperator(caseInsensitive: boolean): string {
|
|
101
|
+
return 'REGEXP';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
formatRegex(source: string, caseInsensitive: boolean): string {
|
|
105
|
+
return caseInsensitive ? `(?i)${source}` : source;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
castColumn(sqlPath: string, type: Class): string {
|
|
109
|
+
if (type === Number) {
|
|
110
|
+
return `CAST(${sqlPath} AS NUMERIC)`;
|
|
111
|
+
}
|
|
112
|
+
return sqlPath;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
getTableExistsQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
|
|
116
|
+
return {
|
|
117
|
+
sql: `
|
|
118
|
+
SELECT name
|
|
119
|
+
FROM sqlite_master
|
|
120
|
+
WHERE type='table' AND name=?;`,
|
|
121
|
+
parameters: [context.tableName]
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
parseTableExistsResult(records: unknown[]): boolean {
|
|
126
|
+
return records.length > 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
getExistingColumnsQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
|
|
130
|
+
return {
|
|
131
|
+
sql: `PRAGMA table_info('${this.escapeLiteral(context.tableName)}');`
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
parseExistingColumns(records: unknown[]): Map<string, string> {
|
|
136
|
+
return new Map(castTo<{ name: string; type: string }[]>(records).map(record => [record.name, record.type.toUpperCase()]));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
getExistingIndexesQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
|
|
140
|
+
return {
|
|
141
|
+
sql: `
|
|
142
|
+
SELECT name, sql
|
|
143
|
+
FROM sqlite_master
|
|
144
|
+
WHERE type='index' AND tbl_name=?;
|
|
145
|
+
`,
|
|
146
|
+
parameters: [context.tableName]
|
|
147
|
+
};
|
|
106
148
|
}
|
|
107
149
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
150
|
+
parseExistingIndexes(records: unknown[]): Map<string, string> {
|
|
151
|
+
return new Map(
|
|
152
|
+
castTo<{ name: string; sql: string }[]>(records)
|
|
153
|
+
.filter(record => record.sql && !record.name.startsWith('sqlite_'))
|
|
154
|
+
.map(record => [record.name, record.sql])
|
|
155
|
+
);
|
|
113
156
|
}
|
|
114
157
|
|
|
115
|
-
|
|
116
|
-
return
|
|
158
|
+
getDropIndexSQL(context: TableContext, indexName: string): string {
|
|
159
|
+
return `DROP INDEX IF EXISTS ${this.escapeIdentifier(indexName)};`;
|
|
117
160
|
}
|
|
118
161
|
|
|
119
|
-
|
|
120
|
-
return
|
|
162
|
+
getTruncateTableSQL(context: TableContext): string {
|
|
163
|
+
return `DELETE FROM ${this.escapeIdentifier(context.tableName)};`;
|
|
121
164
|
}
|
|
122
165
|
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
|
|
3
|
+
import { Injectable, PostConstruct } from '@travetto/di';
|
|
4
|
+
import { BaseSQLModelService } from '@travetto/model-sql';
|
|
5
|
+
|
|
6
|
+
import type { SqliteConnection } from './connection.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A SQLite JSON-based document store model service
|
|
10
|
+
*/
|
|
11
|
+
@Injectable()
|
|
12
|
+
export class SqliteModelService extends BaseSQLModelService {
|
|
13
|
+
connection: SqliteConnection;
|
|
14
|
+
|
|
15
|
+
constructor(connection: SqliteConnection) {
|
|
16
|
+
super();
|
|
17
|
+
this.connection = connection;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get client(): DatabaseSync {
|
|
21
|
+
return this.connection.active!;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@PostConstruct()
|
|
25
|
+
override async initialize(): Promise<void> {
|
|
26
|
+
await super.initialize();
|
|
27
|
+
}
|
|
28
|
+
}
|