@subsquid/openreader 0.2.0 → 0.3.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.
Files changed (128) hide show
  1. package/README.md +2 -15
  2. package/bin/main.js +2 -0
  3. package/dist/db.d.ts +28 -0
  4. package/dist/db.d.ts.map +1 -0
  5. package/dist/db.js +69 -0
  6. package/dist/db.js.map +1 -0
  7. package/dist/gql/opencrud.d.ts +1 -0
  8. package/dist/gql/opencrud.d.ts.map +1 -0
  9. package/dist/gql/opencrud.js +10 -9
  10. package/dist/gql/opencrud.js.map +1 -1
  11. package/dist/gql/schema.d.ts +1 -0
  12. package/dist/gql/schema.d.ts.map +1 -0
  13. package/dist/main.d.ts +2 -3
  14. package/dist/main.d.ts.map +1 -0
  15. package/dist/main.js +6 -30
  16. package/dist/main.js.map +1 -1
  17. package/dist/model.d.ts +1 -0
  18. package/dist/model.d.ts.map +1 -0
  19. package/dist/model.tools.d.ts +1 -0
  20. package/dist/model.tools.d.ts.map +1 -0
  21. package/dist/orderBy.d.ts +1 -0
  22. package/dist/orderBy.d.ts.map +1 -0
  23. package/dist/queryBuilder.d.ts +3 -2
  24. package/dist/queryBuilder.d.ts.map +1 -0
  25. package/dist/queryBuilder.js +28 -27
  26. package/dist/queryBuilder.js.map +1 -1
  27. package/dist/relayConnection.d.ts +1 -0
  28. package/dist/relayConnection.d.ts.map +1 -0
  29. package/dist/requestedFields.d.ts +1 -0
  30. package/dist/requestedFields.d.ts.map +1 -0
  31. package/dist/requestedFields.js +3 -3
  32. package/dist/requestedFields.js.map +1 -1
  33. package/dist/resolver.d.ts +3 -2
  34. package/dist/resolver.d.ts.map +1 -0
  35. package/dist/resolver.js +12 -11
  36. package/dist/resolver.js.map +1 -1
  37. package/dist/scalars.d.ts +2 -2
  38. package/dist/scalars.d.ts.map +1 -0
  39. package/dist/scalars.js +6 -2
  40. package/dist/scalars.js.map +1 -1
  41. package/dist/server.d.ts +5 -11
  42. package/dist/server.d.ts.map +1 -0
  43. package/dist/server.js +13 -31
  44. package/dist/server.js.map +1 -1
  45. package/dist/test/basic.test.d.ts +2 -0
  46. package/dist/test/basic.test.d.ts.map +1 -0
  47. package/dist/test/basic.test.js +286 -0
  48. package/dist/test/basic.test.js.map +1 -0
  49. package/dist/test/connection.test.d.ts +2 -0
  50. package/dist/test/connection.test.d.ts.map +1 -0
  51. package/dist/test/connection.test.js +193 -0
  52. package/dist/test/connection.test.js.map +1 -0
  53. package/dist/test/fts.test.d.ts +2 -0
  54. package/dist/test/fts.test.d.ts.map +1 -0
  55. package/dist/test/fts.test.js +110 -0
  56. package/dist/test/fts.test.js.map +1 -0
  57. package/dist/test/lists.test.d.ts +2 -0
  58. package/dist/test/lists.test.d.ts.map +1 -0
  59. package/dist/test/lists.test.js +266 -0
  60. package/dist/test/lists.test.js.map +1 -0
  61. package/dist/test/lookup.test.d.ts +2 -0
  62. package/dist/test/lookup.test.d.ts.map +1 -0
  63. package/dist/test/lookup.test.js +109 -0
  64. package/dist/test/lookup.test.js.map +1 -0
  65. package/dist/test/scalars.test.d.ts +2 -0
  66. package/dist/test/scalars.test.d.ts.map +1 -0
  67. package/dist/test/scalars.test.js +303 -0
  68. package/dist/test/scalars.test.js.map +1 -0
  69. package/dist/test/tools.test.d.ts +2 -0
  70. package/dist/test/tools.test.d.ts.map +1 -0
  71. package/dist/test/tools.test.js +49 -0
  72. package/dist/test/tools.test.js.map +1 -0
  73. package/dist/test/typed-json.test.d.ts +2 -0
  74. package/dist/test/typed-json.test.d.ts.map +1 -0
  75. package/dist/test/typed-json.test.js +75 -0
  76. package/dist/test/typed-json.test.js.map +1 -0
  77. package/dist/test/unions.test.d.ts +2 -0
  78. package/dist/test/unions.test.d.ts.map +1 -0
  79. package/dist/test/unions.test.js +84 -0
  80. package/dist/test/unions.test.js.map +1 -0
  81. package/dist/test/util/setup.d.ts +7 -0
  82. package/dist/test/util/setup.d.ts.map +1 -0
  83. package/dist/test/util/setup.js +60 -0
  84. package/dist/test/util/setup.js.map +1 -0
  85. package/dist/test/where.test.d.ts +2 -0
  86. package/dist/test/where.test.d.ts.map +1 -0
  87. package/dist/test/where.test.js +127 -0
  88. package/dist/test/where.test.js.map +1 -0
  89. package/dist/tools.d.ts +1 -0
  90. package/dist/tools.d.ts.map +1 -0
  91. package/dist/util.d.ts +1 -13
  92. package/dist/util.d.ts.map +1 -0
  93. package/dist/util.js +6 -79
  94. package/dist/util.js.map +1 -1
  95. package/dist/where.d.ts +1 -0
  96. package/dist/where.d.ts.map +1 -0
  97. package/package.json +26 -20
  98. package/src/db.ts +83 -0
  99. package/src/gql/opencrud.ts +328 -0
  100. package/src/gql/schema.ts +337 -0
  101. package/src/main.ts +51 -0
  102. package/src/model.tools.ts +173 -0
  103. package/src/model.ts +125 -0
  104. package/src/orderBy.ts +105 -0
  105. package/src/queryBuilder.ts +785 -0
  106. package/src/relayConnection.ts +80 -0
  107. package/src/requestedFields.ts +246 -0
  108. package/src/resolver.ts +199 -0
  109. package/src/scalars.ts +247 -0
  110. package/src/server.ts +115 -0
  111. package/src/test/basic.test.ts +339 -0
  112. package/src/test/connection.test.ts +195 -0
  113. package/src/test/fts.test.ts +114 -0
  114. package/src/test/lists.test.ts +278 -0
  115. package/src/test/lookup.test.ts +111 -0
  116. package/src/test/scalars.test.ts +316 -0
  117. package/src/test/tools.test.ts +27 -0
  118. package/src/test/typed-json.test.ts +76 -0
  119. package/src/test/unions.test.ts +85 -0
  120. package/src/test/util/setup.ts +63 -0
  121. package/src/test/where.test.ts +135 -0
  122. package/src/tools.ts +33 -0
  123. package/src/util.ts +39 -0
  124. package/src/where.ts +110 -0
  125. package/CHANGELOG.md +0 -16
  126. package/dist/transaction.d.ts +0 -10
  127. package/dist/transaction.js +0 -47
  128. package/dist/transaction.js.map +0 -1
