@subsquid/openreader 5.4.0-beta.aa7384 → 5.5.0

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 (46) hide show
  1. package/lib/db.d.ts +3 -1
  2. package/lib/db.d.ts.map +1 -1
  3. package/lib/db.js +3 -2
  4. package/lib/db.js.map +1 -1
  5. package/lib/dialect/opencrud/schema.d.ts.map +1 -1
  6. package/lib/dialect/opencrud/schema.js +15 -1
  7. package/lib/dialect/opencrud/schema.js.map +1 -1
  8. package/lib/dialect/opencrud/tree.d.ts.map +1 -1
  9. package/lib/dialect/opencrud/tree.js +17 -0
  10. package/lib/dialect/opencrud/tree.js.map +1 -1
  11. package/lib/main.js +5 -1
  12. package/lib/main.js.map +1 -1
  13. package/lib/model.d.ts +1 -0
  14. package/lib/model.d.ts.map +1 -1
  15. package/lib/model.schema.d.ts.map +1 -1
  16. package/lib/model.schema.js +25 -1
  17. package/lib/model.schema.js.map +1 -1
  18. package/lib/server.d.ts +4 -2
  19. package/lib/server.d.ts.map +1 -1
  20. package/lib/server.js +6 -3
  21. package/lib/server.js.map +1 -1
  22. package/lib/sql/cursor.d.ts.map +1 -1
  23. package/lib/sql/cursor.js +8 -1
  24. package/lib/sql/cursor.js.map +1 -1
  25. package/lib/test/basic.test.js +1 -0
  26. package/lib/test/basic.test.js.map +1 -1
  27. package/lib/test/disableForeignKeyConstraint.test.d.ts +2 -0
  28. package/lib/test/disableForeignKeyConstraint.test.d.ts.map +1 -0
  29. package/lib/test/disableForeignKeyConstraint.test.js +332 -0
  30. package/lib/test/disableForeignKeyConstraint.test.js.map +1 -0
  31. package/lib/util/util.d.ts +3 -0
  32. package/lib/util/util.d.ts.map +1 -1
  33. package/lib/util/util.js +11 -0
  34. package/lib/util/util.js.map +1 -1
  35. package/package.json +5 -5
  36. package/src/db.ts +4 -1
  37. package/src/dialect/opencrud/schema.ts +20 -2
  38. package/src/dialect/opencrud/tree.ts +18 -1
  39. package/src/main.ts +7 -1
  40. package/src/model.schema.ts +31 -1
  41. package/src/model.ts +1 -0
  42. package/src/server.ts +12 -4
  43. package/src/sql/cursor.ts +8 -2
  44. package/src/test/basic.test.ts +1 -0
  45. package/src/test/disableForeignKeyConstraint.test.ts +363 -0
  46. package/src/util/util.ts +16 -0
package/src/main.ts CHANGED
@@ -29,9 +29,11 @@ GraphQL server for postgres-compatible databases
29
29
  program.option('--max-root-fields <count>', 'max number of root fields in a query', nat)
30
30
  program.option('--max-response-size <nodes>', 'max response size measured in nodes', nat)
31
31
  program.option('--sql-statement-timeout <ms>', 'sql statement timeout in ms', nat)
32
+ program.option('--validation-max-errors <count>', 'max validation errors', nat)
32
33
  program.option('--subscriptions', 'enable gql subscriptions')
33
34
  program.option('--subscription-poll-interval <ms>', 'subscription poll interval in ms', nat, 1000)
34
35
  program.option('--subscription-max-response-size <nodes>', 'max response size measured in nodes', nat)
36
+ program.addOption(new Option('--isolation-level <level>', 'transaction isolation level').choices(['SERIALIZABLE', 'REPEATABLE READ', 'READ COMMITTED']))
35
37
 
