@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 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 [SQLModelService](https://github.com/travetto/travetto/tree/main/module/model-sql/src/service.ts#L69) will also modify the database schema in real time to minimize impact to development.
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 will have a `path_id` which determines it's location in the document hierarchy as well as sub tables will have a `parent_path_id` to associate records with the parent values.
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 SQLModelConfig, SQLModelService } from '@travetto/model-sql';
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(ctx: AsyncContext, config: SQLModelConfig) {
41
- return new SQLModelService(ctx, config, new SqliteDialect(ctx, config));
39
+ static getModelService(connection: SqliteConnection) {
40
+ return new SqliteModelService(connection);
42
41
  }
43
42
  }
44
43
  ```
45
44
 
46
- where the [SQLModelConfig](https://github.com/travetto/travetto/tree/main/module/model-sql/src/config.ts#L8) is defined by:
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 SQLModelConfig**
47
+ **Code: Structure of SqliteModelConfig**
49
48
  ```typescript
50
- @Config('model.sql')
51
- export class SQLModelConfig<T extends {} = {}> {
49
+ @Config('model.sqlite')
50
+ export class SqliteModelConfig {
52
51
  /**
53
- * Host to connect to
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
- * Database name
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?: boolean;
59
+ modifyStorage = !Runtime.production;
60
+
80
61
  /**
81
- * Db version
62
+ * SQLite file location
82
63
  */
83
- version = '';
64
+ file?: string;
65
+
84
66
  /**
85
- * Raw client options
67
+ * Custom options
86
68
  */
87
- options: T = asFull({});
69
+ options?: DatabaseSyncOptions;
88
70
  }
89
71
  ```
90
72
 
package/__index__.ts CHANGED
@@ -1,2 +1,3 @@
1
+ export * from './src/config.ts';
1
2
  export * from './src/connection.ts';
2
- export * from './src/dialect.ts';
3
+ export * from './src/service.ts';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@travetto/model-sqlite",
3
- "version": "8.0.0-alpha.25",
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.22",
31
- "@travetto/context": "^8.0.0-alpha.20",
32
- "@travetto/model": "^8.0.0-alpha.23",
33
- "@travetto/model-query": "^8.0.0-alpha.24",
34
- "@travetto/model-sql": "^8.0.0-alpha.25"
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 DatabaseSyncOptions, type SQLInputValue } from 'node:sqlite';
3
+ import { DatabaseSync, type SQLInputValue } from 'node:sqlite';
4
4
 
5
5
  import { createPool, type Pool } from 'generic-pool';
6
6
 
7
- import { type AsyncContext, WithAsyncContext } from '@travetto/context';
8
- import { ExistsError } from '@travetto/model';
9
- import { Connection, type SQLModelConfig } from '@travetto/model-sql';
10
- import { castTo, Runtime, RuntimeError, ShutdownManager, Util } from '@travetto/runtime';
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
- const RECOVERABLE_MESSAGE = /database( table| schema)? is (locked|busy)/;
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 support for Sqlite
32
+ * SQLite Connection Manager.
33
+ * Operates on node:sqlite DatabaseSync using a pool with max=1.
18
34
  */
19
- export class SqliteConnection extends Connection<DatabaseSync> {
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: SQLModelConfig<DatabaseSyncOptions & { file?: string }>) {
43
+ constructor(context: AsyncContext, config: SqliteModelConfig) {
26
44
  super(context);
27
- this.#config = config;
45
+ this.config = config;
28
46
  }
29
47
 
30
- async #withRetries<T>(operation: () => Promise<T>, retries = 10, delay = 250): Promise<T> {
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.#config.options.file ?? Runtime.toolPath('@', 'sqlite_db'));
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.#config.options);
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
- db.function('regexp', (a, b) => (new RegExp(`${a}`).test(`${b}`) ? 1 : 0));
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 connection and establishes crypto extension for use with hashing
115
+ * Initializes the pool and sets connection pragma configuration
58
116
  */
59
- @WithAsyncContext()
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
- async execute<T = unknown>(connection: DatabaseSync, query: string, values?: unknown[]): Promise<{ count: number; records: T[] }> {
80
- const isSelect = query.trim().startsWith('SELECT');
81
- return this.#withRetries(async () => {
82
- console.debug('Executing query', { query });
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 = connection.prepare(query);
163
+ const prepared = client.prepare(query);
86
164
  prepared.setReadBigInts(true);
87
165
  if (isSelect) {
88
- const out = prepared.all(...castTo<SQLInputValue[]>(values ?? []));
89
- const records: T[] = out.map(item => ({ ...castTo<T>(item) }));
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(...castTo<SQLInputValue[]>(values ?? []));
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 = Error.isError(error) || error instanceof Error ? error.message : undefined;
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
- throw new ExistsError('query', query);
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 { AsyncContext } from '@travetto/context';
2
- import { Injectable } from '@travetto/di';
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
- import { SqliteConnection } from './connection.ts';
9
-
10
- /**
11
- * Sqlite Dialect for the SQL Model Source
12
- */
13
- @Injectable()
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
- columns: columns.records.map(col => ({
81
- ...col,
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
- * Define column modification
100
- */
101
- getModifyColumnSQL(stack: VisitStack[]): string {
102
- const field: SchemaFieldConfig = castTo(stack.at(-1));
103
- const type = this.getColumnType(field);
104
- const identifier = this.identifier(field.name);
105
- return `ALTER TABLE ${this.parentTable(stack)} ALTER COLUMN ${identifier} TYPE ${type} USING (${identifier}::${type});`;
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
- * Generate truncate SQL
110
- */
111
- override getTruncateTableSQL(stack: VisitStack[]): string {
112
- return `DELETE FROM ${this.table(stack)};`;
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
- override getDeleteSQL(stack: VisitStack[], where?: WhereClause<unknown>): string {
116
- return super.getDeleteSQL(stack, where).replace(/_ROOT[.]?/g, '');
158
+ getDropIndexSQL(context: TableContext, indexName: string): string {
159
+ return `DROP INDEX IF EXISTS ${this.escapeIdentifier(indexName)};`;
117
160
  }
118
161
 
119
- override getUpdateSQL(stack: VisitStack[], data: Record<string, unknown>, where?: WhereClause<unknown>): string {
120
- return super.getUpdateSQL(stack, data, where).replace(/_ROOT[.]?/g, '');
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
+ }