@@ -0,0 +1,785 @@
1
+ import {toSnakeCase} from "@subsquid/util"
2
+ import assert from "assert"
3
+ import type {ClientBase, QueryArrayResult} from "pg"
4
+ import {Database} from "./db"
5
+ import type {Entity, JsonObject, Model} from "./model"
6
+ import {getEntity, getFtsQuery, getObject, getUnionProps} from "./model.tools"
7
+ import {OpenCrudOrderByValue, OrderBy, parseOrderBy} from "./orderBy"
8
+ import type {FtsRequestedFields, RequestedFields} from "./requestedFields"
9
+ import {fromJsonCast, fromJsonToOutputCast, toOutputArrayCast, toOutputCast} from "./scalars"
10
+ import {ensureArray, toColumn, toFkColumn, toInt, toTable, unsupportedCase} from "./util"
11
+ import {hasConditions, parseWhereField, WhereOp, whereOpToSqlOperator} from "./where"
12
+
13
+
14
+ export interface ListArgs {
15
+ offset?: number
16
+ limit?: number
17
+ orderBy?: OpenCrudOrderByValue[]
18
+ where?: any
19
+ }
20
+
21
+
22
+ export class QueryBuilder {
23
+ private params: any[] = []
24
+ private aliases: AliasSet = new AliasSet()
25
+
26
+ constructor(
27
+ private model: Model,
28
+ private db: Database
29
+ ) {}
30
+
31
+ private param(value: any): string {
32
+ return '$' + this.params.push(value)
33
+ }
34
+
35
+ private ident(name: string): string {
36
+ return this.db.escapeIdentifier(name)
37
+ }
38
+
39
+ select(entityName: string, args: ListArgs, fields?: RequestedFields, variant?: SelectVariant): string {
40
+ let entity = getEntity(this.model, entityName)
41
+ let table = toTable(entityName)
42
+ let alias = this.aliases.add(table)
43
+ let join = new JoinSet(this.aliases)
44
+
45
+ let cursor = new Cursor(
46
+ this.model,
47
+ this.ident.bind(this),
48
+ this.aliases,
49
+ join,
50
+ entityName,
51
+ entity,
52
+ alias,
53
+ ''
54
+ )
55
+
56
+ let whereExps: string[] = []
57
+ let orderByExps: string[] = []
58
+ let columns = new ColumnSet()
59
+ let out = ''
60
+
61
+ if (fields) {
62
+ this.populateColumns(columns, cursor, fields)
63
+ }
64
+
65
+ switch(variant?.kind) {
66
+ case 'fts':
67
+ out += 'SELECT\n'
68
+ out += ` '${entityName}' AS isTypeOf`
69
+ out += ',\n'
70
+ out += ` ts_rank(${cursor.tsv(variant.queryName)}, phraseto_tsquery('english', ${variant.textParam})) AS rank`
71
+ out += ',\n'
72
+ out += ` ts_headline(${cursor.doc(variant.queryName)}, phraseto_tsquery('english', ${variant.textParam})) AS highlight`
73
+ out += ',\n'
74
+ out += columns.size() ? ` json_build_array(${columns.render()})` : " '[]'::json"
75
+ out += ' AS item\n'
76
+ break
77
+ case 'list-subquery':
78
+ if (columns.size()) {
79
+ out += `SELECT json_build_array(${columns.render()}) `
80
+ }
81
+ break
82
+ default:
83
+ if (columns.size()) {
84
+ out += `SELECT ${columns.render()}\n`
85
+ }
86
+ }
87
+
88
+ out += `FROM ${this.ident(table)} ${this.ident(alias)}`
89
+
90
+ if (hasConditions(args.where)) {
91
+ whereExps.push(this.generateWhere(cursor, args.where))
92
+ }
93
+
94
+ if (variant?.kind == 'list-subquery') {
95
+ whereExps.push(`${cursor.fk(variant.field)} = ${variant.parent}`)
96
+ }
97
+
98
+ if (variant?.kind == 'fts') {
99
+ whereExps.push(`phraseto_tsquery('english', ${variant.textParam}) @@ ${cursor.tsv(variant.queryName)}`)
100
+ }
101
+
102
+ let orderByInput = args.orderBy && ensureArray(args.orderBy)
103
+ if (orderByInput?.length) {
104
+ let orderBy = parseOrderBy(this.model, entityName, orderByInput)
105
+ this.populateOrderBy(orderByExps, cursor, orderBy)
106
+ }
107
+
108
+ join.forEach(j => {
109
+ let table = this.ident(j.table)
110
+ let alias = this.ident(j.alias)
111
+ out += `\nLEFT OUTER JOIN ${table} ${alias} ON ${alias}.${j.column} = ${j.rhs}`
112
+ })
113
+
114
+ if (whereExps.length) {
115
+ out += '\nWHERE ' + whereExps.join(' AND ')
116
+ }
117
+
118
+ if (orderByExps.length > 0) {
119
+ out += '\nORDER BY ' + orderByExps.join(', ')
120
+ }
121
+
122
+ if (args.limit) {
123
+ out += '\nLIMIT ' + this.param(args.limit)
124
+ }
125
+
126
+ if (args.offset) {
127
+ out += '\nOFFSET ' + this.param(args.offset)
128
+ }
129
+
130
+ if (variant?.kind == 'list-subquery') {
131
+ out = out.replace(/\n/g, ' ')
132
+ }
133
+
134
+ return out
135
+ }
136
+
137
+ private populateOrderBy(
138
+ exps: string[],
139
+ cursor: Cursor,
140
+ orderBy: OrderBy
141
+ ) {
142
+ for (let key in orderBy) {
143
+ let spec = orderBy[key]
144
+ let propType = cursor.object.properties[key].type
145
+ switch(propType.kind) {
146
+ case 'scalar':
147
+ case 'enum':
148
+ assert(typeof spec == 'string')
149
+ exps.push(`${cursor.native(key)} ${spec}`)
150
+ break
151
+ case 'object':
152
+ case 'union':
153
+ case 'fk':
154
+ case 'lookup':
155
+ assert(typeof spec == 'object')
156
+ this.populateOrderBy(
157
+ exps,
158
+ cursor.child(key),
159
+ spec
160
+ )
161
+ break
162
+ default:
163
+ throw unsupportedCase(propType.kind)
164
+ }
165
+ }
166
+ }
167
+
168
+ private populateColumns(
169
+ columns: ColumnSet,
170
+ cursor: Cursor,
171
+ fields$?: RequestedFields
172
+ ): void {
173
+ for (let fieldName in fields$) {
174
+ let field = fields$[fieldName]
175
+ for (let i = 0; i < field.requests.length; i++) {
176
+ let req = field.requests[i]
177
+ switch(field.propType.kind) {
178
+ case 'scalar':
179
+ case 'enum':
180
+ case 'list':
181
+ req.index = columns.add(cursor.transport(fieldName))
182
+ break
183
+ case 'object':
184
+ req.index = columns.add(cursor.field(fieldName) + ' IS NULL')
185
+ this.populateColumns(
186
+ columns,
187
+ cursor.child(fieldName),
188
+ req.children
189
+ )
190
+ break
191
+ case 'union':
192
+ let cu = cursor.child(fieldName)
193
+ req.index = columns.add(cu.transport('isTypeOf'))
194
+ this.populateColumns(
195
+ columns,
196
+ cu,
197
+ req.children
198
+ )
199
+ break
200
+ case 'fk':
201
+ case 'lookup': {
202
+ let cu = cursor.child(fieldName)
203
+ req.index = columns.add(cu.transport('id'))
204
+ this.populateColumns(
205
+ columns,
206
+ cu,
207
+ req.children
208
+ )
209
+ break
210
+ }
211
+ case 'list-lookup':
212
+ req.index = columns.add(
213
+ 'array(' + this.select(field.propType.entity, req.args, req.children, {
214
+ kind: 'list-subquery',
215
+ field: field.propType.field,
216
+ parent: cursor.native('id')
217
+ }) + ')'
218
+ )
219
+ break
220
+ default:
221
+ throw unsupportedCase((field as any).propType.kind)
222
+ }
223
+ }
224
+ }
225
+ }
226
+
227
+ private generateWhere(cursor: Cursor, where: any): string {
228
+ let {AND, OR, ...conditions} = where
229
+ let exps: string[] = []
230
+ for (let key in conditions) {
231
+ let opArg = conditions[key]
232
+ let f = parseWhereField(key)
233
+ switch(f.op) {
234
+ case 'every':
235
+ if (hasConditions(opArg)) {
236
+ let rel = cursor.object.properties[f.field].type
237
+ assert(rel.kind == 'list-lookup')
238
+ let conditionedFrom = this.select(
239
+ rel.entity,
240
+ {where: opArg},
241
+ undefined,
242
+ {kind: 'list-subquery', parent: cursor.native('id'), field: rel.field}
243
+ )
244
+ let allFrom = this.select(
245
+ rel.entity,
246
+ {},
247
+ undefined,
248
+ {kind: 'list-subquery', parent: cursor.native('id'), field: rel.field}
249
+ )
250
+ exps.push(`(SELECT count(*) ${conditionedFrom}) = (SELECT count(*) ${allFrom})`)
251
+ }
252
+ break
253
+ case 'some':
254
+ case 'none':
255
+ let rel = cursor.object.properties[f.field].type
256
+ assert(rel.kind == 'list-lookup')
257
+ let q = '(SELECT true ' + this.select(
258
+ rel.entity,
259
+ {where: opArg},
260
+ undefined,
261
+ {kind: 'list-subquery', parent: cursor.native('id'), field: rel.field}
262
+ ) + ' LIMIT 1)'
263
+ if (f.op == 'some') {
264
+ exps.push(q)
265
+ } else {
266
+ exps.push(`(SELECT count(*) FROM ${q} ${this.ident(this.aliases.add(key))}) = 0`)
267
+ }
268
+ break
269
+ default: {
270
+ let prop = cursor.object.properties[f.field]
271
+ assert(prop != null)
272
+ this.addPropCondition(exps, cursor, f.field, f.op, opArg)
273
+ }
274
+ }
275
+ }
276
+ if (AND) {
277
+ // We are getting objects here, although we have array in schema
278
+ ensureArray(AND).forEach((andWhere: any) => {
279
+ if (hasConditions(andWhere)) {
280
+ exps.push(
281
+ this.generateWhere(cursor, andWhere)
282
+ )
283
+ }
284
+ })
285
+ }
286
+ if (OR) {
287
+ let ors: string[] = []
288
+ if (exps.length) {
289
+ ors.push('(' + exps.join(' AND ') + ')')
290
+ }
291
+ // We are getting objects here, although we have array in schema
292
+ ensureArray(OR).forEach((orWhere: any) => {
293
+ if (hasConditions(orWhere)) {
294
+ ors.push(
295
+ '(' + this.generateWhere(cursor, orWhere) + ')'
296
+ )
297
+ }
298
+ })
299
+ return '(' + ors.join(' OR ') + ')'
300
+ } else {
301
+ return exps.join(' AND ')
302
+ }
303
+ }
304
+
305
+ private addPropCondition(exps: string[], cursor: Cursor, field: string, op: WhereOp, arg: any): void {
306
+ let propType = cursor.object.properties[field].type
307
+ switch(propType.kind) {
308
+ case 'scalar':
309
+ case 'enum': {
310
+ let lhs = cursor.native(field)
311
+ switch(op) {
312
+ case 'in':
313
+ case 'not_in': {
314
+ // We have 2 options here
315
+ // 1. use array parameter and do: WHERE col IN (SELECT * FROM unnest($array_param))
316
+ // 2. use arg list
317
+ // Let's try second option first.
318
+ let list = ensureArray(arg).map(a => this.param(a))
319
+ let param = `(${list.join(', ')})`
320
+ exps.push(`${lhs} ${whereOpToSqlOperator(op)} ${param}`)
321
+ break
322
+ }
323
+ case 'startsWith':
324
+ exps.push(`starts_with(${lhs}, ${this.param(arg)})`)
325
+ break
326
+ case 'not_startsWith':
327
+ exps.push(`NOT starts_with(${lhs}, ${this.param(arg)})`)
328
+ break
329
+ case 'endsWith': {
330
+ let param = this.param(arg)
331
+ exps.push(`right(${lhs}, length(${param})) = ${param}`)
332
+ break
333
+ }
334
+ case 'not_endsWith': {
335
+ let param = this.param(arg)
336
+ exps.push(`right(${lhs}, length(${param})) != ${param}`)
337
+ break
338
+ }
339
+ case 'contains':
340
+ exps.push(`position(${this.param(arg)} in ${lhs}) > 0`)
341
+ break
342
+ case 'not_contains':
343
+ exps.push(`position(${this.param(arg)} in ${lhs}) = 0`)
344
+ break
345
+ default: {
346
+ exps.push(`${lhs} ${whereOpToSqlOperator(op)} ${this.param(arg)}`)
347
+ }
348
+ }
349
+ break
350
+ }
351
+ case 'list': {
352
+ let item = propType.item.type
353
+ assert(item.kind == 'scalar' || item.kind == 'enum')
354
+ let param = this.param(arg)
355
+ let lhs = cursor.native(field)
356
+ switch(op) {
357
+ case 'containsAll':
358
+ exps.push(`${lhs} @> ${param}`)
359
+ break
360
+ case 'containsAny':
361
+ exps.push(`${lhs} && ${param}`)
362
+ break
363
+ case 'containsNone':
364
+ exps.push(`NOT (${lhs} && ${param})`)
365
+ break
366
+ default:
367
+ throw unsupportedCase(op)
368
+ }
369
+ break
370
+ }
371
+ case 'object':
372
+ case 'union': {
373
+ assert(op == '-')
374
+ let cu = cursor.child(field)
375
+ for (let key in arg) {
376
+ let f = parseWhereField(key)
377
+ this.addPropCondition(exps, cu, f.field, f.op, arg[key])
378
+ }
379
+ break
380
+ }
381
+ case 'fk':
382
+ case 'lookup': {
383
+ assert(op == '-')
384
+ if (hasConditions(arg)) {
385
+ exps.push(
386
+ this.generateWhere(cursor.child(field), arg)
387
+ )
388
+ }
389
+ break
390
+ }
391
+ default:
392
+ throw unsupportedCase(propType.kind)
393
+ }
394
+ }
395
+
396
+ toResult(rows: any[][], fields?: RequestedFields): any[] {
397
+ let out: any[] = new Array(rows.length)
398
+ for (let i = 0; i < rows.length; i++) {
399
+ out[i] = this.mapRow(rows[i], fields)
400
+ }
401
+ return out
402
+ }
403
+
404
+ private mapRow(row: any[], fields?: RequestedFields, ifType?: string): any {
405
+ let rec: any = {}
406
+ for (let key in fields) {
407
+ let f = fields[key]
408
+ for (let i = 0; i < f.requests.length; i++) {
409
+ let req = f.requests[i]
410
+ if (req.ifType != ifType) continue
411
+ switch(f.propType.kind) {
412
+ case 'scalar':
413
+ case 'enum':
414
+ case 'list':
415
+ rec[req.alias] = row[req.index]
416
+ break
417
+ case 'object': {
418
+ let isNull = row[req.index]
419
+ if (!isNull) {
420
+ rec[req.alias] = this.mapRow(row, req.children)
421
+ }
422
+ break
423
+ }
424
+ case 'union': {
425
+ let isTypeOf = row[req.index]
426
+ if (isTypeOf != null) {
427
+ let obj = this.mapRow(row, req.children, isTypeOf)
428
+ obj.isTypeOf = isTypeOf
429
+ rec[req.alias] = obj
430
+ }
431
+ break
432
+ }
433
+ case 'fk':
434
+ case 'lookup': {
435
+ let id = row[req.index]
436
+ if (id != null) {
437
+ rec[req.alias] = this.mapRow(row, req.children)
438
+ }
439
+ break
440
+ }
441
+ case 'list-lookup':
442
+ rec[req.alias] = this.toResult(row[req.index], req.children)
443
+ break
444
+ default:
445
+ throw unsupportedCase((f as any).propType.kind)
446
+ }
447
+ }
448
+ }
449
+ return rec
450
+ }
451
+
452
+ async executeSelect(entityName: string, args: ListArgs, fields$: RequestedFields): Promise<any[]> {
453
+ let sql = this.select(entityName, args, fields$)
454
+ let rows = await this.query(sql)
455
+ return this.toResult(rows, fields$)
456
+ }
457
+
458
+ async executeSelectCount(entityName: string, where?: any): Promise<number> {
459
+ let sql = `SELECT count(*) ${this.select(entityName, {where})}`
460
+ let rows = await this.query(sql)
461
+ return toInt(rows[0][0])
462
+ }
463
+
464
+ async executeListCount(entityName: string, args: ListArgs): Promise<number> {
465
+ let sql = `SELECT count(*) FROM (SELECT true ${this.select(entityName, args)}) AS ${this.aliases.add('list')}`
466
+ let rows = await this.query(sql)
467
+ return toInt(rows[0][0])
468
+ }
469
+
470
+ private query(sql: string): Promise<any[][]> {
471
+ return this.db.query(sql, this.params)
472
+ }
473
+
474
+ fulltextSearchSelect(queryName: string, args: any, $fields: FtsRequestedFields): string {
475
+ let query = getFtsQuery(this.model, queryName)
476
+ let {limit, offset, text} = args
477
+ let textParam = this.param(text)
478
+
479
+ let srcSelects: string[] = []
480
+ query.sources.forEach(src => {
481
+ let where = args[`where${src.entity}`]
482
+ let itemFields = $fields.item?.[src.entity]
483
+ let sql = this.select(src.entity, {where}, itemFields, {kind: 'fts', textParam, queryName})
484
+ srcSelects.push(sql)
485
+ })
486
+
487
+ let cols: string[] = []
488
+ cols.push('isTypeOf')
489
+ cols.push('rank')
490
+ if ($fields.highlight) {
491
+ cols.push('highlight')
492
+ }
493
+ if ($fields.item) {
494
+ cols.push('item')
495
+ }
496
+
497
+ let sql = `SELECT ${cols.join(', ')} FROM (\n\n`
498
+ sql += srcSelects.join('\n\nUNION ALL\n\n')
499
+ sql += `\n\n) AS ${this.aliases.add('tsv')}`
500
+ sql += ` ORDER BY rank DESC`
501
+ if (limit != null) {
502
+ sql += ` LIMIT ${this.param(limit)}`
503
+ }
504
+ if (offset != null) {
505
+ sql += ` OFFSET ${this.param(offset)}`
506
+ }
507
+ return sql
508
+ }
509
+
510
+ toFulltextSearchResult(rows: any[][], fields: FtsRequestedFields): FtsItem[] {
511
+ let out: FtsItem[] = new Array(rows.length)
512
+ for (let i = 0; i < rows.length; i++) {
513
+ let row = rows[i]
514
+ let isTypeOf = row[0]
515
+ let highlight = fields.highlight ? row[2] : undefined
516
+ let itemIdx = fields.highlight ? 3 : 2
517
+ let itemFields = fields.item?.[isTypeOf]
518
+ let item: any
519
+ if (itemFields) {
520
+ item = this.mapRow(row[itemIdx], itemFields)
521
+ item.isTypeOf = isTypeOf
522
+ } else {
523
+ item = {isTypeOf}
524
+ }
525
+ out[i] = {
526
+ rank: row[1],
527
+ highlight,
528
+ item
529
+ }
530
+ }
531
+ return out
532
+ }
533
+
534
+ async executeFulltextSearch(queryName: string, args: any, $fields: FtsRequestedFields): Promise<FtsItem[]> {
535
+ let sql = this.fulltextSearchSelect(queryName, args, $fields)
536
+ let rows = await this.query(sql)
537
+ return this.toFulltextSearchResult(rows, $fields)
538
+ }
539
+ }
540
+
541
+
542
+ export interface FtsItem {
543
+ rank?: number
544
+ highlight?: string
545
+ item?: any
546
+ }
547
+
548
+
549
+ type SelectVariant = FtsVariant | ListSubquery
550
+
551
+
552
+ interface FtsVariant {
553
+ kind: 'fts'
554
+ queryName: string
555
+ textParam: string // builder.param(text)
556
+ }
557
+
558
+
559
+ /**
560
+ * SELECT json_build_array(...fields) FROM ... WHERE {toFkColumn(field)} = {parent}
561
+ */
562
+ interface ListSubquery {
563
+ kind: 'list-subquery'
564
+ field: string
565
+ parent: string
566
+ }
567
+
568
+
569
+ /**
570
+ * A pointer to an entity or nested json object within SQL query.
571
+ *
572
+ * It has convenience methods for building various SQL expressions
573
+ * related to individual properties of an entity or an object it points to.
574
+ */
575
+ class Cursor {
576
+ constructor(
577
+ private model: Model,
578
+ private ident: (name: string) => string,
579
+ private aliases: AliasSet,
580
+ private join: JoinSet,
581
+ private name: string,
582
+ public readonly object: Entity | JsonObject,
583
+ private alias: string,
584
+ private prefix: string
585
+ ) {}
586
+
587
+ transport(propName: string): string {
588
+ let prop = this.object.properties[propName]
589
+ switch(prop.type.kind) {
590
+ case 'scalar':
591
+ case 'enum':
592
+ if (this.object.kind == 'object') {
593
+ return fromJsonToOutputCast(prop.type.name, this.prefix, propName)
594
+ } else {
595
+ return toOutputCast(prop.type.name, this.column(propName))
596
+ }
597
+ case 'list':
598
+ let itemType = prop.type.item.type
599
+ if (this.object.kind == 'object' || itemType.kind != 'scalar' && itemType.kind != 'enum') {
600
+ // this is json
601
+ return this.field(propName)
602
+ } else {
603
+ return toOutputArrayCast(itemType.name, this.column(propName))
604
+ }
605
+ default:
606
+ throw unsupportedCase(prop.type.kind)
607
+ }
608
+ }
609
+
610
+ native(propName: string): string {
611
+ let prop = this.object.properties[propName]
612
+ if (prop.type.kind == 'list') {
613
+ let item = prop.type.item.type
614
+ assert(item.kind == 'scalar' || item.kind == 'enum')
615
+ return this.column(propName)
616
+ }
617
+ assert(prop.type.kind == 'scalar' || prop.type.kind == 'enum')
618
+ if (this.object.kind == 'object') {
619
+ return fromJsonCast(prop.type.name, this.prefix, propName)
620
+ } else {
621
+ return this.column(propName)
622
+ }
623
+ }
624
+
625
+ child(propName: string): Cursor {
626
+ let name: string
627
+ let object: Entity | JsonObject
628
+ let alias: string
629
+ let prefix: string
630
+
631
+ let prop = this.object.properties[propName]
632
+ switch(prop.type.kind) {
633
+ case 'object':
634
+ name = prop.type.name
635
+ object = getObject(this.model, name)
636
+ alias = this.alias
637
+ prefix = this.field(propName)
638
+ break
639
+ case 'union':
640
+ name = prop.type.name
641
+ object = getUnionProps(this.model, name)
642
+ alias = this.alias
643
+ prefix = this.field(propName)
644
+ break
645
+ case 'fk':
646
+ name = prop.type.foreignEntity
647
+ object = getEntity(this.model, name)
648
+ alias = this.join.add(
649
+ toTable(name),
650
+ '"id"',
651
+ this.fk(propName)
652
+ )
653
+ prefix = ''
654
+ break
655
+ case 'lookup':
656
+ name = prop.type.entity
657
+ object = getEntity(this.model, name)
658
+ alias = this.join.add(
659
+ toTable(name),
660
+ this.ident(toFkColumn(prop.type.field)),
661
+ this.field('id')
662
+ )
663
+ prefix = ''
664
+ break
665
+ default:
666
+ throw unsupportedCase(prop.type.kind)
667
+ }
668
+
669
+ return new Cursor(
670
+ this.model,
671
+ this.ident,
672
+ this.aliases,
673
+ this.join,
674
+ name,
675
+ object,
676
+ alias,
677
+ prefix
678
+ )
679
+ }
680
+
681
+ field(name: string): string {
682
+ if (this.object.kind == 'entity') {
683
+ return this.column(name)
684
+ } else {
685
+ return `${this.prefix}->'${name}'`
686
+ }
687
+ }
688
+
689
+ private column(name: string) {
690
+ assert(this.object.kind == 'entity')
691
+ return this.ident(this.alias) + '.' + this.ident(toColumn(name))
692
+ }
693
+
694
+ fk(propName: string): string {
695
+ return this.object.kind == 'entity'
696
+ ? this.ident(this.alias) + '.' + this.ident(toFkColumn(propName))
697
+ : fromJsonCast('ID', this.prefix, propName)
698
+ }
699
+
700
+ tsv(queryName: string): string {
701
+ assert(this.object.kind == 'entity')
702
+ return this.ident(this.alias) + '.' + this.ident(toSnakeCase(queryName) + '_tsv')
703
+ }
704
+
705
+ doc(queryName: string): string {
706
+ assert(this.object.kind == 'entity')
707
+ let query = getFtsQuery(this.model, queryName)
708
+ let src = query.sources.find(src => src.entity == this.name)
709
+ assert(src != null)
710
+ return src.fields.map(f => `coalesce(${this.field(f)}, '')`).join(` || E'\\n\\n' || `)
711
+ }
712
+ }
713
+
714
+
715
+ class ColumnSet {
716
+ private columns: Map<string, number> = new Map()
717
+
718
+ add(column: string): number {
719
+ let idx = this.columns.get(column)
720
+ if (idx == null) {
721
+ idx = this.columns.size
722
+ this.columns.set(column, idx)
723
+ }
724
+ return idx
725
+ }
726
+
727
+ render(): string {
728
+ return Array.from(this.columns.keys()).join(', ')
729
+ }
730
+
731
+ size(): number {
732
+ return this.columns.size
733
+ }
734
+ }
735
+
736
+
737
+ /**
738
+ * LEFT OUTER JOIN "{table}" "{alias}" ON "{alias}".{column} = {rhs}
739
+ */
740
+ interface Join {
741
+ table: string
742
+ alias: string
743
+ column: string
744
+ rhs: string
745
+ }
746
+
747
+
748
+ class JoinSet {
749
+ private joins: Map<string, Join> = new Map()
750
+
751
+ constructor(private aliases: AliasSet) {}
752
+
753
+ add(table: string, column: string, rhs: string): string {
754
+ let key = `${table} ${column} ${rhs}`
755
+ let e = this.joins.get(key)
756
+ if (!e) {
757
+ e = {
758
+ table,
759
+ alias: this.aliases.add(table),
760
+ column,
761
+ rhs
762
+ }
763
+ this.joins.set(key, e)
764
+ }
765
+ return e.alias
766
+ }
767
+
768
+ forEach(cb: (join: Join) => void): void {
769
+ this.joins.forEach(join => cb(join))
770
+ }
771
+ }
772
+
773
+
774
+ class AliasSet {
775
+ private aliases: Record<string, number> = {}
776
+
777
+ add(name: string): string {
778
+ if (this.aliases[name]) {
779
+ return name + '_' + (this.aliases[name]++)
780
+ } else {
781
+ this.aliases[name] = 1
782
+ return name
783
+ }
784
+ }
785
+ }