36
38
  let opts = program.parse().opts() as {
37
39
  schema: string
@@ -42,9 +44,11 @@ GraphQL server for postgres-compatible databases
42
44
  maxRootFields?: number
43
45
  maxResponseSize?: number
44
46
  sqlStatementTimeout?: number
47
+ validationMaxErrors?: number
45
48
  subscriptions?: boolean
46
49
  subscriptionPollInterval: number
47
50
  subscriptionMaxResponseSize?: number
51
+ isolationLevel?: 'SERIALIZABLE' | 'REPEATABLE READ' | 'READ COMMITTED'
48
52
  }
49
53
 
50
54
  let model = loadModel(opts.schema)
@@ -65,7 +69,9 @@ GraphQL server for postgres-compatible databases
65
69
  maxResponseNodes: opts.maxResponseSize,
66
70
  subscriptions: opts.subscriptions,
67
71
  subscriptionPollInterval: opts.subscriptionPollInterval,
68
- subscriptionMaxResponseNodes: opts.subscriptionMaxResponseSize
72
+ subscriptionMaxResponseNodes: opts.subscriptionMaxResponseSize,
73
+ validationMaxErrors: opts.validationMaxErrors,
74
+ isolationLevel: opts.isolationLevel
69
75
  })
70
76
 
71
77
  LOG.info(`listening on port ${server.port}`)
