@budibase/backend-core 2.31.8 → 2.32.1

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.
Files changed (47) hide show
  1. package/dist/index.js +420 -186
  2. package/dist/index.js.map +4 -4
  3. package/dist/index.js.meta.json +1 -1
  4. package/dist/package.json +4 -4
  5. package/dist/plugins.js.map +2 -2
  6. package/dist/plugins.js.meta.json +1 -1
  7. package/dist/src/cache/user.d.ts +7 -1
  8. package/dist/src/cache/user.js +4 -3
  9. package/dist/src/cache/user.js.map +1 -1
  10. package/dist/src/environment.d.ts +1 -0
  11. package/dist/src/environment.js +1 -1
  12. package/dist/src/environment.js.map +1 -1
  13. package/dist/src/events/index.d.ts +1 -0
  14. package/dist/src/events/index.js +3 -1
  15. package/dist/src/events/index.js.map +1 -1
  16. package/dist/src/events/publishers/ai.d.ts +7 -0
  17. package/dist/src/events/publishers/ai.js +30 -0
  18. package/dist/src/events/publishers/ai.js.map +1 -0
  19. package/dist/src/events/publishers/index.d.ts +1 -0
  20. package/dist/src/events/publishers/index.js +3 -1
  21. package/dist/src/events/publishers/index.js.map +1 -1
  22. package/dist/src/features/index.d.ts +2 -0
  23. package/dist/src/features/index.js +2 -0
  24. package/dist/src/features/index.js.map +1 -1
  25. package/dist/src/middleware/authenticated.js +16 -3
  26. package/dist/src/middleware/authenticated.js.map +1 -1
  27. package/dist/src/sql/sql.js +333 -161
  28. package/dist/src/sql/sql.js.map +1 -1
  29. package/dist/src/sql/utils.d.ts +2 -1
  30. package/dist/src/sql/utils.js +14 -0
  31. package/dist/src/sql/utils.js.map +1 -1
  32. package/dist/tests/core/utilities/structures/licenses.js +10 -0
  33. package/dist/tests/core/utilities/structures/licenses.js.map +1 -1
  34. package/dist/tests/core/utilities/structures/quotas.js +4 -0
  35. package/dist/tests/core/utilities/structures/quotas.js.map +1 -1
  36. package/package.json +4 -4
  37. package/src/cache/user.ts +17 -6
  38. package/src/environment.ts +1 -0
  39. package/src/events/index.ts +1 -0
  40. package/src/events/publishers/ai.ts +21 -0
  41. package/src/events/publishers/index.ts +1 -0
  42. package/src/features/index.ts +2 -0
  43. package/src/middleware/authenticated.ts +25 -8
  44. package/src/sql/sql.ts +460 -224
  45. package/src/sql/utils.ts +29 -1
  46. package/tests/core/utilities/structures/licenses.ts +10 -0
  47. package/tests/core/utilities/structures/quotas.ts +4 -0
package/src/sql/sql.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  isValidFilter,
8
8
  isValidISODateString,
9
9
  sqlLog,
10
+ validateManyToMany,
10
11
  } from "./utils"
11
12
  import SqlTableQueryBuilder from "./sqlTable"
