@quatrain/backend-sqlite 1.0.2 → 1.0.3

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.
@@ -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
+ }