@@ -32,6 +32,7 @@ const baseSchema = buildASTSchema(parse(`
32
32
  directive @fulltext(query: String!) on FIELD_DEFINITION
33
33
  directive @cardinality(value: Int!) on OBJECT | FIELD_DEFINITION
34
34
  directive @byteWeight(value: Float!) on FIELD_DEFINITION
35
+ directive @disableForeignKeyConstraint on FIELD_DEFINITION
35
36
  directive @variant on OBJECT # legacy
36
37
  directive @jsonField on OBJECT # legacy
37
38
  scalar ID
@@ -132,6 +133,7 @@ function addEntityOrJsonObjectOrInterface(model: Model, type: GraphQLObjectType
132
133
  let derivedFrom = checkDerivedFrom(type, f)
133
134
  let index = checkFieldIndex(type, f)
134
135
  let unique = index?.unique || false
136
+ let fkConstraint = checkDisableForeignKeyConstraint(type, f)
135
137
  let limits = {
136
138
  ...checkByteWeightDirective(type, f),
137
139
  ...checkCardinalityLimitDirective(type, f)
@@ -199,10 +201,14 @@ function addEntityOrJsonObjectOrInterface(model: Model, type: GraphQLObjectType
199
201
  description
200
202
  }
201
203
  } else {
204
+ if (fkConstraint.disableConstraint && !nullable) {
205
+ throw new SchemaError(`Property ${propName} must be nullable when @disableForeignKeyConstraint is applied`)
206
+ }
202
207
  properties[key] = {
203
208
  type: {
204
209
  kind: 'fk',
205
- entity: fieldType.name
210
+ entity: fieldType.name,
211
+ ...fkConstraint
206
212
  },
207
213
  nullable,
208
214
  unique,
@@ -509,6 +515,30 @@ function checkCardinalityLimitDirective(type: GraphQLNamedType, f: GraphQLField<
509
515
  }
510
516
 
511
517
 
518
+ function checkDisableForeignKeyConstraint(type: GraphQLNamedType, f: GraphQLField<any, any>): {disableConstraint?: boolean} {
519
+ let directives = f.astNode?.directives?.filter(d => d.name.value == 'disableForeignKeyConstraint') || []
520
+ if (directives.length == 0) return {}
521
+ if (!isEntityType(type)) throw new SchemaError(
522
+ `@disableForeignKeyConstraint was applied to ${type.name}.${f.name}, but only entity fields can have this directive`
523
+ )
524
+ if (directives.length > 1) throw new SchemaError(
525
+ `Multiple @disableForeignKeyConstraint directives were applied to ${type.name}.${f.name}`
526
+ )
527
+ let fieldType = asNonNull(f)
528
+ let list = unwrapList(fieldType)
529
+ if (list.nulls.length > 0) throw new SchemaError(
530
+ `@disableForeignKeyConstraint was applied to ${type.name}.${f.name}, but list fields cannot have this directive`
531
+ )
532
+ if (!isEntityType(list.item)) throw new SchemaError(
533
+ `@disableForeignKeyConstraint was applied to ${type.name}.${f.name}, but only foreign key fields can have this directive`
534
+ )
535
+ if (f.astNode?.directives?.some(d => d.name.value == 'derivedFrom')) throw new SchemaError(
536
+ `@disableForeignKeyConstraint was applied to ${type.name}.${f.name}, but @derivedFrom fields cannot have this directive`
537
+ )
538
+ return {disableConstraint: true}
539
+ }
540
+
541
+
512
542
  function checkByteWeightDirective(type: GraphQLNamedType, f: GraphQLField<any, any>): {byteWeight?: number} {
513
543
  let directives = f.astNode?.directives?.filter(d => d.name.value == 'byteWeight') || []
514
544
  if (directives.length > 1) throw new SchemaError(
package/src/model.ts CHANGED
@@ -112,6 +112,7 @@ export interface ListPropType {
112
112
  export interface FkPropType {
113
113
  kind: 'fk'
114
114
  entity: Name
115
+ disableConstraint?: boolean
115
116
  }
116
117
 
117
118
 
package/src/server.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import {Logger} from '@subsquid/logger'
2
2
  import {listen, ListeningServer} from '@subsquid/util-internal-http-server'
3
3
  import {KeyValueCache, PluginDefinition} from '@subsquid/apollo-server-core'
4
- import { ApolloServer } from '@subsquid/apollo-server-express';
4
+ import {ApolloServer} from '@subsquid/apollo-server-express'
5
5
  import express from 'express'
6
6
  import fs from 'fs'
7
7
  import {ExecutionArgs, GraphQLSchema} from 'graphql'
@@ -12,7 +12,7 @@ import type {Pool} from 'pg'
12
12
  import {WebSocketServer} from 'ws'
13
13
  import {Context, OpenreaderContext} from './context'
14
14
  import {PoolOpenreaderContext} from './db'
15
- import type {DbType} from './db'
15
+ import type {DbType, TransactionIsolationLevel} from './db'
16
16
  import type {Model} from './model'
17
17
  import {openreaderExecute, openreaderSubscribe} from './util/execute'
18
18
  import {ResponseSizeLimit} from './util/limit'
@@ -34,7 +34,9 @@ export interface ServerOptions {
34
34
  subscriptionPollInterval?: number
35
35
  subscriptionConnection?: Pool
36
36
  subscriptionMaxResponseNodes?: number
37
+ validationMaxErrors?: number
37
38
  cache?: KeyValueCache
39
+ isolationLevel?: TransactionIsolationLevel
38
40
  }
39
41
 
40
42
  export async function serve(options: ServerOptions): Promise<ListeningServer> {
@@ -44,6 +46,7 @@ export async function serve(options: ServerOptions): Promise<ListeningServer> {
44
46
  subscriptionPollInterval,
45
47
  maxResponseNodes,
46
48
  subscriptionMaxResponseNodes,
49
+ validationMaxErrors,
47
50
  log,
48
51
  } = options
49
52
 
@@ -52,12 +55,15 @@ export async function serve(options: ServerOptions): Promise<ListeningServer> {
52
55
  let schemaBuilder = await getSchemaBuilder(options)
53
56
  let schema = schemaBuilder.build()
54
57
 
58
+ let isolationLevel = options.isolationLevel ?? 'SERIALIZABLE'
59
+
55
60
  let context = () => {
56
61
  let openreader: OpenreaderContext = new PoolOpenreaderContext(
57
62
  dbType,
58
63
  connection,
59
64
  subscriptionConnection,
60
65
  subscriptionPollInterval,
66
+ isolationLevel,
61
67
  log
62
68
  )
63
69
 
@@ -85,6 +91,7 @@ export async function serve(options: ServerOptions): Promise<ListeningServer> {
85
91
  subscriptions: options.subscriptions,
86
92
  log: options.log,
87
93
  graphiqlConsole: options.graphiqlConsole,
94
+ validationMaxErrors,
88
95
  maxRequestSizeBytes: options.maxRequestSizeBytes,
89
96
  maxRootFields: options.maxRootFields,
90
97
  cache: options.cache,
@@ -105,8 +112,8 @@ export interface ApolloOptions {
105
112
  graphiqlConsole?: boolean
106
113
  log?: Logger
107
114
  maxRequestSizeBytes?: number
108
- maxRootFields?: number
109
115
  validationMaxErrors?: number
116
+ maxRootFields?: number
110
117
  cache?: KeyValueCache
111
118
  }
112
119
 
@@ -151,6 +158,7 @@ export async function runApollo(options: ApolloOptions): Promise<ListeningServer
151
158
  cache: options.cache,
152
159
  stopOnTerminationSignals: false,
153
160
  allowBatchedHttpRequests: false,
161
+ dangerouslyDisableValidation: false,
154
162
  validateOptions: {
155
163
  maxErrors: validationMaxErrors
156
164
  },
@@ -161,7 +169,7 @@ export async function runApollo(options: ApolloOptions): Promise<ListeningServer
161
169
  rootValue: {},
162
170
  contextValue: req.context,
163
171
  variableValues: req.request.variables,
164
- operationName: req.operationName
172
+ operationName: req.operationName,
165
173
  })
166
174
  },
167
175
  plugins: [
package/src/sql/cursor.ts CHANGED
@@ -4,7 +4,7 @@ import assert from "assert"
4
4
  import {DbType} from "../db"
5
5
  import {Entity, JsonObject, Model, ObjectPropType, Prop, UnionPropType} from "../model"
6
6
  import {getEntity, getFtsQuery, getObject, getUnionProps} from "../model.tools"
7
- import {toColumn, toFkColumn, toTable} from "../util/util"
7
+ import {getFkPropByIdField, toColumn, toFkColumn, toTable} from "../util/util"
8
8
  import {AliasSet, escapeIdentifier, JoinSet} from "./util"
9
9
 
10
10
 
@@ -62,7 +62,13 @@ export class EntityCursor implements Cursor {
62
62
  }
63
63
 
64
64
  prop(field: string): Prop {
65
- return assertNotNull(this.entity.properties[field], `property ${field} is missing`)
65
+ let p = this.entity.properties[field]
66
+ if (p) return p
67
+ let fkProp = getFkPropByIdField(field, this.entity.properties)
68
+ if (fkProp) {
69
+ return {type: {kind: 'scalar', name: 'String'}, nullable: fkProp.nullable}
70
+ }
71
+ return assertNotNull(p, `property ${field} is missing`)
66
72
  }
67
73
 
68
74
  output(field: string): string {
@@ -314,6 +314,7 @@ describe('basic tests', function() {
314
314
  fields: [
315
315
  {description: 'Unique identifier'},
316
316
  {description: 'Related account'},
317
+ {description: 'Related account'},
317
318
  {description: 'Balance'},
318
319
  ]
319
320
  }
@@ -0,0 +1,363 @@
1
+ import expect from 'expect'
2
+ import {parse} from 'graphql'
3
+ import {buildModel, buildSchema, SchemaError} from '../model.schema'
4
+ import {useDatabase, useServer} from './setup'
5
+
6
+
7
+ function model(schema: string) {
8
+ return buildModel(buildSchema(parse(schema)))
9
+ }
10
+
11
+
12
+ describe('@disableForeignKeyConstraint', function () {
13
+ describe('schema validation', function () {
14
+ it('parses valid usage and sets disableConstraint flag', function () {
15
+ let m = model(`
16
+ type Account @entity {
17
+ id: ID!
18
+ }
19
+ type Transfer @entity {
20
+ id: ID!
21
+ from: Account @disableForeignKeyConstraint
22
+ }
23
+ `)
24
+ expect(m.Transfer).toBeDefined()
25
+ expect(m.Transfer.kind).toBe('entity')
26
+ if (m.Transfer.kind === 'entity') {
27
+ let fromProp = m.Transfer.properties.from
28
+ expect(fromProp.type).toEqual({
29
+ kind: 'fk',
30
+ entity: 'Account',
31
+ disableConstraint: true,
32
+ })
33
+ }
34
+ })
35
+
36
+ it('rejects non-nullable FK field', function () {
37
+ expect(() =>
38
+ model(`
39
+ type Account @entity {
40
+ id: ID!
41
+ }
42
+ type Transfer @entity {
43
+ id: ID!
44
+ from: Account! @disableForeignKeyConstraint
45
+ }
46
+ `)
47
+ ).toThrow(SchemaError)
48
+ })
49
+
50
+ it('rejects directive on scalar field', function () {
51
+ expect(() =>
52
+ model(`
53
+ type Account @entity {
54
+ id: ID!
55
+ name: String @disableForeignKeyConstraint
56
+ }
57
+ `)
58
+ ).toThrow(SchemaError)
59
+ })
60
+
61
+ it('rejects directive on enum field', function () {
62
+ expect(() =>
63
+ model(`
64
+ enum Status { ACTIVE INACTIVE }
65
+ type Account @entity {
66
+ id: ID!
67
+ status: Status @disableForeignKeyConstraint
68
+ }
69
+ `)
70
+ ).toThrow(SchemaError)
71
+ })
72
+
73
+ it('rejects directive on list (derivedFrom) field', function () {
74
+ expect(() =>
75
+ model(`
76
+ type Account @entity {
77
+ id: ID!
78
+ transfers: [Transfer!] @derivedFrom(field: "from") @disableForeignKeyConstraint
79
+ }
80
+ type Transfer @entity {
81
+ id: ID!
82
+ from: Account
83
+ }
84
+ `)
85
+ ).toThrow(SchemaError)
86
+ })
87
+
88
+ it('rejects directive on @derivedFrom lookup field', function () {
89
+ expect(() =>
90
+ model(`
91
+ type Account @entity {
92
+ id: ID!
93
+ profile: Profile @derivedFrom(field: "account") @disableForeignKeyConstraint
94
+ }
95
+ type Profile @entity {
96
+ id: ID!
97
+ account: Account! @unique
98
+ }
99
+ `)
100
+ ).toThrow(SchemaError)
101
+ })
102
+
103
+ it('rejects directive on non-entity type', function () {
104
+ expect(() =>
105
+ model(`
106
+ type Account @entity {
107
+ id: ID!
108
+ }
109
+ type Metadata {
110
+ ref: Account @disableForeignKeyConstraint
111
+ }
112
+ type Transfer @entity {
113
+ id: ID!
114
+ meta: Metadata
115
+ }
116
+ `)
117
+ ).toThrow(SchemaError)
118
+ })
119
+ })
120
+
121
+ describe('runtime (opencrud)', function () {
122
+ // Database setup: transfer table has from_id and to_id columns
123
+ // with NO foreign key constraints. Some IDs reference accounts
124
+ // that don't exist, simulating @disableForeignKeyConstraint behavior.
125
+ useDatabase([
126
+ `create table account (id text primary key, name text)`,
127
+ `create table transfer (id text primary key, from_id text, to_id text, amount numeric)`,
128
+ // account '1' exists, account '2' exists, but account 'missing' does NOT exist
129
+ `insert into account (id, name) values ('1', 'Alice')`,
130
+ `insert into account (id, name) values ('2', 'Bob')`,
131
+ `insert into transfer (id, from_id, to_id, amount) values ('t1', '1', '2', 100)`,
132
+ `insert into transfer (id, from_id, to_id, amount) values ('t2', '2', 'missing', 50)`,
133
+ `insert into transfer (id, from_id, to_id, amount) values ('t3', 'missing', '1', 75)`,
134
+ `insert into transfer (id, from_id, to_id, amount) values ('t4', null, '1', 25)`,
135
+ ])
136
+
137
+ const client = useServer(`
138
+ type Account @entity {
139
+ id: ID!
140
+ name: String
141
+ transfersFrom: [Transfer!] @derivedFrom(field: "from")
142
+ transfersTo: [Transfer!] @derivedFrom(field: "to")
143
+ }
144
+
145
+ type Transfer @entity {
146
+ id: ID!
147
+ from: Account @disableForeignKeyConstraint
148
+ to: Account @disableForeignKeyConstraint
149
+ amount: Int!
150
+ }
151
+ `)
152
+
153
+ it('returns null for FK relation when referenced row is missing, but exposes raw ID via {field}Id', function () {
154
+ return client.test(`
155
+ query {
156
+ transfers(orderBy: [id_ASC]) {
157
+ id
158
+ from { id name }
159
+ fromId
160
+ to { id name }
161
+ toId
162
+ amount
163
+ }
164
+ }
165
+ `, {
166
+ transfers: [
167
+ {id: 't1', from: {id: '1', name: 'Alice'}, fromId: '1', to: {id: '2', name: 'Bob'}, toId: '2', amount: 100},
168
+ {id: 't2', from: {id: '2', name: 'Bob'}, fromId: '2', to: null, toId: 'missing', amount: 50},
169
+ {id: 't3', from: null, fromId: 'missing', to: {id: '1', name: 'Alice'}, toId: '1', amount: 75},
170
+ {id: 't4', from: null, fromId: null, to: {id: '1', name: 'Alice'}, toId: '1', amount: 25},
171
+ ]
172
+ })
173
+ })
174
+
175
+ it('can query only {field}Id without the relation', function () {
176
+ return client.test(`
177
+ query {
178
+ transfers(orderBy: [id_ASC]) {
179
+ id
180
+ fromId
181
+ toId
182
+ }
183
+ }
184
+ `, {
185
+ transfers: [
186
+ {id: 't1', fromId: '1', toId: '2'},
187
+ {id: 't2', fromId: '2', toId: 'missing'},
188
+ {id: 't3', fromId: 'missing', toId: '1'},
189
+ {id: 't4', fromId: null, toId: '1'},
190
+ ]
191
+ })
192
+ })
193
+
194
+ it('supports {field}Id_eq filter', function () {
195
+ return client.test(`
196
+ query {
197
+ transfers(where: {fromId_eq: "missing"}, orderBy: [id_ASC]) {
198
+ id
199
+ fromId
200
+ }
201
+ }
202
+ `, {
203
+ transfers: [
204
+ {id: 't3', fromId: 'missing'},
205
+ ]
206
+ })
207
+ })
208
+
209
+ it('supports {field}Id_not_eq filter', function () {
210
+ return client.test(`
211
+ query {
212
+ transfers(where: {fromId_not_eq: "missing"}, orderBy: [id_ASC]) {
213
+ id
214
+ fromId
215
+ }
216
+ }
217
+ `, {
218
+ transfers: [
219
+ {id: 't1', fromId: '1'},
220
+ {id: 't2', fromId: '2'},
221
+ ]
222
+ })
223
+ })
224
+
225
+ it('supports {field}Id_in filter', function () {
226
+ return client.test(`
227
+ query {
228
+ transfers(where: {fromId_in: ["1", "missing"]}, orderBy: [id_ASC]) {
229
+ id
230
+ fromId
231
+ }
232
+ }
233
+ `, {
234
+ transfers: [
235
+ {id: 't1', fromId: '1'},
236
+ {id: 't3', fromId: 'missing'},
237
+ ]
238
+ })
239
+ })
240
+
241
+ it('supports {field}Id_not_in filter', function () {
242
+ return client.test(`
243
+ query {
244
+ transfers(where: {fromId_not_in: ["1", "missing"]}, orderBy: [id_ASC]) {
245
+ id
246
+ fromId
247
+ }
248
+ }
249
+ `, {
250
+ transfers: [
251
+ {id: 't2', fromId: '2'},
252
+ ]
253
+ })
254
+ })
255
+
256
+ it('supports {field}Id_isNull filter', function () {
257
+ return client.test(`
258
+ query {
259
+ transfers(where: {fromId_isNull: true}, orderBy: [id_ASC]) {
260
+ id
261
+ fromId
262
+ }
263
+ }
264
+ `, {
265
+ transfers: [
266
+ {id: 't4', fromId: null},
267
+ ]
268
+ })
269
+ })
270
+
271
+ it('supports {field}Id_contains filter', function () {
272
+ return client.test(`
273
+ query {
274
+ transfers(where: {fromId_contains: "iss"}, orderBy: [id_ASC]) {
275
+ id
276
+ fromId
277
+ }
278
+ }
279
+ `, {
280
+ transfers: [
281
+ {id: 't3', fromId: 'missing'},
282
+ ]
283
+ })
284
+ })
285
+
286
+ it('supports {field}Id_startsWith filter', function () {
287
+ return client.test(`
288
+ query {
289
+ transfers(where: {fromId_startsWith: "mis"}, orderBy: [id_ASC]) {
290
+ id
291
+ fromId
292
+ }
293
+ }
294
+ `, {
295
+ transfers: [
296
+ {id: 't3', fromId: 'missing'},
297
+ ]
298
+ })
299
+ })
300
+
301
+ it('supports {field}Id_endsWith filter', function () {
302
+ return client.test(`
303
+ query {
304
+ transfers(where: {toId_endsWith: "2"}, orderBy: [id_ASC]) {
305
+ id
306
+ toId
307
+ }
308
+ }
309
+ `, {
310
+ transfers: [
311
+ {id: 't1', toId: '2'},
312
+ ]
313
+ })
314
+ })
315
+
316
+ it('supports {field}Id_gt and {field}Id_lt filters', function () {
317
+ return client.test(`
318
+ query {
319
+ transfers(where: {fromId_gt: "1", fromId_lt: "missing"}, orderBy: [id_ASC]) {
320
+ id
321
+ fromId
322
+ }
323
+ }
324
+ `, {
325
+ transfers: [
326
+ {id: 't2', fromId: '2'},
327
+ ]
328
+ })
329
+ })
330
+
331
+ it('can combine {field}Id filter with other filters', function () {
332
+ return client.test(`
333
+ query {
334
+ transfers(where: {fromId_eq: "2", amount_gt: 40}, orderBy: [id_ASC]) {
335
+ id
336
+ fromId
337
+ amount
338
+ }
339
+ }
340
+ `, {
341
+ transfers: [
342
+ {id: 't2', fromId: '2', amount: 50},
343
+ ]
344
+ })
345
+ })
346
+
347
+ it('can combine {field}Id filter with relation filter in OR', function () {
348
+ return client.test(`
349
+ query {
350
+ transfers(where: {OR: [{fromId_eq: "missing"}, {from: {name_eq: "Alice"}}]}, orderBy: [id_ASC]) {
351
+ id
352
+ fromId
353
+ }
354
+ }
355
+ `, {
356
+ transfers: [
357
+ {id: 't1', fromId: '1'},
358
+ {id: 't3', fromId: 'missing'},
359
+ ]
360
+ })
361
+ })
362
+ })
363
+ })
package/src/util/util.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {toSnakeCase} from "@subsquid/util-naming"
2
2
  import assert from "assert"
3
+ import {Prop} from "../model"
3
4
 
4
5
 
5
6
  export function toColumn(gqlFieldName: string): string {
@@ -37,3 +38,18 @@ export function invalidFormat(type: string, value: string): Error {
37
38
  export function identity<T>(x: T): T {
38
39
  return x
39
40
  }
41
+
42
+
43
+ export function toFkIdField(fkFieldName: string): string {
44
+ return fkFieldName + 'Id'
45
+ }
46
+
47
+
48
+ export function getFkPropByIdField(
49
+ idFieldName: string,
50
+ properties: Record<string, Prop>
51
+ ): Prop | undefined {
52
+ if (!idFieldName.endsWith('Id')) return undefined
53
+ let fkProp = properties[idFieldName.slice(0, -2)]
54
+ return fkProp?.type.kind == 'fk' ? fkProp : undefined
55
+ }