12
13
  import {
@@ -39,6 +40,7 @@ import { dataFilters, helpers } from "@budibase/shared-core"
39
40
  import { cloneDeep } from "lodash"
40
41
 
41
42
  type QueryFunction = (query: SqlQuery | SqlQuery[], operation: Operation) => any
43
+ const MAX_SQS_RELATIONSHIP_FIELDS = 63
42
44
 
43
45
  function getBaseLimit() {
44
46
  const envLimit = environment.SQL_MAX_ROWS
@@ -47,6 +49,13 @@ function getBaseLimit() {
47
49
  return envLimit || 5000
48
50
  }
49
51
 
52
+ function getRelationshipLimit() {
53
+ const envLimit = environment.SQL_MAX_RELATED_ROWS
54
+ ? parseInt(environment.SQL_MAX_RELATED_ROWS)
55
+ : null
56
+ return envLimit || 500
57
+ }
58
+
50
59
  function getTableName(table?: Table): string | undefined {
51
60
  // SQS uses the table ID rather than the table name
52
61
  if (
@@ -92,6 +101,23 @@ class InternalBuilder {
92
101
  })
93
102
  }
94
103
 
104
+ // states the various situations in which we need a full mapped select statement
105
+ private readonly SPECIAL_SELECT_CASES = {
106
+ POSTGRES_MONEY: (field: FieldSchema | undefined) => {
107
+ return (
108
+ this.client === SqlClient.POSTGRES &&
109
+ field?.externalType?.includes("money")
110
+ )
111
+ },
112
+ MSSQL_DATES: (field: FieldSchema | undefined) => {
113
+ return (
114
+ this.client === SqlClient.MS_SQL &&
115
+ field?.type === FieldType.DATETIME &&
116
+ field.timeOnly
117
+ )
118
+ },
119
+ }
120
+
95
121
  get table(): Table {
96
122
  return this.query.meta.table
97
123
  }
@@ -125,79 +151,70 @@ class InternalBuilder {
125
151
  .join(".")
126
152
  }
127
153
 
154
+ private isFullSelectStatementRequired(): boolean {
155
+ const { meta } = this.query
156
+ for (let column of Object.values(meta.table.schema)) {
157
+ if (this.SPECIAL_SELECT_CASES.POSTGRES_MONEY(column)) {
158
+ return true
159
+ } else if (this.SPECIAL_SELECT_CASES.MSSQL_DATES(column)) {
160
+ return true
161
+ }
162
+ }
163
+ return false
164
+ }
165
+
128
166
  private generateSelectStatement(): (string | Knex.Raw)[] | "*" {
129
- const { resource, meta } = this.query
167
+ const { meta, endpoint, resource, tableAliases } = this.query
130
168
 
131
169
  if (!resource || !resource.fields || resource.fields.length === 0) {
132
170
  return "*"
133
171
  }
134
172
 
173
+ const alias = tableAliases?.[endpoint.entityId]
174
+ ? tableAliases?.[endpoint.entityId]
175
+ : endpoint.entityId
135
176
  const schema = meta.table.schema
136
- return resource.fields.map(field => {
137
- const parts = field.split(/\./g)
138
- let table: string | undefined = undefined
139
- let column: string | undefined = undefined
140
-
141
- // Just a column name, e.g.: "column"
142
- if (parts.length === 1) {
143
- column = parts[0]
144
- }
145
-
146
- // A table name and a column name, e.g.: "table.column"
147
- if (parts.length === 2) {
148
- table = parts[0]
149
- column = parts[1]
150
- }
151
-
152
- // A link doc, e.g.: "table.doc1.fieldName"
153
- if (parts.length > 2) {
154
- table = parts[0]
155
- column = parts.slice(1).join(".")
156
- }
157
-
158
- if (!column) {
159
- throw new Error(`Invalid field name: ${field}`)
160
- }
161
-
162
- const columnSchema = schema[column]
177
+ if (!this.isFullSelectStatementRequired()) {
178
+ return [this.knex.raw(`${this.quote(alias)}.*`)]
179
+ }
180
+ // get just the fields for this table
181
+ return resource.fields
182
+ .map(field => {
183
+ const parts = field.split(/\./g)
184
+ let table: string | undefined = undefined
185
+ let column = parts[0]
186
+
187
+ // Just a column name, e.g.: "column"
188
+ if (parts.length > 1) {
189
+ table = parts[0]
190
+ column = parts.slice(1).join(".")
191
+ }
163
192
 
164
- if (
165
- this.client === SqlClient.POSTGRES &&
166
- columnSchema?.externalType?.includes("money")
167
- ) {
168
- return this.knex.raw(
169
- `${this.quotedIdentifier(
170
- [table, column].join(".")
171
- )}::money::numeric as ${this.quote(field)}`
172
- )
173
- }
193
+ return { table, column, field }
194
+ })
195
+ .filter(({ table }) => !table || table === alias)
196
+ .map(({ table, column, field }) => {
197
+ const columnSchema = schema[column]
198
+
199
+ if (this.SPECIAL_SELECT_CASES.POSTGRES_MONEY(columnSchema)) {
200
+ return this.knex.raw(
201
+ `${this.quotedIdentifier(
202
+ [table, column].join(".")
203
+ )}::money::numeric as ${this.quote(field)}`
204
+ )
205
+ }
174
206
 
175
- if (
176
- this.client === SqlClient.MS_SQL &&
177
- columnSchema?.type === FieldType.DATETIME &&
178
- columnSchema.timeOnly
179
- ) {
180
- // Time gets returned as timestamp from mssql, not matching the expected
181
- // HH:mm format
182
- return this.knex.raw(`CONVERT(varchar, ${field}, 108) as "${field}"`)
183
- }
207
+ if (this.SPECIAL_SELECT_CASES.MSSQL_DATES(columnSchema)) {
208
+ // Time gets returned as timestamp from mssql, not matching the expected
209
+ // HH:mm format
210
+ return this.knex.raw(`CONVERT(varchar, ${field}, 108) as "${field}"`)
211
+ }
184
212
 
185
- // There's at least two edge cases being handled in the expression below.
186
- // 1. The column name could start/end with a space, and in that case we
187
- // want to preseve that space.
188
- // 2. Almost all column names are specified in the form table.column, except
189
- // in the case of relationships, where it's table.doc1.column. In that
190
- // case, we want to split it into `table`.`doc1.column` for reasons that
191
- // aren't actually clear to me, but `table`.`doc1` breaks things with the
192
- // sample data tests.
193
- if (table) {
194
- return this.knex.raw(
195
- `${this.quote(table)}.${this.quote(column)} as ${this.quote(field)}`
196
- )
197
- } else {
198
- return this.knex.raw(`${this.quote(field)} as ${this.quote(field)}`)
199
- }
200
- })
213
+ const quoted = table
214
+ ? `${this.quote(table)}.${this.quote(column)}`
215
+ : this.quote(field)
216
+ return this.knex.raw(quoted)
217
+ })
201
218
  }
202
219
 
203
220
  // OracleDB can't use character-large-objects (CLOBs) in WHERE clauses,
@@ -328,6 +345,85 @@ class InternalBuilder {
328
345
  return filters
329
346
  }
330
347
 
348
+ addJoinFieldCheck(query: Knex.QueryBuilder, relationship: RelationshipsJson) {
349
+ const document = relationship.from?.split(".")[0] || ""
350
+ return query.andWhere(`${document}.fieldName`, "=", relationship.column)
351
+ }
352
+
353
+ addRelationshipForFilter(
354
+ query: Knex.QueryBuilder,
355
+ filterKey: string,
356
+ whereCb: (query: Knex.QueryBuilder) => Knex.QueryBuilder
357
+ ): Knex.QueryBuilder {
358
+ const mainKnex = this.knex
359
+ const { relationships, endpoint, tableAliases: aliases } = this.query
360
+ const tableName = endpoint.entityId
361
+ const fromAlias = aliases?.[tableName] || tableName
362
+ const matches = (possibleTable: string) =>
363
+ filterKey.startsWith(`${possibleTable}`)
364
+ if (!relationships) {
365
+ return query
366
+ }
367
+ for (const relationship of relationships) {
368
+ const relatedTableName = relationship.tableName
369
+ const toAlias = aliases?.[relatedTableName] || relatedTableName
370
+ // this is the relationship which is being filtered
371
+ if (
372
+ (matches(relatedTableName) || matches(toAlias)) &&
373
+ relationship.to &&
374
+ relationship.tableName
375
+ ) {
376
+ let subQuery = mainKnex
377
+ .select(mainKnex.raw(1))
378
+ .from({ [toAlias]: relatedTableName })
379
+ const manyToMany = validateManyToMany(relationship)
380
+ if (manyToMany) {
381
+ const throughAlias =
382
+ aliases?.[manyToMany.through] || relationship.through
383
+ let throughTable = this.tableNameWithSchema(manyToMany.through, {
384
+ alias: throughAlias,
385
+ schema: endpoint.schema,
386
+ })
387
+ subQuery = subQuery
388
+ // add a join through the junction table
389
+ .innerJoin(throughTable, function () {
390
+ // @ts-ignore
391
+ this.on(
392
+ `${toAlias}.${manyToMany.toPrimary}`,
393
+ "=",
394
+ `${throughAlias}.${manyToMany.to}`
395
+ )
396
+ })
397
+ // check the document in the junction table points to the main table
398
+ .where(
399
+ `${throughAlias}.${manyToMany.from}`,
400
+ "=",
401
+ mainKnex.raw(
402
+ this.quotedIdentifier(`${fromAlias}.${manyToMany.fromPrimary}`)
403
+ )
404
+ )
405
+ // in SQS the same junction table is used for different many-to-many relationships between the
406
+ // two same tables, this is needed to avoid rows ending up in all columns
407
+ if (this.client === SqlClient.SQL_LITE) {
408
+ subQuery = this.addJoinFieldCheck(subQuery, manyToMany)
409
+ }
410
+ } else {
411
+ // "join" to the main table, making sure the ID matches that of the main
412
+ subQuery = subQuery.where(
413
+ `${toAlias}.${relationship.to}`,
414
+ "=",
415
+ mainKnex.raw(
416
+ this.quotedIdentifier(`${fromAlias}.${relationship.from}`)
417
+ )
418
+ )
419
+ }
420
+ query = query.whereExists(whereCb(subQuery))
421
+ break
422
+ }
423
+ }
424
+ return query
425
+ }
426
+
331
427
  // right now we only do filters on the specific table being queried
332
428
  addFilters(
333
429
  query: Knex.QueryBuilder,
@@ -339,12 +435,13 @@ class InternalBuilder {
339
435
  if (!filters) {
340
436
  return query
341
437
  }
438
+ const builder = this
342
439
  filters = this.parseFilters({ ...filters })
343
440
  const aliases = this.query.tableAliases
344
441
  // if all or specified in filters, then everything is an or
345
442
  const allOr = filters.allOr
346
- const tableName =
347
- this.client === SqlClient.SQL_LITE ? this.table._id! : this.table.name
443
+ const isSqlite = this.client === SqlClient.SQL_LITE
444
+ const tableName = isSqlite ? this.table._id! : this.table.name
348
445
 
349
446
  function getTableAlias(name: string) {
350
447
  const alias = aliases?.[name]
@@ -352,13 +449,33 @@ class InternalBuilder {
352
449
  }
353
450
  function iterate(
354
451
  structure: AnySearchFilter,
355
- fn: (key: string, value: any) => void,
356
- complexKeyFn?: (key: string[], value: any) => void
452
+ fn: (
453
+ query: Knex.QueryBuilder,
454
+ key: string,
455
+ value: any
456
+ ) => Knex.QueryBuilder,
457
+ complexKeyFn?: (
458
+ query: Knex.QueryBuilder,
459
+ key: string[],
460
+ value: any
461
+ ) => Knex.QueryBuilder
357
462
  ) {
463
+ const handleRelationship = (
464
+ q: Knex.QueryBuilder,
465
+ key: string,
466
+ value: any
467
+ ) => {
468
+ const [filterTableName, ...otherProperties] = key.split(".")
469
+ const property = otherProperties.join(".")
470
+ const alias = getTableAlias(filterTableName)
471
+ return fn(q, alias ? `${alias}.${property}` : property, value)
472
+ }
358
473
  for (const key in structure) {
359
474
  const value = structure[key]
360
475
  const updatedKey = dbCore.removeKeyNumbering(key)
361
476
  const isRelationshipField = updatedKey.includes(".")
477
+ const shouldProcessRelationship =
478
+ opts?.relationship && isRelationshipField
362
479
 
363
480
  let castedTypeValue
364
481
  if (
@@ -367,7 +484,8 @@ class InternalBuilder {
367
484
  complexKeyFn
368
485
  ) {
369
486
  const alias = getTableAlias(tableName)
370
- complexKeyFn(
487
+ query = complexKeyFn(
488
+ query,
371
489
  castedTypeValue.id.map((x: string) =>
372
490
  alias ? `${alias}.${x}` : x
373
491
  ),
@@ -375,26 +493,29 @@ class InternalBuilder {
375
493
  )
376
494
  } else if (!isRelationshipField) {
377
495
  const alias = getTableAlias(tableName)
378
- fn(alias ? `${alias}.${updatedKey}` : updatedKey, value)
379
- }
380
- if (opts?.relationship && isRelationshipField) {
381
- const [filterTableName, property] = updatedKey.split(".")
382
- const alias = getTableAlias(filterTableName)
383
- fn(alias ? `${alias}.${property}` : property, value)
496
+ query = fn(
497
+ query,
498
+ alias ? `${alias}.${updatedKey}` : updatedKey,
499
+ value
500
+ )
501
+ } else if (shouldProcessRelationship) {
502
+ query = builder.addRelationshipForFilter(query, updatedKey, q => {
503
+ return handleRelationship(q, updatedKey, value)
504
+ })
384
505
  }
385
506
  }
386
507
  }
387
508
 
388
- const like = (key: string, value: any) => {
509
+ const like = (q: Knex.QueryBuilder, key: string, value: any) => {
389
510
  const fuzzyOr = filters?.fuzzyOr
390
511
  const fnc = fuzzyOr || allOr ? "orWhere" : "where"
391
512
  // postgres supports ilike, nothing else does
392
513
  if (this.client === SqlClient.POSTGRES) {
393
- query = query[fnc](key, "ilike", `%${value}%`)
514
+ return q[fnc](key, "ilike", `%${value}%`)
394
515
  } else {
395
516
  const rawFnc = `${fnc}Raw`
396
517
  // @ts-ignore
397
- query = query[rawFnc](`LOWER(${this.quotedIdentifier(key)}) LIKE ?`, [
518
+ return q[rawFnc](`LOWER(${this.quotedIdentifier(key)}) LIKE ?`, [
398
519
  `%${value.toLowerCase()}%`,
399
520
  ])
400
521
  }
@@ -412,13 +533,13 @@ class InternalBuilder {
412
533
  return `[${value.join(",")}]`
413
534
  }
414
535
  if (this.client === SqlClient.POSTGRES) {
415
- iterate(mode, (key, value) => {
536
+ iterate(mode, (q, key, value) => {
416
537
  const wrap = any ? "" : "'"
417
538
  const op = any ? "\\?| array" : "@>"
418
539
  const fieldNames = key.split(/\./g)
419
540
  const table = fieldNames[0]
420
541
  const col = fieldNames[1]
421
- query = query[rawFnc](
542
+ return q[rawFnc](
422
543
  `${not}COALESCE("${table}"."${col}"::jsonb ${op} ${wrap}${stringifyArray(
423
544
  value,
424
545
  any ? "'" : '"'
@@ -427,8 +548,8 @@ class InternalBuilder {
427
548
  })
428
549
  } else if (this.client === SqlClient.MY_SQL) {
429
550
  const jsonFnc = any ? "JSON_OVERLAPS" : "JSON_CONTAINS"
430
- iterate(mode, (key, value) => {
431
- query = query[rawFnc](
551
+ iterate(mode, (q, key, value) => {
552
+ return q[rawFnc](
432
553
  `${not}COALESCE(${jsonFnc}(${key}, '${stringifyArray(
433
554
  value
434
555
  )}'), FALSE)`
@@ -436,7 +557,7 @@ class InternalBuilder {
436
557
  })
437
558
  } else {
438
559
  const andOr = mode === filters?.containsAny ? " OR " : " AND "
439
- iterate(mode, (key, value) => {
560
+ iterate(mode, (q, key, value) => {
440
561
  let statement = ""
441
562
  const identifier = this.quotedIdentifier(key)
442
563
  for (let i in value) {
@@ -451,16 +572,16 @@ class InternalBuilder {
451
572
  }
452
573
 
453
574
  if (statement === "") {
454
- return
575
+ return q
455
576
  }
456
577
 
457
578
  if (not) {
458
- query = query[rawFnc](
579
+ return q[rawFnc](
459
580
  `(NOT (${statement}) OR ${identifier} IS NULL)`,
460
581
  value
461
582
  )
462
583
  } else {
463
- query = query[rawFnc](statement, value)
584
+ return q[rawFnc](statement, value)
464
585
  }
465
586
  })
466
587
  }
@@ -490,39 +611,39 @@ class InternalBuilder {
490
611
  const fnc = allOr ? "orWhereIn" : "whereIn"
491
612
  iterate(
492
613
  filters.oneOf,
493
- (key: string, array) => {
614
+ (q, key: string, array) => {
494
615
  if (this.client === SqlClient.ORACLE) {
495
616
  key = this.convertClobs(key)
496
617
  array = Array.isArray(array) ? array : [array]
497
618
  const binding = new Array(array.length).fill("?").join(",")
498
- query = query.whereRaw(`${key} IN (${binding})`, array)
619
+ return q.whereRaw(`${key} IN (${binding})`, array)
499
620
  } else {
500
- query = query[fnc](key, Array.isArray(array) ? array : [array])
621
+ return q[fnc](key, Array.isArray(array) ? array : [array])
501
622
  }
502
623
  },
503
- (key: string[], array) => {
624
+ (q, key: string[], array) => {
504
625
  if (this.client === SqlClient.ORACLE) {
505
626
  const keyStr = `(${key.map(k => this.convertClobs(k)).join(",")})`
506
627
  const binding = `(${array
507
628
  .map((a: any) => `(${new Array(a.length).fill("?").join(",")})`)
508
629
  .join(",")})`
509
- query = query.whereRaw(`${keyStr} IN ${binding}`, array.flat())
630
+ return q.whereRaw(`${keyStr} IN ${binding}`, array.flat())
510
631
  } else {
511
- query = query[fnc](key, Array.isArray(array) ? array : [array])
632
+ return q[fnc](key, Array.isArray(array) ? array : [array])
512
633
  }
513
634
  }
514
635
  )
515
636
  }
516
637
  if (filters.string) {
517
- iterate(filters.string, (key, value) => {
638
+ iterate(filters.string, (q, key, value) => {
518
639
  const fnc = allOr ? "orWhere" : "where"
519
640
  // postgres supports ilike, nothing else does
520
641
  if (this.client === SqlClient.POSTGRES) {
521
- query = query[fnc](key, "ilike", `${value}%`)
642
+ return q[fnc](key, "ilike", `${value}%`)
522
643
  } else {
523
644
  const rawFnc = `${fnc}Raw`
524
645
  // @ts-ignore
525
- query = query[rawFnc](`LOWER(${this.quotedIdentifier(key)}) LIKE ?`, [
646
+ return q[rawFnc](`LOWER(${this.quotedIdentifier(key)}) LIKE ?`, [
526
647
  `${value.toLowerCase()}%`,
527
648
  ])
528
649
  }
@@ -532,7 +653,7 @@ class InternalBuilder {
532
653
  iterate(filters.fuzzy, like)
533
654
  }
534
655
  if (filters.range) {
535
- iterate(filters.range, (key, value) => {
656
+ iterate(filters.range, (q, key, value) => {
536
657
  const isEmptyObject = (val: any) => {
537
658
  return (
538
659
  val &&
@@ -561,97 +682,93 @@ class InternalBuilder {
561
682
  schema?.type === FieldType.BIGINT &&
562
683
  this.client === SqlClient.SQL_LITE
563
684
  ) {
564
- query = query.whereRaw(
685
+ return q.whereRaw(
565
686
  `CAST(${key} AS INTEGER) BETWEEN CAST(? AS INTEGER) AND CAST(? AS INTEGER)`,
566
687
  [value.low, value.high]
567
688
  )
568
689
  } else {
569
690
  const fnc = allOr ? "orWhereBetween" : "whereBetween"
570
- query = query[fnc](key, [value.low, value.high])
691
+ return q[fnc](key, [value.low, value.high])
571
692
  }
572
693
  } else if (lowValid) {
573
694
  if (
574
695
  schema?.type === FieldType.BIGINT &&
575
696
  this.client === SqlClient.SQL_LITE
576
697
  ) {
577
- query = query.whereRaw(
578
- `CAST(${key} AS INTEGER) >= CAST(? AS INTEGER)`,
579
- [value.low]
580
- )
698
+ return q.whereRaw(`CAST(${key} AS INTEGER) >= CAST(? AS INTEGER)`, [
699
+ value.low,
700
+ ])
581
701
  } else {
582
702
  const fnc = allOr ? "orWhere" : "where"
583
- query = query[fnc](key, ">=", value.low)
703
+ return q[fnc](key, ">=", value.low)
584
704
  }
585
705
  } else if (highValid) {
586
706
  if (
587
707
  schema?.type === FieldType.BIGINT &&
588
708
  this.client === SqlClient.SQL_LITE
589
709
  ) {
590
- query = query.whereRaw(
591
- `CAST(${key} AS INTEGER) <= CAST(? AS INTEGER)`,
592
- [value.high]
593
- )
710
+ return q.whereRaw(`CAST(${key} AS INTEGER) <= CAST(? AS INTEGER)`, [
711
+ value.high,
712
+ ])
594
713
  } else {
595
714
  const fnc = allOr ? "orWhere" : "where"
596
- query = query[fnc](key, "<=", value.high)
715
+ return q[fnc](key, "<=", value.high)
597
716
  }
598
717
  }
718
+ return q
599
719
  })
600
720
  }
601
721
  if (filters.equal) {
602
- iterate(filters.equal, (key, value) => {
722
+ iterate(filters.equal, (q, key, value) => {
603
723
  const fnc = allOr ? "orWhereRaw" : "whereRaw"
604
724
  if (this.client === SqlClient.MS_SQL) {
605
- query = query[fnc](
725
+ return q[fnc](
606
726
  `CASE WHEN ${this.quotedIdentifier(key)} = ? THEN 1 ELSE 0 END = 1`,
607
727
  [value]
608
728
  )
609
729
  } else if (this.client === SqlClient.ORACLE) {
610
730
  const identifier = this.convertClobs(key)
611
- query = query[fnc](
612
- `(${identifier} IS NOT NULL AND ${identifier} = ?)`,
613
- [value]
614
- )
731
+ return q[fnc](`(${identifier} IS NOT NULL AND ${identifier} = ?)`, [
732
+ value,
733
+ ])
615
734
  } else {
616
- query = query[fnc](
617
- `COALESCE(${this.quotedIdentifier(key)} = ?, FALSE)`,
618
- [value]
619
- )
735
+ return q[fnc](`COALESCE(${this.quotedIdentifier(key)} = ?, FALSE)`, [
736
+ value,
737
+ ])
620
738
  }
621
739
  })
622
740
  }
623
741
  if (filters.notEqual) {
624
- iterate(filters.notEqual, (key, value) => {
742
+ iterate(filters.notEqual, (q, key, value) => {
625
743
  const fnc = allOr ? "orWhereRaw" : "whereRaw"
626
744
  if (this.client === SqlClient.MS_SQL) {
627
- query = query[fnc](
745
+ return q[fnc](
628
746
  `CASE WHEN ${this.quotedIdentifier(key)} = ? THEN 1 ELSE 0 END = 0`,
629
747
  [value]
630
748
  )
631
749
  } else if (this.client === SqlClient.ORACLE) {
632
750
  const identifier = this.convertClobs(key)
633
- query = query[fnc](
751
+ return q[fnc](
634
752
  `(${identifier} IS NOT NULL AND ${identifier} != ?) OR ${identifier} IS NULL`,
635
753
  [value]
636
754
  )
637
755
  } else {
638
- query = query[fnc](
639
- `COALESCE(${this.quotedIdentifier(key)} != ?, TRUE)`,
640
- [value]
641
- )
756
+ return q[fnc](`COALESCE(${this.quotedIdentifier(key)} != ?, TRUE)`, [
757
+ value,
758
+ ])
642
759
  }
643
760
  })
644
761
  }
645
762
  if (filters.empty) {
646
- iterate(filters.empty, key => {
763
+ iterate(filters.empty, (q, key) => {
647
764
  const fnc = allOr ? "orWhereNull" : "whereNull"
648
- query = query[fnc](key)
765
+ return q[fnc](key)
649
766
  })
650
767
  }
651
768
  if (filters.notEmpty) {
652
- iterate(filters.notEmpty, key => {
769
+ iterate(filters.notEmpty, (q, key) => {
653
770
  const fnc = allOr ? "orWhereNotNull" : "whereNotNull"
654
- query = query[fnc](key)
771
+ return q[fnc](key)
655
772
  })
656
773
  }
657
774
  if (filters.contains) {
@@ -745,80 +862,207 @@ class InternalBuilder {
745
862
  return withSchema
746
863
  }
747
864
 
748
- addRelationships(
865
+ private buildJsonField(field: string): string {
866
+ const parts = field.split(".")
867
+ let tableField: string, unaliased: string
868
+ if (parts.length > 1) {
869
+ const alias = parts.shift()!
870
+ unaliased = parts.join(".")
871
+ tableField = `${this.quote(alias)}.${this.quote(unaliased)}`
872
+ } else {
873
+ unaliased = parts.join(".")
874
+ tableField = this.quote(unaliased)
875
+ }
876
+ const separator = this.client === SqlClient.ORACLE ? " VALUE " : ","
877
+ return `'${unaliased}'${separator}${tableField}`
878
+ }
879
+
880
+ addJsonRelationships(
749
881
  query: Knex.QueryBuilder,
750
882
  fromTable: string,
751
- relationships: RelationshipsJson[] | undefined,
752
- schema: string | undefined,
753
- aliases?: Record<string, string>
883
+ relationships: RelationshipsJson[]
754
884
  ): Knex.QueryBuilder {
755
- if (!relationships) {
756
- return query
757
- }
758
- const tableSets: Record<string, [RelationshipsJson]> = {}
759
- // aggregate into table sets (all the same to tables)
885
+ const sqlClient = this.client
886
+ const knex = this.knex
887
+ const { resource, tableAliases: aliases, endpoint } = this.query
888
+ const fields = resource?.fields || []
760
889
  for (let relationship of relationships) {
761
- const keyObj: { toTable: string; throughTable: string | undefined } = {
762
- toTable: relationship.tableName,
763
- throughTable: undefined,
764
- }
765
- if (relationship.through) {
766
- keyObj.throughTable = relationship.through
767
- }
768
- const key = JSON.stringify(keyObj)
769
- if (tableSets[key]) {
770
- tableSets[key].push(relationship)
771
- } else {
772
- tableSets[key] = [relationship]
890
+ const {
891
+ tableName: toTable,
892
+ through: throughTable,
893
+ to: toKey,
894
+ from: fromKey,
895
+ fromPrimary,
896
+ toPrimary,
897
+ } = relationship
898
+ // skip invalid relationships
899
+ if (!toTable || !fromTable) {
900
+ continue
773
901
  }
774
- }
775
- for (let [key, relationships] of Object.entries(tableSets)) {
776
- const { toTable, throughTable } = JSON.parse(key)
777
902
  const toAlias = aliases?.[toTable] || toTable,
778
- throughAlias = aliases?.[throughTable] || throughTable,
779
903
  fromAlias = aliases?.[fromTable] || fromTable
780
904
  let toTableWithSchema = this.tableNameWithSchema(toTable, {
781
905
  alias: toAlias,
782
- schema,
906
+ schema: endpoint.schema,
783
907
  })
784
- let throughTableWithSchema = this.tableNameWithSchema(throughTable, {
785
- alias: throughAlias,
786
- schema,
908
+ let relationshipFields = fields.filter(
909
+ field => field.split(".")[0] === toAlias
910
+ )
911
+ if (this.client === SqlClient.SQL_LITE) {
912
+ relationshipFields = relationshipFields.slice(
913
+ 0,
914
+ MAX_SQS_RELATIONSHIP_FIELDS
915
+ )
916
+ }
917
+ const fieldList: string = relationshipFields
918
+ .map(field => this.buildJsonField(field))
919
+ .join(",")
920
+ // SQL Server uses TOP - which performs a little differently to the normal LIMIT syntax
921
+ // it reduces the result set rather than limiting how much data it filters over
922
+ const primaryKey = `${toAlias}.${toPrimary || toKey}`
923
+ let subQuery: Knex.QueryBuilder = knex
924
+ .from(toTableWithSchema)
925
+ .limit(getRelationshipLimit())
926
+ // add sorting to get consistent order
927
+ .orderBy(primaryKey)
928
+
929
+ // many-to-many relationship with junction table
930
+ if (throughTable && toPrimary && fromPrimary) {
931
+ const throughAlias = aliases?.[throughTable] || throughTable
932
+ let throughTableWithSchema = this.tableNameWithSchema(throughTable, {
933
+ alias: throughAlias,
934
+ schema: endpoint.schema,
935
+ })
936
+ subQuery = subQuery
937
+ .join(throughTableWithSchema, function () {
938
+ this.on(`${toAlias}.${toPrimary}`, "=", `${throughAlias}.${toKey}`)
939
+ })
940
+ .where(
941
+ `${throughAlias}.${fromKey}`,
942
+ "=",
943
+ knex.raw(this.quotedIdentifier(`${fromAlias}.${fromPrimary}`))
944
+ )
945
+ }
946
+ // one-to-many relationship with foreign key
947
+ else {
948
+ subQuery = subQuery.where(
949
+ `${toAlias}.${toKey}`,
950
+ "=",
951
+ knex.raw(this.quotedIdentifier(`${fromAlias}.${fromKey}`))
952
+ )
953
+ }
954
+
955
+ const standardWrap = (select: string): Knex.QueryBuilder => {
956
+ subQuery = subQuery.select(`${toAlias}.*`)
957
+ // @ts-ignore - the from alias syntax isn't in Knex typing
958
+ return knex.select(knex.raw(select)).from({
959
+ [toAlias]: subQuery,
960
+ })
961
+ }
962
+ let wrapperQuery: Knex.QueryBuilder | Knex.Raw
963
+ switch (sqlClient) {
964
+ case SqlClient.SQL_LITE:
965
+ // need to check the junction table document is to the right column, this is just for SQS
966
+ subQuery = this.addJoinFieldCheck(subQuery, relationship)
967
+ wrapperQuery = standardWrap(
968
+ `json_group_array(json_object(${fieldList}))`
969
+ )
970
+ break
971
+ case SqlClient.POSTGRES:
972
+ wrapperQuery = standardWrap(
973
+ `json_agg(json_build_object(${fieldList}))`
974
+ )
975
+ break
976
+ case SqlClient.MY_SQL:
977
+ wrapperQuery = subQuery.select(
978
+ knex.raw(`json_arrayagg(json_object(${fieldList}))`)
979
+ )
980
+ break
981
+ case SqlClient.ORACLE:
982
+ wrapperQuery = standardWrap(
983
+ `json_arrayagg(json_object(${fieldList}))`
984
+ )
985
+ break
986
+ case SqlClient.MS_SQL:
987
+ wrapperQuery = knex.raw(
988
+ `(SELECT ${this.quote(toAlias)} = (${knex
989
+ .select(`${fromAlias}.*`)
990
+ // @ts-ignore - from alias syntax not TS supported
991
+ .from({
992
+ [fromAlias]: subQuery.select(`${toAlias}.*`),
993
+ })} FOR JSON PATH))`
994
+ )
995
+ break
996
+ default:
997
+ throw new Error(`JSON relationships not implement for ${sqlClient}`)
998
+ }
999
+
1000
+ query = query.select({ [relationship.column]: wrapperQuery })
1001
+ }
1002
+ return query
1003
+ }
1004
+
1005
+ addJoin(
1006
+ query: Knex.QueryBuilder,
1007
+ tables: { from: string; to: string; through?: string },
1008
+ columns: {
1009
+ from?: string
1010
+ to?: string
1011
+ fromPrimary?: string
1012
+ toPrimary?: string
1013
+ }[]
1014
+ ): Knex.QueryBuilder {
1015
+ const { tableAliases: aliases, endpoint } = this.query
1016
+ const schema = endpoint.schema
1017
+ const toTable = tables.to,
1018
+ fromTable = tables.from,
1019
+ throughTable = tables.through
1020
+ const toAlias = aliases?.[toTable] || toTable,
1021
+ throughAlias = (throughTable && aliases?.[throughTable]) || throughTable,
1022
+ fromAlias = aliases?.[fromTable] || fromTable
1023
+ let toTableWithSchema = this.tableNameWithSchema(toTable, {
1024
+ alias: toAlias,
1025
+ schema,
1026
+ })
1027
+ let throughTableWithSchema = throughTable
1028
+ ? this.tableNameWithSchema(throughTable, {
1029
+ alias: throughAlias,
1030
+ schema,
1031
+ })
1032
+ : undefined
1033
+ if (!throughTable) {
1034
+ // @ts-ignore
1035
+ query = query.leftJoin(toTableWithSchema, function () {
1036
+ for (let relationship of columns) {
1037
+ const from = relationship.from,
1038
+ to = relationship.to
1039
+ // @ts-ignore
1040
+ this.orOn(`${fromAlias}.${from}`, "=", `${toAlias}.${to}`)
1041
+ }
787
1042
  })
788
- if (!throughTable) {
1043
+ } else {
1044
+ query = query
789
1045
  // @ts-ignore
790
- query = query.leftJoin(toTableWithSchema, function () {
791
- for (let relationship of relationships) {
792
- const from = relationship.from,
793
- to = relationship.to
1046
+ .leftJoin(throughTableWithSchema, function () {
1047
+ for (let relationship of columns) {
1048
+ const fromPrimary = relationship.fromPrimary
1049
+ const from = relationship.from
794
1050
  // @ts-ignore
795
- this.orOn(`${fromAlias}.${from}`, "=", `${toAlias}.${to}`)
1051
+ this.orOn(
1052
+ `${fromAlias}.${fromPrimary}`,
1053
+ "=",
1054
+ `${throughAlias}.${from}`
1055
+ )
1056
+ }
1057
+ })
1058
+ .leftJoin(toTableWithSchema, function () {
1059
+ for (let relationship of columns) {
1060
+ const toPrimary = relationship.toPrimary
1061
+ const to = relationship.to
1062
+ // @ts-ignore
1063
+ this.orOn(`${toAlias}.${toPrimary}`, `${throughAlias}.${to}`)
796
1064
  }
797
1065
  })
798
- } else {
799
- query = query
800
- // @ts-ignore
801
- .leftJoin(throughTableWithSchema, function () {
802
- for (let relationship of relationships) {
803
- const fromPrimary = relationship.fromPrimary
804
- const from = relationship.from
805
- // @ts-ignore
806
- this.orOn(
807
- `${fromAlias}.${fromPrimary}`,
808
- "=",
809
- `${throughAlias}.${from}`
810
- )
811
- }
812
- })
813
- .leftJoin(toTableWithSchema, function () {
814
- for (let relationship of relationships) {
815
- const toPrimary = relationship.toPrimary
816
- const to = relationship.to
817
- // @ts-ignore
818
- this.orOn(`${toAlias}.${toPrimary}`, `${throughAlias}.${to}`)
819
- }
820
- })
821
- }
822
1066
  }
823
1067
  return query
824
1068
  }
@@ -906,8 +1150,7 @@ class InternalBuilder {
906
1150
  if (!primary) {
907
1151
  throw new Error("Primary key is required for upsert")
908
1152
  }
909
- const ret = query.insert(parsedBody).onConflict(primary).merge()
910
- return ret
1153
+ return query.insert(parsedBody).onConflict(primary).merge()
911
1154
  } else if (
912
1155
  this.client === SqlClient.MS_SQL ||
913
1156
  this.client === SqlClient.ORACLE
@@ -924,8 +1167,7 @@ class InternalBuilder {
924
1167
  limits?: { base: number; query: number }
925
1168
  } = {}
926
1169
  ): Knex.QueryBuilder {
927
- let { endpoint, filters, paginate, relationships, tableAliases } =
928
- this.query
1170
+ let { endpoint, filters, paginate, relationships } = this.query
929
1171
  const { limits } = opts
930
1172
  const counting = endpoint.operation === Operation.COUNT
931
1173
 
@@ -957,45 +1199,39 @@ class InternalBuilder {
957
1199
  if (foundOffset != null) {
958
1200
  query = query.offset(foundOffset)
959
1201
  }
960
- // add sorting to pre-query
961
- // no point in sorting when counting
962
- query = this.addSorting(query)
963
1202
  }
964
- // add filters to the query (where)
965
- query = this.addFilters(query, filters)
966
1203
 
967
- const alias = tableAliases?.[tableName] || tableName
968
- let preQuery: Knex.QueryBuilder = this.knex({
969
- // the typescript definition for the knex constructor doesn't support this
970
- // syntax, but it is the only way to alias a pre-query result as part of
971
- // a query - there is an alias dictionary type, but it assumes it can only
972
- // be a table name, not a pre-query
973
- [alias]: query as any,
974
- })
975
1204
  // if counting, use distinct count, else select
976
- preQuery = !counting
977
- ? preQuery.select(this.generateSelectStatement())
978
- : this.addDistinctCount(preQuery)
1205
+ query = !counting
1206
+ ? query.select(this.generateSelectStatement())
1207
+ : this.addDistinctCount(query)
979
1208
  // have to add after as well (this breaks MS-SQL)
980
- if (this.client !== SqlClient.MS_SQL && !counting) {
981
- preQuery = this.addSorting(preQuery)
982
- }
983
- // handle joins
984
- query = this.addRelationships(
985
- preQuery,
986
- tableName,
987
- relationships,
988
- endpoint.schema,
989
- tableAliases
990
- )
991
-
992
- // add a base limit over the whole query
993
- // if counting we can't set this limit
994
- if (limits?.base) {
995
- query = query.limit(limits.base)
1209
+ if (!counting) {
1210
+ query = this.addSorting(query)
996
1211
  }
997
1212
 
998
- return this.addFilters(query, filters, { relationship: true })
1213
+ query = this.addFilters(query, filters, { relationship: true })
1214
+
1215
+ // handle relationships with a CTE for all others
1216
+ if (relationships?.length) {
1217
+ const mainTable =
1218
+ this.query.tableAliases?.[this.query.endpoint.entityId] ||
1219
+ this.query.endpoint.entityId
1220
+ const cte = this.addSorting(
1221
+ this.knex
1222
+ .with("paginated", query)
1223
+ .select(this.generateSelectStatement())
1224
+ .from({
1225
+ [mainTable]: "paginated",
1226
+ })
1227
+ )
1228
+ // add JSON aggregations attached to the CTE
1229
+ return this.addJsonRelationships(cte, tableName, relationships)
1230
+ }
1231
+ // no relationships found - return query
1232
+ else {
1233
+ return query
1234
+ }
999
1235
  }
1000
1236
 
1001
1237
  update(opts: QueryOptions): Knex.QueryBuilder {