@quatrain/backend-sqlite 1.0.2 → 1.0.4
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/LICENSE.md +15 -0
- package/README.md +26 -0
- package/package.json +40 -43
- package/src/SQLiteAdapter.ts +916 -0
- package/src/__test__/CRUD.test.ts +67 -0
- package/src/__test__/SQLiteAdapter.test.ts +433 -0
- package/src/__test__/Search.test.ts +325 -0
- package/src/__test__/common.ts +61 -0
- package/src/index.ts +3 -0
- package/lib/SQLiteAdapter.d.ts +0 -55
- package/lib/SQLiteAdapter.js +0 -696
- package/lib/index.d.ts +0 -2
- package/lib/index.js +0 -5
|
@@ -0,0 +1,916 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ObjectUri,
|
|
3
|
+
NotFoundError,
|
|
4
|
+
statuses,
|
|
5
|
+
StringProperty,
|
|
6
|
+
ObjectProperty,
|
|
7
|
+
} from '@quatrain/core'
|
|
8
|
+
import {
|
|
9
|
+
DataObjectClass,
|
|
10
|
+
Backend,
|
|
11
|
+
AbstractBackendAdapter,
|
|
12
|
+
BackendAction,
|
|
13
|
+
BackendParameters,
|
|
14
|
+
BackendError,
|
|
15
|
+
QueryMetaType,
|
|
16
|
+
QueryResultType,
|
|
17
|
+
Filters,
|
|
18
|
+
Filter,
|
|
19
|
+
SortAndLimit,
|
|
20
|
+
Sorting,
|
|
21
|
+
CollectionHierarchy,
|
|
22
|
+
} from '@quatrain/backend'
|
|
23
|
+
import { randomUUID } from 'crypto'
|
|
24
|
+
import sqlite3, { Statement } from 'sqlite3'
|
|
25
|
+
import { open, Database } from 'sqlite'
|
|
26
|
+
import { AbstractPropertyType } from '@quatrain/core/dist/properties/types/AbstractPropertyType'
|
|
27
|
+
|
|
28
|
+
const operatorsMap: { [x: string]: string } = {
|
|
29
|
+
equals: '=',
|
|
30
|
+
notEquals: '!=',
|
|
31
|
+
greater: '>',
|
|
32
|
+
greaterOrEquals: '>=',
|
|
33
|
+
lower: '<',
|
|
34
|
+
lowerOrEquals: '<=', // Corrected from '>' in PostgreSQL implementation
|
|
35
|
+
like: 'LIKE',
|
|
36
|
+
contains: 'IN',
|
|
37
|
+
notContains: 'NOT IN',
|
|
38
|
+
containsAll: 'JSON_EXTRACT', // Use JSON_EXTRACT with custom logic
|
|
39
|
+
containsAny: 'JSON_EXTRACT', // Use JSON_EXTRACT with custom logic
|
|
40
|
+
isNull: 'IS NULL',
|
|
41
|
+
isNotNull: 'IS NOT NULL',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* SQLite Backend Adapter for Quatrain
|
|
46
|
+
*/
|
|
47
|
+
export class SQLiteAdapter extends AbstractBackendAdapter {
|
|
48
|
+
protected _connection: undefined | Database<sqlite3.Database>
|
|
49
|
+
protected _dbPath: string
|
|
50
|
+
|
|
51
|
+
constructor(params: BackendParameters = {}) {
|
|
52
|
+
super(params)
|
|
53
|
+
this._dbPath = (params.config?.database as string) || ':memory:'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
protected _buildPath(dataObject: DataObjectClass<any>, uid?: string) {
|
|
57
|
+
const collection = this.getCollection(dataObject)
|
|
58
|
+
if (!collection) {
|
|
59
|
+
throw new BackendError(
|
|
60
|
+
`[SQLA] Can't define record path without a collection name`
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// define document path
|
|
65
|
+
let path = `${collection}/${uid}`
|
|
66
|
+
if (
|
|
67
|
+
this._params.hierarchy &&
|
|
68
|
+
this._params.hierarchy[collection] ===
|
|
69
|
+
CollectionHierarchy.SUBCOLLECTION &&
|
|
70
|
+
dataObject.parentProp &&
|
|
71
|
+
dataObject.has(dataObject.parentProp) &&
|
|
72
|
+
dataObject.val(dataObject.parentProp)
|
|
73
|
+
) {
|
|
74
|
+
path = `${dataObject.val(dataObject.parentProp).path}/${path}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
Backend.log(`[SQLA] Record path is '${path}'`)
|
|
78
|
+
|
|
79
|
+
return path
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
protected async _connect(): Promise<Database<sqlite3.Database>> {
|
|
83
|
+
if (!this._connection) {
|
|
84
|
+
// Open SQLite database
|
|
85
|
+
this._connection = await open<sqlite3.Database, Statement>({
|
|
86
|
+
filename: this._dbPath,
|
|
87
|
+
driver: sqlite3.Database,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// Enable foreign keys support
|
|
91
|
+
await this._connection.run('PRAGMA foreign_keys = ON')
|
|
92
|
+
|
|
93
|
+
// Configure SQLite to handle JSON arrays and objects
|
|
94
|
+
// SQLite doesn't support CREATE FUNCTION syntax, so we'll use built-in JSON functions
|
|
95
|
+
// Enable JSON1 extension if available
|
|
96
|
+
try {
|
|
97
|
+
await this._connection.exec('SELECT json_valid(\'[]\')') // Test if JSON1 is available
|
|
98
|
+
} catch (err) {
|
|
99
|
+
Backend.warn('[SQLA] JSON1 extension not available, array operations may be limited')
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return this._connection
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Process data for compatibility
|
|
108
|
+
* @param data
|
|
109
|
+
* @param filterNulls
|
|
110
|
+
* @returns
|
|
111
|
+
*/
|
|
112
|
+
protected _prepareData(data: any, filterNulls = true) {
|
|
113
|
+
if (filterNulls) {
|
|
114
|
+
data = Object.entries(data)
|
|
115
|
+
.filter(([_, v]) => v !== null && v !== '')
|
|
116
|
+
.map(([_, v]) => v)
|
|
117
|
+
} else {
|
|
118
|
+
data = Object.values(data)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Handle arrays by converting them to JSON strings for SQLite
|
|
122
|
+
data.forEach((el: any, key: number) => {
|
|
123
|
+
if (Array.isArray(el)) {
|
|
124
|
+
data[key] = JSON.stringify(el)
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
if (
|
|
129
|
+
this._params['useNativeForeignKeys'] &&
|
|
130
|
+
this._params['useNativeForeignKeys'] === true
|
|
131
|
+
) {
|
|
132
|
+
data.forEach((el: any, key: number) => {
|
|
133
|
+
if (
|
|
134
|
+
typeof el === 'object' &&
|
|
135
|
+
el !== null &&
|
|
136
|
+
Reflect.has(el, 'ref')
|
|
137
|
+
) {
|
|
138
|
+
const resourcePart = el.ref.split('/').pop()
|
|
139
|
+
if (resourcePart.indexOf('.') === -1) {
|
|
140
|
+
// convert reference for database objects only
|
|
141
|
+
data[key] = el.ref.split('/').pop()
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return data
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Ensure the collection table exists in SQLite
|
|
152
|
+
* @param dataObject DataObject to create table for
|
|
153
|
+
*/
|
|
154
|
+
private async _ensureTable(dataObject: DataObjectClass<any>): Promise<void> {
|
|
155
|
+
const collection = this.getCollection(dataObject)
|
|
156
|
+
if (!collection) {
|
|
157
|
+
throw new BackendError(`[SQLA] Cannot determine collection name`)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const db = await this._connect()
|
|
161
|
+
const tableExists = await db.get(
|
|
162
|
+
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
|
|
163
|
+
[collection.toLowerCase()]
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if (!tableExists) {
|
|
167
|
+
// Table doesn't exist, create it
|
|
168
|
+
let query = `CREATE TABLE IF NOT EXISTS ${collection.toLowerCase()} (
|
|
169
|
+
id TEXT PRIMARY KEY`
|
|
170
|
+
|
|
171
|
+
// Add columns based on dataObject properties
|
|
172
|
+
Object.entries(dataObject.properties).forEach(
|
|
173
|
+
([prop, propDef]: [prop: string, propDef: any]) => {
|
|
174
|
+
const propName = prop.toLowerCase()
|
|
175
|
+
let columnType = 'TEXT'
|
|
176
|
+
|
|
177
|
+
// Map property types to SQLite column types
|
|
178
|
+
if (propDef.constructor.name === 'NumberProperty') {
|
|
179
|
+
columnType = 'REAL'
|
|
180
|
+
} else if (propDef.constructor.name === 'BooleanProperty') {
|
|
181
|
+
columnType = 'INTEGER'
|
|
182
|
+
} else if (propDef.constructor.name === 'DateTimeProperty') {
|
|
183
|
+
columnType = 'INTEGER' // Store as timestamp
|
|
184
|
+
} else if (propDef.constructor.name === 'ArrayProperty') {
|
|
185
|
+
columnType = 'TEXT' // Store as JSON string
|
|
186
|
+
} else if (propDef.constructor.name === 'ObjectProperty') {
|
|
187
|
+
columnType = 'TEXT' // Store reference ID
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
query += `,\n${propName} ${columnType}`
|
|
191
|
+
}
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
query += `)`
|
|
195
|
+
await db.exec(query)
|
|
196
|
+
Backend.log(`[SQLA] Created table ${collection.toLowerCase()}`)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Create record in backend
|
|
202
|
+
* @param dataObject DataObject instance to persist in backend
|
|
203
|
+
* @param desiredUid Desired unique ID for record
|
|
204
|
+
* @returns DataObject
|
|
205
|
+
*/
|
|
206
|
+
async create(
|
|
207
|
+
dataObject: DataObjectClass<any>,
|
|
208
|
+
desiredUid: string | undefined
|
|
209
|
+
): Promise<DataObjectClass<any>> {
|
|
210
|
+
return new Promise(async (resolve, reject) => {
|
|
211
|
+
try {
|
|
212
|
+
if (dataObject.uid) {
|
|
213
|
+
throw new BackendError(
|
|
214
|
+
`Data object already has an uid and can't be created`
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const uid = desiredUid || randomUUID()
|
|
219
|
+
|
|
220
|
+
// Make sure table exists
|
|
221
|
+
await this._ensureTable(dataObject)
|
|
222
|
+
|
|
223
|
+
// execute middlewares
|
|
224
|
+
await this.executeMiddlewares(dataObject, BackendAction.CREATE, {
|
|
225
|
+
useDateFormat: true,
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
const data = dataObject.toJSON({
|
|
229
|
+
withoutURIData: true,
|
|
230
|
+
converters: {
|
|
231
|
+
datetime: (v: any) => (v ? new Date(v).getTime() : v), // Store as timestamp in SQLite
|
|
232
|
+
},
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
const db = await this._connect()
|
|
236
|
+
const collection = this.getCollection(dataObject)
|
|
237
|
+
|
|
238
|
+
let columns = ['id']
|
|
239
|
+
let placeholders = ['?']
|
|
240
|
+
let values = [uid]
|
|
241
|
+
|
|
242
|
+
Object.entries(data).forEach(
|
|
243
|
+
([key, value]: [key: string, value: any]) => {
|
|
244
|
+
columns.push(key.toLowerCase())
|
|
245
|
+
placeholders.push('?')
|
|
246
|
+
|
|
247
|
+
// Convert arrays and objects to JSON strings
|
|
248
|
+
if (
|
|
249
|
+
Array.isArray(value) ||
|
|
250
|
+
(typeof value === 'object' && value !== null)
|
|
251
|
+
) {
|
|
252
|
+
values.push(JSON.stringify(value))
|
|
253
|
+
} else {
|
|
254
|
+
values.push(value)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
const query = `INSERT INTO ${collection?.toLowerCase()} (${columns.join(
|
|
260
|
+
', '
|
|
261
|
+
)})
|
|
262
|
+
VALUES (${placeholders.join(', ')})`
|
|
263
|
+
|
|
264
|
+
Backend.debug(`[SQLA] ${query}`)
|
|
265
|
+
Backend.debug(`[SQLA] Values ${JSON.stringify(values)}`)
|
|
266
|
+
|
|
267
|
+
await db.run(query, values)
|
|
268
|
+
|
|
269
|
+
dataObject.uri.path = this._buildPath(dataObject, uid)
|
|
270
|
+
dataObject.uri.label = data && Reflect.get(data, 'name')
|
|
271
|
+
dataObject.isPersisted(true)
|
|
272
|
+
|
|
273
|
+
Backend.info(
|
|
274
|
+
`[SQLA] Saved object "${data.name}" at path ${dataObject.path}`
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
resolve(dataObject)
|
|
278
|
+
} catch (err) {
|
|
279
|
+
console.error(err)
|
|
280
|
+
Backend.error((err as Error).message)
|
|
281
|
+
reject(new BackendError((err as Error).message))
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async read(dataObject: DataObjectClass<any>): Promise<DataObjectClass<any>> {
|
|
287
|
+
const path = dataObject.path
|
|
288
|
+
const collection = this.getCollection(dataObject)
|
|
289
|
+
|
|
290
|
+
const parts = path.split('/')
|
|
291
|
+
if (parts.length < 2 || parts.length % 2 !== 0) {
|
|
292
|
+
throw new BackendError(
|
|
293
|
+
`[SQLA] path parts number should be even, received: '${path}'`
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
Backend.log(`[SQLA] Getting document ${path}`)
|
|
298
|
+
|
|
299
|
+
if (!collection) {
|
|
300
|
+
throw new BackendError(
|
|
301
|
+
`[SQLA] Can't find collection matching object to query`
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const db = await this._connect()
|
|
306
|
+
const uid = parts[parts.length - 1]
|
|
307
|
+
|
|
308
|
+
// Ensure table exists
|
|
309
|
+
await this._ensureTable(dataObject)
|
|
310
|
+
|
|
311
|
+
const result = await db.get(
|
|
312
|
+
`SELECT * FROM ${collection.toLowerCase()} WHERE id = ?`,
|
|
313
|
+
[uid]
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
if (!result) {
|
|
317
|
+
throw new NotFoundError(`[SQLA] No document matches path '${path}'`)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Process object references
|
|
321
|
+
for (const prop in dataObject.properties) {
|
|
322
|
+
const propDef = dataObject.properties[prop]
|
|
323
|
+
|
|
324
|
+
if (
|
|
325
|
+
propDef.constructor.name === 'ObjectProperty' &&
|
|
326
|
+
propDef.instanceOf
|
|
327
|
+
) {
|
|
328
|
+
const propValue = result[prop.toLowerCase()]
|
|
329
|
+
|
|
330
|
+
if (propValue) {
|
|
331
|
+
let refTable = undefined
|
|
332
|
+
if (this._params.mapping && this._params.mapping[propDef.instanceOf]) {
|
|
333
|
+
refTable = this._params.mapping[propDef.instanceOf]
|
|
334
|
+
} else if (propDef.instanceOf && propDef.instanceOf.COLLECTION) {
|
|
335
|
+
refTable = propDef.instanceOf.COLLECTION
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (refTable) {
|
|
339
|
+
// Look up the referenced object for its name
|
|
340
|
+
const refObject = await db.get(
|
|
341
|
+
`SELECT name FROM ${refTable.toLowerCase()} WHERE id = ?`,
|
|
342
|
+
[propValue]
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
if (refObject) {
|
|
346
|
+
result[prop] = {
|
|
347
|
+
ref: `${refTable}/${propValue}`,
|
|
348
|
+
path: `${refTable}/${propValue}`,
|
|
349
|
+
label: refObject.name || '',
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
} else if (propDef.constructor.name === 'ArrayProperty') {
|
|
355
|
+
// Parse JSON arrays
|
|
356
|
+
try {
|
|
357
|
+
if (result[prop.toLowerCase()]) {
|
|
358
|
+
result[prop] = JSON.parse(result[prop.toLowerCase()])
|
|
359
|
+
}
|
|
360
|
+
} catch (e) {
|
|
361
|
+
Backend.warn(`[SQLA] Failed to parse array for ${prop}: ${e}`)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Normalize property name case
|
|
366
|
+
if (prop.toLowerCase() !== prop) {
|
|
367
|
+
result[prop] = result[prop.toLowerCase()]
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
dataObject.populate(result)
|
|
372
|
+
return dataObject
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async update(
|
|
376
|
+
dataObject: DataObjectClass<any>
|
|
377
|
+
): Promise<DataObjectClass<any>> {
|
|
378
|
+
if (dataObject.uid === undefined) {
|
|
379
|
+
throw new Error('DataObject has no uid')
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
Backend.info(`[SQLA] Updating document ${dataObject.path}`)
|
|
383
|
+
|
|
384
|
+
// execute middlewares
|
|
385
|
+
await this.executeMiddlewares(dataObject, BackendAction.UPDATE)
|
|
386
|
+
|
|
387
|
+
const data = dataObject.toJSON({
|
|
388
|
+
withoutURIData: true,
|
|
389
|
+
ignoreUnchanged: true,
|
|
390
|
+
converters: {
|
|
391
|
+
datetime: (ts: number) => ts, // Store directly as timestamp in SQLite
|
|
392
|
+
},
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
if (Object.keys(data).length === 0) {
|
|
396
|
+
Backend.warn('[SQLA] Nothing to update')
|
|
397
|
+
return dataObject
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
Backend.debug(`[SQLA] Data to update ${JSON.stringify(data)}`)
|
|
401
|
+
|
|
402
|
+
const db = await this._connect()
|
|
403
|
+
const collection = this.getCollection(dataObject)
|
|
404
|
+
|
|
405
|
+
// Ensure table exists
|
|
406
|
+
await this._ensureTable(dataObject)
|
|
407
|
+
|
|
408
|
+
let updates: string[] = []
|
|
409
|
+
let values: any[] = []
|
|
410
|
+
|
|
411
|
+
Object.entries(data).forEach(
|
|
412
|
+
([key, value]: [key: string, value: any]) => {
|
|
413
|
+
updates.push(`${key.toLowerCase()} = ?`)
|
|
414
|
+
|
|
415
|
+
// Convert arrays and objects to JSON strings
|
|
416
|
+
if (
|
|
417
|
+
Array.isArray(value) ||
|
|
418
|
+
(typeof value === 'object' && value !== null)
|
|
419
|
+
) {
|
|
420
|
+
value = JSON.stringify(value)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
values.push(value as string | number | boolean)
|
|
424
|
+
}
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
if (updates.length === 0) {
|
|
428
|
+
return dataObject
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
values.push(dataObject.uid)
|
|
432
|
+
const query = `UPDATE ${collection?.toLowerCase()} SET ${updates.join(
|
|
433
|
+
', '
|
|
434
|
+
)} WHERE id = ?`
|
|
435
|
+
|
|
436
|
+
Backend.debug(`[SQLA] ${query}`)
|
|
437
|
+
Backend.debug(`[SQLA] Values ${JSON.stringify(values)}`)
|
|
438
|
+
|
|
439
|
+
await db.run(query, values)
|
|
440
|
+
return dataObject
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async delete(
|
|
444
|
+
dataObject: DataObjectClass<any>,
|
|
445
|
+
hardDelete = false
|
|
446
|
+
): Promise<DataObjectClass<any>> {
|
|
447
|
+
if (dataObject.uid === undefined) {
|
|
448
|
+
throw new BackendError('Dataobject has no uid')
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const collection = this.getCollection(dataObject)
|
|
452
|
+
if (!collection) {
|
|
453
|
+
throw new BackendError(`[SQLA] Cannot determine collection name`)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// execute middlewares
|
|
457
|
+
await this.executeMiddlewares(dataObject, BackendAction.DELETE, {
|
|
458
|
+
useDateFormat: true,
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
const db = await this._connect()
|
|
462
|
+
|
|
463
|
+
if (!hardDelete) {
|
|
464
|
+
dataObject.set('status', statuses.DELETED)
|
|
465
|
+
await db.run(
|
|
466
|
+
`UPDATE ${collection.toLowerCase()} SET status = ? WHERE id = ?`,
|
|
467
|
+
[statuses.DELETED, dataObject.uid]
|
|
468
|
+
)
|
|
469
|
+
} else {
|
|
470
|
+
await db.run(`DELETE FROM ${collection.toLowerCase()} WHERE id = ?`, [
|
|
471
|
+
dataObject.uid,
|
|
472
|
+
])
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
dataObject.uri = new ObjectUri()
|
|
476
|
+
return dataObject
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async deleteCollection(collection: string, batchSize = 500): Promise<void> {
|
|
480
|
+
Backend.log(`Deleting all records from collection '${collection}'`)
|
|
481
|
+
const db = await this._connect()
|
|
482
|
+
|
|
483
|
+
// Check if table exists before trying to delete from it
|
|
484
|
+
const tableExists = await db.get(
|
|
485
|
+
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
|
|
486
|
+
[collection.toLowerCase()]
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
if (tableExists) {
|
|
490
|
+
await db.run(`DELETE FROM ${collection.toLowerCase()}`)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Convert array into SQL expression
|
|
496
|
+
* @param from Array of strings or numbers
|
|
497
|
+
* @returns string
|
|
498
|
+
*/
|
|
499
|
+
protected _array2String(from: (string | number)[]) {
|
|
500
|
+
// For SQLite, we'll use the JSON functions to check arrays
|
|
501
|
+
return `'${JSON.stringify(from)}'`
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Execute a query on a collection
|
|
506
|
+
* @param dataObject
|
|
507
|
+
* @param filters
|
|
508
|
+
* @param pagination
|
|
509
|
+
* @params parent
|
|
510
|
+
* @returns
|
|
511
|
+
*/
|
|
512
|
+
async find(
|
|
513
|
+
dataObject: DataObjectClass<any>,
|
|
514
|
+
filters: Filters | Filter[] | undefined = undefined,
|
|
515
|
+
pagination: SortAndLimit | undefined = undefined,
|
|
516
|
+
parent: DataObjectClass<any> | undefined = undefined
|
|
517
|
+
): Promise<QueryResultType<DataObjectClass<any>>> {
|
|
518
|
+
try {
|
|
519
|
+
// use parent path to start fullPath, if available
|
|
520
|
+
let fullPath = parent ? `${parent.path}/` : ''
|
|
521
|
+
if (dataObject.path && dataObject.path !== ObjectUri.DEFAULT) {
|
|
522
|
+
fullPath += `${dataObject.path}/`
|
|
523
|
+
}
|
|
524
|
+
const collection = this.getCollection(dataObject)
|
|
525
|
+
|
|
526
|
+
if (!collection) {
|
|
527
|
+
throw new BackendError(
|
|
528
|
+
`[SQLA] Can't find collection matching object to query`
|
|
529
|
+
)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
Backend.debug(`[SQLA] Preparing query on '${collection}'`)
|
|
533
|
+
|
|
534
|
+
const db = await this._connect()
|
|
535
|
+
|
|
536
|
+
// Ensure table exists
|
|
537
|
+
await this._ensureTable(dataObject)
|
|
538
|
+
|
|
539
|
+
let hasFilters = false
|
|
540
|
+
const query: string[] = []
|
|
541
|
+
const params: any[] = []
|
|
542
|
+
const joinTables: { [key: string]: { table: string; alias: string } } =
|
|
543
|
+
{}
|
|
544
|
+
|
|
545
|
+
query.push(`SELECT * FROM ${collection.toLowerCase()}`)
|
|
546
|
+
|
|
547
|
+
// Add joins for object references
|
|
548
|
+
Object.entries(dataObject.properties).forEach(
|
|
549
|
+
([prop, propDef]: [prop: string, propDef: any]) => {
|
|
550
|
+
if (
|
|
551
|
+
propDef.constructor.name === 'ObjectProperty' &&
|
|
552
|
+
propDef.instanceOf
|
|
553
|
+
) {
|
|
554
|
+
const propName = prop.toLowerCase()
|
|
555
|
+
const joinAlias = `${propName}_table`
|
|
556
|
+
|
|
557
|
+
let table = undefined
|
|
558
|
+
if (this._params.mapping && this._params.mapping[propDef.instanceOf]) {
|
|
559
|
+
table = this._params.mapping[propDef.instanceOf]
|
|
560
|
+
} else if (propDef.instanceOf && propDef.instanceOf.COLLECTION) {
|
|
561
|
+
table = propDef.instanceOf.COLLECTION
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (table) {
|
|
565
|
+
joinTables[prop] = { table, alias: joinAlias }
|
|
566
|
+
|
|
567
|
+
query.push(
|
|
568
|
+
`LEFT JOIN ${table.toLowerCase()} AS ${joinAlias}
|
|
569
|
+
ON ${joinAlias}.id = ${collection.toLowerCase()}.${propName}`
|
|
570
|
+
)
|
|
571
|
+
} else {
|
|
572
|
+
Backend.warn(`[SQLA] Skipping join for property ${prop} - no collection found`)
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
if (parent) {
|
|
579
|
+
query.push(
|
|
580
|
+
`WHERE ${collection.toLowerCase()}.${dataObject.parentProp} = ?`
|
|
581
|
+
)
|
|
582
|
+
params.push(parent.uid)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (filters instanceof Filters) {
|
|
586
|
+
hasFilters = true
|
|
587
|
+
// SQLite doesn't support complex Filters object, but we'll mark it as handled
|
|
588
|
+
} else if (Array.isArray(filters)) {
|
|
589
|
+
// list of filters objects
|
|
590
|
+
filters.forEach((filter, i) => {
|
|
591
|
+
query.push(parent && i === 0 ? 'AND' : i > 0 ? 'AND' : 'WHERE')
|
|
592
|
+
|
|
593
|
+
let realProp: any = filter.prop.toLowerCase()
|
|
594
|
+
let realOperator: string = operatorsMap[filter.operator]
|
|
595
|
+
let realValue = filter.value
|
|
596
|
+
|
|
597
|
+
if (filter.prop === 'keywords') {
|
|
598
|
+
const keywordFilters: string[] = []
|
|
599
|
+
|
|
600
|
+
const props = dataObject.getProperties(StringProperty.name)
|
|
601
|
+
Object.keys(props).forEach((rp) => {
|
|
602
|
+
keywordFilters.push(
|
|
603
|
+
`${collection.toLowerCase()}.${rp.toLowerCase()} LIKE ?`
|
|
604
|
+
)
|
|
605
|
+
params.push(`%${filter.value as string}%`)
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
query.push(`(${keywordFilters.join(' OR ')})`)
|
|
609
|
+
} else if (
|
|
610
|
+
filter.prop !== AbstractBackendAdapter.PKEY_IDENTIFIER &&
|
|
611
|
+
!dataObject.has(filter.prop)
|
|
612
|
+
) {
|
|
613
|
+
throw new BackendError(
|
|
614
|
+
`[SQLA] No such property '${filter.prop}' on object'`
|
|
615
|
+
)
|
|
616
|
+
} else if (
|
|
617
|
+
filter.prop === AbstractBackendAdapter.PKEY_IDENTIFIER
|
|
618
|
+
) {
|
|
619
|
+
realProp = 'id'
|
|
620
|
+
} else {
|
|
621
|
+
const property = dataObject.get(filter.prop)
|
|
622
|
+
realProp = filter.prop.toLowerCase()
|
|
623
|
+
|
|
624
|
+
if (
|
|
625
|
+
property.constructor.name === 'ArrayProperty' &&
|
|
626
|
+
Array.isArray(realValue)
|
|
627
|
+
) {
|
|
628
|
+
// Use EXISTS with json_each for array containment in SQLite
|
|
629
|
+
const placeholders = realValue.map(() => 'json_each.value = ?').join(' OR ')
|
|
630
|
+
query.push(
|
|
631
|
+
`EXISTS (SELECT 1 FROM json_each(${collection.toLowerCase()}.${realProp}) WHERE ${placeholders})`
|
|
632
|
+
)
|
|
633
|
+
params.push(...(realValue as string[]))
|
|
634
|
+
// Skip further processing for this filter
|
|
635
|
+
Backend.debug(
|
|
636
|
+
`[SQLA] Array filter added: ${realProp} EXISTS ${String(realValue)}`
|
|
637
|
+
)
|
|
638
|
+
} else if (property.constructor.name === 'ObjectProperty') {
|
|
639
|
+
if (filter.value instanceof ObjectUri) {
|
|
640
|
+
realValue = filter.value.uid
|
|
641
|
+
} else if (
|
|
642
|
+
filter.value &&
|
|
643
|
+
typeof filter.value === 'object' &&
|
|
644
|
+
filter.value.ref
|
|
645
|
+
) {
|
|
646
|
+
realValue = filter.value.ref.split('/')[1]
|
|
647
|
+
} else if (typeof filter.value === 'string') {
|
|
648
|
+
const collectionName =
|
|
649
|
+
this._params.mapping &&
|
|
650
|
+
this._params.mapping[
|
|
651
|
+
dataObject.properties[filter.prop].instanceOf
|
|
652
|
+
]
|
|
653
|
+
? this._params.mapping[
|
|
654
|
+
dataObject.properties[filter.prop].instanceOf
|
|
655
|
+
]
|
|
656
|
+
: dataObject.properties[filter.prop].instanceOf
|
|
657
|
+
.COLLECTION
|
|
658
|
+
realValue = filter.value.replace(
|
|
659
|
+
`${collectionName}/`,
|
|
660
|
+
''
|
|
661
|
+
)
|
|
662
|
+
} else if (
|
|
663
|
+
filter.value &&
|
|
664
|
+
typeof filter.value === 'object' &&
|
|
665
|
+
filter.value.uid
|
|
666
|
+
) {
|
|
667
|
+
// Handle DataObject instances
|
|
668
|
+
realValue = filter.value.uid
|
|
669
|
+
} else {
|
|
670
|
+
realValue =
|
|
671
|
+
(filter.value &&
|
|
672
|
+
filter.value.uri &&
|
|
673
|
+
filter.value.uri.path &&
|
|
674
|
+
filter.value.uri.path.split('/')[1]) ||
|
|
675
|
+
filter.value
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Only add the filter query if it's not an ArrayProperty (which was already handled above)
|
|
680
|
+
if (!(property.constructor.name === 'ArrayProperty' && Array.isArray(realValue))) {
|
|
681
|
+
if (realOperator === operatorsMap['containsAny']) {
|
|
682
|
+
// Use EXISTS with json_each for array containment in SQLite
|
|
683
|
+
query.push(
|
|
684
|
+
`EXISTS (SELECT 1 FROM json_each(${collection.toLowerCase()}.${realProp}) WHERE json_each.value = ?)`
|
|
685
|
+
)
|
|
686
|
+
params.push(realValue)
|
|
687
|
+
} else if (
|
|
688
|
+
realOperator === operatorsMap['equals'] &&
|
|
689
|
+
realValue === 'null'
|
|
690
|
+
) {
|
|
691
|
+
query.push(`${collection.toLowerCase()}.${realProp} IS NULL`)
|
|
692
|
+
} else if (
|
|
693
|
+
realOperator === operatorsMap['contains'] ||
|
|
694
|
+
realOperator === operatorsMap['notContains']
|
|
695
|
+
) {
|
|
696
|
+
if (Array.isArray(realValue)) {
|
|
697
|
+
const placeholders = realValue.map(() => '?').join(', ')
|
|
698
|
+
query.push(
|
|
699
|
+
`${collection.toLowerCase()}.${realProp} ${realOperator} (${placeholders})`
|
|
700
|
+
)
|
|
701
|
+
params.push(...realValue)
|
|
702
|
+
} else {
|
|
703
|
+
query.push(
|
|
704
|
+
`${collection.toLowerCase()}.${realProp} ${realOperator} (?)`
|
|
705
|
+
)
|
|
706
|
+
params.push(realValue as string | number)
|
|
707
|
+
}
|
|
708
|
+
} else {
|
|
709
|
+
// Handle ObjectProperty specially for JSON-stored references
|
|
710
|
+
if (property && property.constructor.name === 'ObjectProperty') {
|
|
711
|
+
if (realOperator === operatorsMap.equals) {
|
|
712
|
+
// For ObjectProperty, check if the JSON contains the reference
|
|
713
|
+
query.push(
|
|
714
|
+
`json_extract(${collection.toLowerCase()}.${realProp}, '$.ref') LIKE ?`
|
|
715
|
+
)
|
|
716
|
+
params.push(`%${realValue}`)
|
|
717
|
+
} else {
|
|
718
|
+
query.push(
|
|
719
|
+
`${collection.toLowerCase()}.${realProp} ${realOperator} ?`
|
|
720
|
+
)
|
|
721
|
+
params.push(realValue)
|
|
722
|
+
}
|
|
723
|
+
} else if (realOperator === operatorsMap.like) {
|
|
724
|
+
query.push(
|
|
725
|
+
`${collection.toLowerCase()}.${realProp} ${realOperator} ?`
|
|
726
|
+
)
|
|
727
|
+
params.push(`%${realValue}%`)
|
|
728
|
+
} else if (
|
|
729
|
+
realOperator === operatorsMap.isNull ||
|
|
730
|
+
realOperator === operatorsMap.isNotNull
|
|
731
|
+
) {
|
|
732
|
+
query.push(
|
|
733
|
+
`${collection.toLowerCase()}.${realProp} ${realOperator}`
|
|
734
|
+
)
|
|
735
|
+
} else {
|
|
736
|
+
query.push(
|
|
737
|
+
`${collection.toLowerCase()}.${realProp} ${realOperator} ?`
|
|
738
|
+
)
|
|
739
|
+
params.push(realValue)
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
Backend.debug(
|
|
744
|
+
`[SQLA] Filter added: ${realProp} ${realOperator} ${String(
|
|
745
|
+
realValue
|
|
746
|
+
)}`
|
|
747
|
+
)
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
})
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Count query - without pagination
|
|
754
|
+
const countQuery = query.join(' ').replace('*', 'COUNT(*) as total')
|
|
755
|
+
Backend.debug(`[SQLA] Count SQL ${countQuery}`)
|
|
756
|
+
|
|
757
|
+
const countResult = await db.get(countQuery, params)
|
|
758
|
+
const totalCount = countResult ? countResult.total : 0
|
|
759
|
+
|
|
760
|
+
Backend.debug(`[SQLA] Counting records ${totalCount}`)
|
|
761
|
+
|
|
762
|
+
// Add sorting and pagination
|
|
763
|
+
let sortField: string[] = []
|
|
764
|
+
if (pagination && pagination.sortings) {
|
|
765
|
+
pagination.sortings.forEach((sorting: Sorting, i) => {
|
|
766
|
+
query.push(i === 0 ? `ORDER BY` : ',')
|
|
767
|
+
query.push(
|
|
768
|
+
`${collection.toLowerCase()}.${sorting.prop.toLowerCase()} ${
|
|
769
|
+
sorting.order
|
|
770
|
+
}`
|
|
771
|
+
)
|
|
772
|
+
if (sorting.prop !== undefined) {
|
|
773
|
+
sortField.push(`${sorting.prop} ${sorting.order.toUpperCase()}`)
|
|
774
|
+
}
|
|
775
|
+
})
|
|
776
|
+
|
|
777
|
+
if (pagination?.limits.batch !== -1) {
|
|
778
|
+
query.push(`LIMIT ?`)
|
|
779
|
+
params.push(pagination.limits.batch)
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (pagination?.limits.offset) {
|
|
783
|
+
query.push(`OFFSET ?`)
|
|
784
|
+
params.push(pagination.limits.offset)
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const finalQuery = query.join(' ')
|
|
789
|
+
Backend.debug(`[SQLA] Full SQL ${finalQuery}`)
|
|
790
|
+
Backend.debug(`[SQLA] Params ${JSON.stringify(params)}`)
|
|
791
|
+
|
|
792
|
+
const results = await db.all(finalQuery, params)
|
|
793
|
+
|
|
794
|
+
const meta: QueryMetaType = {
|
|
795
|
+
count: totalCount,
|
|
796
|
+
offset: pagination?.limits.offset || 0,
|
|
797
|
+
batch: pagination?.limits.batch || 20,
|
|
798
|
+
sortField: sortField.join(', '),
|
|
799
|
+
executionTime: Backend.timestamp(),
|
|
800
|
+
debug: { sql: finalQuery, params },
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const items: DataObjectClass<any>[] = []
|
|
804
|
+
|
|
805
|
+
for (let doc of results || []) {
|
|
806
|
+
// Process document before populating
|
|
807
|
+
Object.entries(dataObject.properties).forEach(([prop, propDef]: [prop: string, propDef: any]) => {
|
|
808
|
+
const lcProp = prop.toLowerCase()
|
|
809
|
+
|
|
810
|
+
// Handle ObjectProperty references
|
|
811
|
+
if (
|
|
812
|
+
propDef.constructor.name === 'ObjectProperty' &&
|
|
813
|
+
propDef.instanceOf
|
|
814
|
+
) {
|
|
815
|
+
const refValue = doc[lcProp]
|
|
816
|
+
if (refValue) {
|
|
817
|
+
const info = joinTables[prop]
|
|
818
|
+
|
|
819
|
+
if (info) {
|
|
820
|
+
const label = doc[`${lcProp}_table_name`] || ''
|
|
821
|
+
|
|
822
|
+
doc[prop] = {
|
|
823
|
+
ref: `${info.table}/${refValue}`,
|
|
824
|
+
path: `${info.table}/${refValue}`,
|
|
825
|
+
label,
|
|
826
|
+
}
|
|
827
|
+
} else {
|
|
828
|
+
// Fallback when no join table info is available
|
|
829
|
+
// Try to determine table name from propDef
|
|
830
|
+
let tableName = undefined
|
|
831
|
+
if (this._params.mapping && this._params.mapping[propDef.instanceOf]) {
|
|
832
|
+
tableName = this._params.mapping[propDef.instanceOf]
|
|
833
|
+
} else if (propDef.instanceOf && propDef.instanceOf.COLLECTION) {
|
|
834
|
+
tableName = propDef.instanceOf.COLLECTION
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (tableName) {
|
|
838
|
+
doc[prop] = {
|
|
839
|
+
ref: `${tableName}/${refValue}`,
|
|
840
|
+
path: `${tableName}/${refValue}`,
|
|
841
|
+
label: '',
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
// Handle array properties
|
|
848
|
+
else if (propDef.constructor.name === 'ArrayProperty') {
|
|
849
|
+
try {
|
|
850
|
+
if (doc[lcProp]) {
|
|
851
|
+
doc[prop] = JSON.parse(doc[lcProp])
|
|
852
|
+
}
|
|
853
|
+
} catch (e) {
|
|
854
|
+
Backend.warn(
|
|
855
|
+
`[SQLA] Failed to parse array for ${prop}: ${e}`
|
|
856
|
+
)
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Ensure property is available with original case
|
|
861
|
+
if (prop !== lcProp) {
|
|
862
|
+
doc[prop] = doc[lcProp]
|
|
863
|
+
}
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
const newDataObject: DataObjectClass<any> = await dataObject.clone({
|
|
867
|
+
...doc,
|
|
868
|
+
})
|
|
869
|
+
|
|
870
|
+
let newDataObjectUri = ``
|
|
871
|
+
if (newDataObject.has('parent')) {
|
|
872
|
+
if (
|
|
873
|
+
!(
|
|
874
|
+
newDataObject.val('parent') &&
|
|
875
|
+
newDataObject.val('parent').path
|
|
876
|
+
)
|
|
877
|
+
) {
|
|
878
|
+
throw new BackendError(
|
|
879
|
+
`DataObject has parent but parent is not persisted`
|
|
880
|
+
)
|
|
881
|
+
}
|
|
882
|
+
newDataObjectUri = `${newDataObject.get('parent')._value._path}/`
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
newDataObjectUri += `${this.getCollection(dataObject)}/${doc.id}`
|
|
886
|
+
|
|
887
|
+
newDataObject.uri = new ObjectUri(
|
|
888
|
+
newDataObjectUri,
|
|
889
|
+
newDataObject.val('name')
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
items.push(newDataObject)
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
return { items, meta }
|
|
896
|
+
} catch (err) {
|
|
897
|
+
console.error(err)
|
|
898
|
+
Backend.error(`[SQLA] Query failed: ${(err as Error).message}`)
|
|
899
|
+
throw new BackendError(
|
|
900
|
+
`Query failed for '${dataObject.class.name}': ${
|
|
901
|
+
(err as Error).message
|
|
902
|
+
}`
|
|
903
|
+
)
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Close the SQLite connection
|
|
909
|
+
*/
|
|
910
|
+
async close(): Promise<void> {
|
|
911
|
+
if (this._connection) {
|
|
912
|
+
await this._connection.close()
|
|
913
|
+
this._connection = undefined
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|