@platformatic/sql-graphql 0.7.0 → 0.9.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.
package/index.js CHANGED
@@ -16,6 +16,7 @@ async function mapperToGraphql (app, opts) {
16
16
  const loaders = {}
17
17
  const federationReplacements = []
18
18
  const relations = []
19
+ const ignore = opts.ignore || {}
19
20
 
20
21
  const graphOpts = {
21
22
  queryTopFields,
@@ -35,8 +36,11 @@ async function mapperToGraphql (app, opts) {
35
36
  }
36
37
  } else {
37
38
  for (const entity of Object.values(mapper.entities)) {
39
+ if (ignore[entity.pluralName] === true) {
40
+ continue
41
+ }
38
42
  relations.push(...entity.relations)
39
- const meta = constructGraph(app, entity, graphOpts)
43
+ const meta = constructGraph(app, entity, graphOpts, ignore[entity.pluralName] || {})
40
44
  metaMap.set(entity, meta)
41
45
  }
42
46
 
@@ -89,10 +93,23 @@ async function mapperToGraphql (app, opts) {
89
93
 
90
94
  let subscription
91
95
  if (app.platformatic.mq) {
92
- const entitiesList = Object.keys(app.platformatic.entities)
93
- const ignoreList = opts.subscriptionIgnore || []
96
+ // Create a copy because we will alter it
97
+ const ignoreList = Array.from(opts.subscriptionIgnore || [])
98
+
99
+ const entitiesList = []
100
+
101
+ for (const entity of Object.values(app.platformatic.entities)) {
102
+ if (ignoreList.includes(entity.singularName)) {
103
+ continue
104
+ }
105
+ // We currently do not support subscriptions for join tables
106
+ if (entity.primaryKeys.size > 1) {
107
+ continue
108
+ }
109
+ entitiesList.push(entity)
110
+ }
94
111
 
95
- if (entitiesList.length - ignoreList.length > 0) {
112
+ if (entitiesList.length > 0) {
96
113
  opts.subscription = {
97
114
  emitter: app.platformatic.mq
98
115
  }
@@ -15,8 +15,13 @@ const ascDesc = new graphql.GraphQLEnumType({
15
15
  }
16
16
  })
17
17
 
18
- function constructGraph (app, entity, opts) {
19
- const primaryKey = camelcase(entity.primaryKey)
18
+ const limitType = new graphql.GraphQLScalarType({
19
+ name: 'LimitInt',
20
+ description: 'Limit will be applied by default if not passed. If the provided value exceeds the maximum allowed value a validation error will be thrown'
21
+ })
22
+
23
+ function constructGraph (app, entity, opts, ignore) {
24
+ const primaryKeys = Array.from(entity.primaryKeys).map((key) => camelcase(key))
20
25
  const relationalFields = entity.relations
21
26
  .map((relation) => relation.column_name)
22
27
  const entityName = entity.name
@@ -35,6 +40,10 @@ function constructGraph (app, entity, opts) {
35
40
 
36
41
  for (const key of Object.keys(entity.fields)) {
37
42
  const field = entity.fields[key]
43
+ if (ignore[field.camelcase]) {
44
+ continue
45
+ }
46
+
38
47
  const meta = { field }
39
48
 
40
49
  // sqlite doesn't support enums
@@ -66,7 +75,12 @@ function constructGraph (app, entity, opts) {
66
75
  resolvers.Mutation = resolvers.Mutation || {}
67
76
  loaders.Query = loaders.Query || {}
68
77
 
69
- const getBy = camelcase(['get', singular, 'by', primaryKey])
78
+ const getBy = camelcase(['get', singular, ...(primaryKeys.map((key, i) => {
79
+ if (i === 0) {
80
+ return ['by', key]
81
+ }
82
+ return ['and', key]
83
+ }).flat())].join('_'))
70
84
 
71
85
  const whereArgType = new graphql.GraphQLInputObjectType({
72
86
  name: `${entityName}WhereArguments`,
@@ -81,6 +95,7 @@ function constructGraph (app, entity, opts) {
81
95
  gte: { type: fields[field].type },
82
96
  lt: { type: fields[field].type },
83
97
  lte: { type: fields[field].type },
98
+ like: { type: fields[field].type },
84
99
  in: { type: new graphql.GraphQLList(fields[field].type) },
85
100
  nin: { type: new graphql.GraphQLList(fields[field].type) }
86
101
  }
@@ -93,14 +108,21 @@ function constructGraph (app, entity, opts) {
93
108
  queryTopFields[getBy] = {
94
109
  type,
95
110
  args: {
96
- [primaryKey]: { type: new graphql.GraphQLNonNull(fields[primaryKey].type) }
111
+ ...(primaryKeys.reduce((acc, primaryKey) => {
112
+ acc[primaryKey] = { type: new graphql.GraphQLNonNull(fields[primaryKey].type) }
113
+ return acc
114
+ }, {}))
97
115
  }
98
116
  }
99
117
  loaders.Query[getBy] = {
100
118
  loader (queries, ctx) {
101
119
  const keys = []
102
120
  for (const query of queries) {
103
- keys.push(query.params[primaryKey])
121
+ const pairs = []
122
+ for (const key of primaryKeys) {
123
+ pairs.push({ key, value: query.params[key] })
124
+ }
125
+ keys.push(pairs)
104
126
  }
105
127
  return loadMany(keys, queries, ctx)
106
128
  },
@@ -121,7 +143,7 @@ function constructGraph (app, entity, opts) {
121
143
  queryTopFields[plural] = {
122
144
  type: new graphql.GraphQLList(type),
123
145
  args: {
124
- limit: { type: graphql.GraphQLInt },
146
+ limit: { type: limitType },
125
147
  offset: { type: graphql.GraphQLInt },
126
148
  orderBy: {
127
149
  type: new graphql.GraphQLList(new graphql.GraphQLInputObjectType({
@@ -138,7 +160,9 @@ function constructGraph (app, entity, opts) {
138
160
 
139
161
  resolvers.Query[plural] = (_, query, ctx, info) => {
140
162
  const requestedFields = info.fieldNodes[0].selectionSet.selections.map((s) => s.name.value)
141
- requestedFields.push(primaryKey)
163
+ for (const primaryKey of primaryKeys) {
164
+ requestedFields.push(primaryKey)
165
+ }
142
166
  return entity.find({ ...query, fields: [...requestedFields, ...relationalFields], ctx })
143
167
  }
144
168
 
@@ -172,7 +196,9 @@ function constructGraph (app, entity, opts) {
172
196
 
173
197
  resolvers.Query[count] = async (_, query, ctx, info) => {
174
198
  const requestedFields = info.fieldNodes[0].selectionSet.selections.map((s) => s.name.value)
175
- requestedFields.push(primaryKey)
199
+ for (const primaryKey of primaryKeys) {
200
+ requestedFields.push(primaryKey)
201
+ }
176
202
  const total = await entity.count({ ...query, fields: [...requestedFields, ...relationalFields], ctx })
177
203
  return { total }
178
204
  }
@@ -220,14 +246,21 @@ function constructGraph (app, entity, opts) {
220
246
 
221
247
  federationReplacements.push({
222
248
  find: new RegExp(`type ${entityName}`),
223
- replace: `type ${entityName} @key(fields: "${primaryKey}")`
249
+ replace: `type ${entityName} @key(fields: "${primaryKeys}")`
224
250
  })
225
251
 
226
252
  if (federationMetadata) {
227
253
  loaders[entityName] = loaders[entityName] || {}
228
254
  loaders[entityName].__resolveReference = {
229
255
  loader (queries, ctx) {
230
- const keys = queries.map(({ obj }) => obj[primaryKey])
256
+ const keys = []
257
+ for (const { obj } of queries) {
258
+ const pairs = []
259
+ for (const key of primaryKeys) {
260
+ pairs.push({ key, value: obj[key] })
261
+ }
262
+ keys.push(pairs)
263
+ }
231
264
  return loadMany(keys, queries, ctx)
232
265
  },
233
266
  opts: {
@@ -246,25 +279,47 @@ function constructGraph (app, entity, opts) {
246
279
 
247
280
  async function loadMany (keys, queries, ctx) {
248
281
  const fields = getFields(queries)
249
- const res = await entity.find({
250
- where: {
251
- [primaryKey]: {
252
- in: keys
282
+
283
+ // TODO this is inefficient as it might load
284
+ // more data than needed if there are more than
285
+ // one primary key
286
+ const where = keys.reduce((acc, pairs) => {
287
+ pairs.reduce((acc, { key, value }) => {
288
+ if (acc[key]) {
289
+ acc[key].in.push(value)
290
+ } else {
291
+ acc[key] = {
292
+ in: [value]
293
+ }
253
294
  }
254
- },
295
+ return acc
296
+ }, acc)
297
+ return acc
298
+ }, {})
299
+
300
+ const res = await entity.find({
301
+ where,
255
302
  fields,
256
303
  ctx
257
304
  })
258
305
 
259
- const map = {}
260
-
261
- for (const row of res) {
262
- map[row[primaryKey]] = row
263
- }
264
-
265
306
  const output = []
266
- for (const key of keys) {
267
- output.push(map[key])
307
+ // TODO this is extremely inefficient
308
+ // we need a better data structure
309
+ for (const pair of keys) {
310
+ for (const row of res) {
311
+ let target = row
312
+ for (const { key, value } of pair) {
313
+ if (row[key] !== value) {
314
+ target = null
315
+ break
316
+ }
317
+ }
318
+
319
+ if (target) {
320
+ output.push(target)
321
+ }
322
+ }
268
323
  }
269
324
 
270
325
  return output
@@ -272,7 +327,9 @@ function constructGraph (app, entity, opts) {
272
327
 
273
328
  function getFields (queries) {
274
329
  const fields = new Set([...relationalFields])
275
- fields.add(primaryKey)
330
+ for (const primaryKey of primaryKeys) {
331
+ fields.add(primaryKey)
332
+ }
276
333
  for (const query of queries) {
277
334
  fromSelectionSet(query.info.fieldNodes[0].selectionSet, fields)
278
335
  }
@@ -15,7 +15,7 @@ module.exports = function establishRelations (app, relations, resolvers, loaders
15
15
  const entity = entities[key]
16
16
  tablesTypeMap[entity.table] = metaMap.get(entity)
17
17
  }
18
- for (const { table_name, foreign_table_name, column_name } of relations) {
18
+ for (const { table_name, foreign_table_name, column_name, foreign_column_name } of relations) {
19
19
  const enhanceAssertLogMsg = `(table: "${table_name}", foreign table: "${foreign_table_name}", column: "${column_name}")`
20
20
 
21
21
  assert(table_name, `table_name is required ${enhanceAssertLogMsg}`)
@@ -34,11 +34,13 @@ module.exports = function establishRelations (app, relations, resolvers, loaders
34
34
  const originalField = camelcase(column_name)
35
35
  delete current.fields[originalField]
36
36
  loaders[current.type] = loaders[current.type] || resolvers[current.type] || {}
37
+ const key = camelcase(foreign_column_name)
37
38
  loaders[current.type][lowered] = {
38
39
  loader (queries, ctx) {
39
- const keys = queries.map(({ obj }) => {
40
- return obj[originalField]
41
- })
40
+ const keys = []
41
+ for (const { obj } of queries) {
42
+ keys.push([{ key, value: obj[originalField].toString() }])
43
+ }
42
44
  return foreign.loadMany(keys, queries, ctx)
43
45
  },
44
46
  opts: {
@@ -48,8 +50,9 @@ module.exports = function establishRelations (app, relations, resolvers, loaders
48
50
  }
49
51
  }
50
52
 
51
- // foreign to current
52
- {
53
+ // foreign to current, we skip this if the current table has a composite primary key
54
+ // TODO implement support for this case
55
+ if (current.entity.primaryKeys.size === 1) {
53
56
  const lowered = lowerCaseFirst(camelcase(current.entity.table))
54
57
  if (!relationships[foreign.type] || relationships[foreign.type][lowered] !== false) {
55
58
  foreign.fields[lowered] = queryTopFields[lowered]
@@ -9,6 +9,14 @@ function setupSubscriptions (app, metaMap, resolvers, ignores) {
9
9
  if (ignores.indexOf(field.singularName) >= 0) {
10
10
  continue
11
11
  }
12
+ // TODO currently we are not supporting subscriptions for
13
+ // entities that have a composite primary key
14
+ /* istanbul ignore next */
15
+ if (field.primaryKeys.size !== 1) {
16
+ continue
17
+ }
18
+ const entity = app.platformatic.entities[field.singularName]
19
+ const primaryKey = entity.primaryKeys.values().next().value
12
20
  const { type } = meta
13
21
  const saved = `${field.singularName}Saved`
14
22
  fields[saved] = {
@@ -18,11 +26,9 @@ function setupSubscriptions (app, metaMap, resolvers, ignores) {
18
26
  subscribe: async function * (_, query, ctx, info) {
19
27
  const log = ctx.reply.request.log
20
28
  const { pubsub } = ctx
21
- const entity = app.platformatic.entities[field.singularName]
22
29
  const topic = await entity.getSubscriptionTopic({ action: 'save', ctx })
23
30
  log.trace({ topic }, 'subscribed')
24
31
  const res = await pubsub.subscribe(topic)
25
- const primaryKey = entity.primaryKey
26
32
  const fields = meta.getFields([{ info }])
27
33
 
28
34
  for await (const msg of res) {
@@ -55,8 +61,8 @@ function setupSubscriptions (app, metaMap, resolvers, ignores) {
55
61
  type: new graphql.GraphQLObjectType({
56
62
  name: `${field.name}Deleted`,
57
63
  fields: {
58
- [field.primaryKey]: {
59
- type: meta.fields[field.primaryKey].type
64
+ [primaryKey]: {
65
+ type: meta.fields[primaryKey].type
60
66
  }
61
67
  }
62
68
  })
@@ -64,7 +70,6 @@ function setupSubscriptions (app, metaMap, resolvers, ignores) {
64
70
  resolvers.Subscription[deleted] = {
65
71
  subscribe: async (_, query, ctx, info) => {
66
72
  const { pubsub } = ctx
67
- const entity = app.platformatic.entities[field.singularName]
68
73
  const topic = await entity.getSubscriptionTopic({ action: 'delete', ctx })
69
74
  const res = await pubsub.subscribe(topic)
70
75
  ctx.reply.request.log.trace({ topic }, 'subscribed')
package/lib/utils.js CHANGED
@@ -54,6 +54,8 @@ function sqlTypeToGraphQL (sqlType) {
54
54
  return graphql.GraphQLString
55
55
  case 'json':
56
56
  return GraphQLJSONObject
57
+ case 'jsonb':
58
+ return GraphQLJSONObject
57
59
  default:
58
60
  return graphql.GraphQLString
59
61
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/sql-graphql",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Map SQL dbs to GraphQL",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -14,13 +14,13 @@
14
14
  },
15
15
  "homepage": "https://github.com/platformatic/platformatic#readme",
16
16
  "devDependencies": {
17
- "@platformatic/sql-events": "0.7.0",
18
- "@platformatic/sql-mapper": "0.7.0",
17
+ "@platformatic/sql-events": "0.9.0",
18
+ "@platformatic/sql-mapper": "0.9.0",
19
19
  "fastify": "^4.6.0",
20
20
  "snazzy": "^9.0.0",
21
21
  "standard": "^17.0.0",
22
22
  "tap": "^16.0.0",
23
- "tsd": "^0.24.0",
23
+ "tsd": "^0.25.0",
24
24
  "ws": "^8.9.0"
25
25
  },
26
26
  "dependencies": {
@@ -0,0 +1,474 @@
1
+ 'use strict'
2
+
3
+ const { clear, connInfo, isSQLite, isMysql, isPg } = require('./helper')
4
+ const { test } = require('tap')
5
+ const fastify = require('fastify')
6
+ const sqlMapper = require('@platformatic/sql-mapper')
7
+ const sqlEvents = require('@platformatic/sql-events')
8
+ const sqlGraphQL = require('..')
9
+
10
+ test('composite primary keys', async ({ equal, same, teardown, rejects }) => {
11
+ /* https://github.com/platformatic/platformatic/issues/299 */
12
+ async function onDatabaseLoad (db, sql) {
13
+ await clear(db, sql)
14
+
15
+ if (isSQLite) {
16
+ await db.query(sql`CREATE TABLE pages (
17
+ id INTEGER PRIMARY KEY,
18
+ the_title VARCHAR(42)
19
+ );`)
20
+
21
+ await db.query(sql`CREATE TABLE users (
22
+ id INTEGER PRIMARY KEY,
23
+ username VARCHAR(255) NOT NULL
24
+ );`)
25
+
26
+ await db.query(sql`CREATE TABLE editors (
27
+ page_id INTEGER NOT NULL,
28
+ user_id INTEGER NOT NULL,
29
+ role VARCHAR(255) NOT NULL,
30
+ CONSTRAINT fk_editor_pages FOREIGN KEY (page_id) REFERENCES pages(id),
31
+ CONSTRAINT fk_editor_users FOREIGN KEY (user_id) REFERENCES users(id),
32
+ PRIMARY KEY (page_id, user_id)
33
+ );`)
34
+ } else if (isPg) {
35
+ await db.query(sql`CREATE TABLE pages (
36
+ id SERIAL PRIMARY KEY,
37
+ the_title VARCHAR(255) NOT NULL
38
+ );`)
39
+
40
+ await db.query(sql`CREATE TABLE users (
41
+ id SERIAL PRIMARY KEY,
42
+ username VARCHAR(255) NOT NULL
43
+ );`)
44
+
45
+ await db.query(sql`CREATE TABLE editors (
46
+ page_id INTEGER NOT NULL,
47
+ user_id INTEGER NOT NULL,
48
+ role VARCHAR(255) NOT NULL,
49
+ CONSTRAINT fk_editor_pages FOREIGN KEY (page_id) REFERENCES pages(id),
50
+ CONSTRAINT fk_editor_users FOREIGN KEY (user_id) REFERENCES users(id),
51
+ PRIMARY KEY (page_id, user_id)
52
+ );`)
53
+ } else if (isMysql) {
54
+ await db.query(sql`CREATE TABLE pages (
55
+ id INTEGER PRIMARY KEY AUTO_INCREMENT,
56
+ the_title VARCHAR(255) NOT NULL
57
+ );`)
58
+
59
+ await db.query(sql`CREATE TABLE users (
60
+ id INTEGER PRIMARY KEY AUTO_INCREMENT,
61
+ username VARCHAR(255) NOT NULL
62
+ );`)
63
+
64
+ await db.query(sql`CREATE TABLE editors (
65
+ page_id INTEGER NOT NULL,
66
+ user_id INTEGER NOT NULL,
67
+ role VARCHAR(255) NOT NULL,
68
+ CONSTRAINT \`fk_editor_pages\` FOREIGN KEY (page_id) REFERENCES pages (id) ON DELETE CASCADE ON UPDATE RESTRICT,
69
+ CONSTRAINT \`fk_editor_users\` FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ON UPDATE RESTRICT,
70
+ PRIMARY KEY (page_id, user_id)
71
+ );`)
72
+ }
73
+ }
74
+
75
+ const app = fastify()
76
+ app.register(sqlMapper, {
77
+ ...connInfo,
78
+ onDatabaseLoad
79
+ })
80
+ app.register(sqlEvents) // needed as if it's present it might throw
81
+ app.register(sqlGraphQL)
82
+ teardown(app.close.bind(app))
83
+
84
+ await app.ready()
85
+
86
+ {
87
+ const res = await app.inject({
88
+ method: 'POST',
89
+ url: '/graphql',
90
+ body: {
91
+ query: `
92
+ mutation {
93
+ savePage(input: { theTitle: "foobar" }) {
94
+ id
95
+ theTitle
96
+ }
97
+ }
98
+ `
99
+ }
100
+ })
101
+ equal(res.statusCode, 200, 'savePage status code')
102
+ same(res.json(), {
103
+ data: {
104
+ savePage: {
105
+ id: 1,
106
+ theTitle: 'foobar'
107
+ }
108
+ }
109
+ }, 'savePage response')
110
+ }
111
+
112
+ {
113
+ const res = await app.inject({
114
+ method: 'POST',
115
+ url: '/graphql',
116
+ body: {
117
+ query: `
118
+ mutation {
119
+ saveUser(input: { username: "mcollina" }) {
120
+ id
121
+ username
122
+ }
123
+ }
124
+ `
125
+ }
126
+ })
127
+ equal(res.statusCode, 200, 'saveUser status code')
128
+ same(res.json(), {
129
+ data: {
130
+ saveUser: {
131
+ id: 1,
132
+ username: 'mcollina'
133
+ }
134
+ }
135
+ }, 'saveUser response')
136
+ }
137
+
138
+ {
139
+ const res = await app.inject({
140
+ method: 'POST',
141
+ url: '/graphql',
142
+ body: {
143
+ query: `
144
+ mutation {
145
+ saveUser(input: { username: "lucamaraschi" }) {
146
+ id
147
+ username
148
+ }
149
+ }
150
+ `
151
+ }
152
+ })
153
+ equal(res.statusCode, 200, 'saveUser status code')
154
+ same(res.json(), {
155
+ data: {
156
+ saveUser: {
157
+ id: 2,
158
+ username: 'lucamaraschi'
159
+ }
160
+ }
161
+ }, 'saveUser response')
162
+ }
163
+
164
+ {
165
+ const res = await app.inject({
166
+ method: 'POST',
167
+ url: '/graphql',
168
+ body: {
169
+ query: `
170
+ mutation {
171
+ saveEditor(input: { userId: "1", pageId: "1", role: "admin" }) {
172
+ user {
173
+ id
174
+ username
175
+ }
176
+ page {
177
+ id
178
+ theTitle
179
+ }
180
+ role
181
+ }
182
+ }
183
+ `
184
+ }
185
+ })
186
+ equal(res.statusCode, 200, 'saveEditor status code')
187
+ same(res.json(), {
188
+ data: {
189
+ saveEditor: {
190
+ user: {
191
+ id: 1,
192
+ username: 'mcollina'
193
+ },
194
+ page: {
195
+ id: 1,
196
+ theTitle: 'foobar'
197
+ },
198
+ role: 'admin'
199
+ }
200
+ }
201
+ }, 'saveEditor response')
202
+ }
203
+
204
+ {
205
+ const res = await app.inject({
206
+ method: 'POST',
207
+ url: '/graphql',
208
+ body: {
209
+ query: `
210
+ mutation {
211
+ saveEditor(input: { userId: "2", pageId: "1", role: "author" }) {
212
+ user {
213
+ id
214
+ username
215
+ }
216
+ page {
217
+ id
218
+ theTitle
219
+ }
220
+ role
221
+ }
222
+ }
223
+ `
224
+ }
225
+ })
226
+ equal(res.statusCode, 200, 'saveEditor status code')
227
+ same(res.json(), {
228
+ data: {
229
+ saveEditor: {
230
+ user: {
231
+ id: 2,
232
+ username: 'lucamaraschi'
233
+ },
234
+ page: {
235
+ id: 1,
236
+ theTitle: 'foobar'
237
+ },
238
+ role: 'author'
239
+ }
240
+ }
241
+ }, 'saveEditor response')
242
+ }
243
+
244
+ {
245
+ const res = await app.inject({
246
+ method: 'POST',
247
+ url: '/graphql',
248
+ body: {
249
+ query: `
250
+ mutation {
251
+ saveEditor(input: { userId: "1", pageId: "1", role: "captain" }) {
252
+ user {
253
+ id
254
+ username
255
+ }
256
+ page {
257
+ id
258
+ theTitle
259
+ }
260
+ role
261
+ }
262
+ }
263
+ `
264
+ }
265
+ })
266
+ equal(res.statusCode, 200, 'saveEditor status code')
267
+ same(res.json(), {
268
+ data: {
269
+ saveEditor: {
270
+ user: {
271
+ id: 1,
272
+ username: 'mcollina'
273
+ },
274
+ page: {
275
+ id: 1,
276
+ theTitle: 'foobar'
277
+ },
278
+ role: 'captain'
279
+ }
280
+ }
281
+ }, 'saveEditor response')
282
+ }
283
+
284
+ {
285
+ const res = await app.inject({
286
+ method: 'post',
287
+ url: '/graphql',
288
+ body: {
289
+ query: `
290
+ query {
291
+ editors(orderBy: { field: role, direction: DESC }) {
292
+ user {
293
+ id
294
+ username
295
+ }
296
+ page {
297
+ id
298
+ theTitle
299
+ }
300
+ role
301
+ }
302
+ }
303
+ `
304
+ }
305
+ })
306
+ equal(res.statusCode, 200, 'editors status code')
307
+ same(res.json(), {
308
+ data: {
309
+ editors: [{
310
+ user: {
311
+ id: '1',
312
+ username: 'mcollina'
313
+ },
314
+ page: {
315
+ id: '1',
316
+ theTitle: 'foobar'
317
+ },
318
+ role: 'captain'
319
+ }, {
320
+ user: {
321
+ id: '2',
322
+ username: 'lucamaraschi'
323
+ },
324
+ page: {
325
+ id: '1',
326
+ theTitle: 'foobar'
327
+ },
328
+ role: 'author'
329
+ }]
330
+ }
331
+ }, 'editor response')
332
+ }
333
+ })
334
+
335
+ test('composite primary keys with no foreign keys', async ({ equal, same, teardown, rejects }) => {
336
+ /* https://github.com/platformatic/platformatic/issues/299 */
337
+ async function onDatabaseLoad (db, sql) {
338
+ await clear(db, sql)
339
+
340
+ await db.query(sql`CREATE TABLE editors (
341
+ page_id INTEGER NOT NULL,
342
+ user_id INTEGER NOT NULL,
343
+ role VARCHAR(255) NOT NULL,
344
+ PRIMARY KEY (page_id, user_id)
345
+ );`)
346
+ }
347
+
348
+ const app = fastify()
349
+ app.register(sqlMapper, {
350
+ ...connInfo,
351
+ onDatabaseLoad
352
+ })
353
+ app.register(sqlEvents) // needed as if it's present it will throw
354
+ app.register(sqlGraphQL)
355
+ teardown(app.close.bind(app))
356
+
357
+ await app.ready()
358
+
359
+ {
360
+ const res = await app.inject({
361
+ method: 'POST',
362
+ url: '/graphql',
363
+ body: {
364
+ query: `
365
+ mutation {
366
+ saveEditor(input: { userId: "1", pageId: "1", role: "admin" }) {
367
+ userId
368
+ pageId
369
+ role
370
+ }
371
+ }
372
+ `
373
+ }
374
+ })
375
+ equal(res.statusCode, 200, 'saveEditor status code')
376
+ same(res.json(), {
377
+ data: {
378
+ saveEditor: {
379
+ userId: '1',
380
+ pageId: '1',
381
+ role: 'admin'
382
+ }
383
+ }
384
+ }, 'saveEditor response')
385
+ }
386
+
387
+ {
388
+ const res = await app.inject({
389
+ method: 'POST',
390
+ url: '/graphql',
391
+ body: {
392
+ query: `
393
+ mutation {
394
+ saveEditor(input: { userId: "2", pageId: "1", role: "author" }) {
395
+ userId
396
+ pageId
397
+ role
398
+ }
399
+ }
400
+ `
401
+ }
402
+ })
403
+ equal(res.statusCode, 200, 'saveEditor status code')
404
+ same(res.json(), {
405
+ data: {
406
+ saveEditor: {
407
+ userId: '2',
408
+ pageId: '1',
409
+ role: 'author'
410
+ }
411
+ }
412
+ }, 'saveEditor response')
413
+ }
414
+
415
+ {
416
+ const res = await app.inject({
417
+ method: 'POST',
418
+ url: '/graphql',
419
+ body: {
420
+ query: `
421
+ mutation {
422
+ saveEditor(input: { userId: "1", pageId: "1", role: "captain" }) {
423
+ userId
424
+ pageId
425
+ role
426
+ }
427
+ }
428
+ `
429
+ }
430
+ })
431
+ equal(res.statusCode, 200, 'saveEditor status code')
432
+ same(res.json(), {
433
+ data: {
434
+ saveEditor: {
435
+ userId: 1,
436
+ pageId: 1,
437
+ role: 'captain'
438
+ }
439
+ }
440
+ }, 'saveEditor response')
441
+ }
442
+
443
+ {
444
+ const res = await app.inject({
445
+ method: 'post',
446
+ url: '/graphql',
447
+ body: {
448
+ query: `
449
+ query {
450
+ editors(orderBy: { field: role, direction: DESC }) {
451
+ userId
452
+ pageId
453
+ role
454
+ }
455
+ }
456
+ `
457
+ }
458
+ })
459
+ equal(res.statusCode, 200, 'editors status code')
460
+ same(res.json(), {
461
+ data: {
462
+ editors: [{
463
+ userId: '1',
464
+ pageId: '1',
465
+ role: 'captain'
466
+ }, {
467
+ userId: '2',
468
+ pageId: '1',
469
+ role: 'author'
470
+ }]
471
+ }
472
+ }, 'editor response')
473
+ }
474
+ })
package/test/helper.js CHANGED
@@ -30,6 +30,11 @@ if (!process.env.DB || process.env.DB === 'postgresql') {
30
30
  module.exports.connInfo = connInfo
31
31
 
32
32
  module.exports.clear = async function (db, sql) {
33
+ try {
34
+ await db.query(sql`DROP TABLE editors`)
35
+ } catch (err) {
36
+ }
37
+
33
38
  try {
34
39
  await db.query(sql`DROP TABLE pages`)
35
40
  } catch (err) {
@@ -115,3 +115,93 @@ test('ignore a column', async ({ pass, teardown, equal }) => {
115
115
  equal(Category.fields.find((f) => f.name === 'name'), undefined, 'name column is ignored')
116
116
  }
117
117
  })
118
+
119
+ test('ignore a table via sql-graphql option', async ({ pass, teardown, equal }) => {
120
+ const app = fastify()
121
+ app.register(sqlMapper, {
122
+ ...connInfo,
123
+ async onDatabaseLoad (db, sql) {
124
+ pass('onDatabaseLoad called')
125
+
126
+ await clear(db, sql)
127
+ await createBasicPages(db, sql)
128
+ }
129
+ })
130
+ app.register(sqlGraphQL, {
131
+ ignore: {
132
+ categories: true
133
+ }
134
+ })
135
+ teardown(app.close.bind(app))
136
+
137
+ await app.ready()
138
+
139
+ {
140
+ const res = await app.inject({
141
+ method: 'POST',
142
+ url: '/graphql',
143
+ body: {
144
+ query: `
145
+ query {
146
+ __schema {
147
+ types {
148
+ name
149
+ }
150
+ }
151
+ }
152
+ `
153
+ }
154
+ })
155
+ equal(res.statusCode, 200, 'introspection query status code')
156
+ const data = res.json().data
157
+ equal(data.__schema.types.find((t) => t.name === 'Category'), undefined, 'Category type is ignored')
158
+ }
159
+ })
160
+
161
+ test('ignore a column via sql-graphql option', async ({ pass, teardown, equal }) => {
162
+ const app = fastify()
163
+ app.register(sqlMapper, {
164
+ ...connInfo,
165
+ async onDatabaseLoad (db, sql) {
166
+ pass('onDatabaseLoad called')
167
+
168
+ await clear(db, sql)
169
+ await createBasicPages(db, sql)
170
+ }
171
+ })
172
+ app.register(sqlGraphQL, {
173
+ ignore: {
174
+ categories: {
175
+ name: true
176
+ }
177
+ }
178
+ })
179
+ teardown(app.close.bind(app))
180
+
181
+ await app.ready()
182
+
183
+ {
184
+ const res = await app.inject({
185
+ method: 'POST',
186
+ url: '/graphql',
187
+ body: {
188
+ query: `
189
+ query {
190
+ __schema {
191
+ types {
192
+ name
193
+ fields {
194
+ name
195
+ }
196
+ }
197
+ }
198
+ }
199
+ `
200
+ }
201
+ })
202
+ equal(res.statusCode, 200, 'introspection query status code')
203
+ const data = res.json().data
204
+ const Category = data.__schema.types.find((t) => t.name === 'Category')
205
+ equal(Category.fields.find((f) => f.name === 'name'), undefined, 'name column is ignored')
206
+ }
207
+ })
@@ -4,7 +4,7 @@ const { test } = require('tap')
4
4
  const sqlGraphQL = require('..')
5
5
  const sqlMapper = require('@platformatic/sql-mapper')
6
6
  const fastify = require('fastify')
7
- const { clear, connInfo, isSQLite, isMysql, isMariaDB } = require('./helper')
7
+ const { clear, connInfo, isSQLite, isMysql, isMariaDB, isPg } = require('./helper')
8
8
 
9
9
  async function createBasicPages (db, sql) {
10
10
  if (isSQLite) {
@@ -901,3 +901,59 @@ test('deserialize JSON columns', { skip: isSQLite }, async (t) => {
901
901
  same(json.data.getPageById.metadata, jsonData)
902
902
  }
903
903
  })
904
+
905
+ test('deserialize JSONB columns', { skip: !isPg }, async (t) => {
906
+ const { pass, teardown, same } = t
907
+ const app = fastify()
908
+ const jsonData = {
909
+ foo: 'bar',
910
+ baz: 42,
911
+ items: ['foo', 'bar'],
912
+ nested: {
913
+ hello: 'world'
914
+ }
915
+ }
916
+ app.register(sqlMapper, {
917
+ ...connInfo,
918
+ async onDatabaseLoad (db, sql) {
919
+ pass('onDatabaseLoad called')
920
+
921
+ await clear(db, sql)
922
+
923
+ await db.query(sql`CREATE TABLE pages (
924
+ id SERIAL PRIMARY KEY,
925
+ title VARCHAR(255) NOT NULL,
926
+ metadata JSONB NOT NULL
927
+ );`)
928
+
929
+ await db.query(sql`INSERT INTO pages (id, title, metadata) VALUES (1, 'Hello World', ${JSON.stringify(jsonData)})`)
930
+ }
931
+ })
932
+ app.register(sqlGraphQL, {
933
+ graphiql: false
934
+ })
935
+ teardown(app.close.bind(app))
936
+
937
+ await app.ready()
938
+ const res = await app.inject({
939
+ method: 'POST',
940
+ url: '/graphql',
941
+ body: {
942
+ query: `
943
+ query {
944
+ getPageById(id: 1) {
945
+ id
946
+ title
947
+ metadata
948
+ }
949
+ }
950
+ `
951
+ }
952
+ })
953
+ const json = res.json()
954
+ if (isMariaDB) {
955
+ same(json.data.getPageById.metadata, JSON.stringify(jsonData))
956
+ } else {
957
+ same(json.data.getPageById.metadata, jsonData)
958
+ }
959
+ })
@@ -33,7 +33,7 @@ function createWebSocketClient (t, app) {
33
33
  return { client, ws }
34
34
  }
35
35
 
36
- test('subscription - crud', { only: true }, async t => {
36
+ test('subscription - crud', async t => {
37
37
  const app = Fastify()
38
38
  t.teardown(() => app.close())
39
39
 
@@ -812,3 +812,291 @@ test('delete all', async ({ pass, teardown, same, equal }) => {
812
812
  }, 'posts response')
813
813
  }
814
814
  })
815
+
816
+ test('like', async ({ pass, teardown, same, equal }) => {
817
+ const app = fastify()
818
+ app.register(sqlMapper, {
819
+ ...connInfo,
820
+ async onDatabaseLoad (db, sql) {
821
+ pass('onDatabaseLoad called')
822
+
823
+ await clear(db, sql)
824
+
825
+ if (isSQLite) {
826
+ await db.query(sql`CREATE TABLE posts (
827
+ id INTEGER PRIMARY KEY,
828
+ title VARCHAR(42),
829
+ long_text TEXT,
830
+ counter INTEGER
831
+ );`)
832
+ } else {
833
+ await db.query(sql`CREATE TABLE posts (
834
+ id SERIAL PRIMARY KEY,
835
+ title VARCHAR(42),
836
+ long_text TEXT,
837
+ counter INTEGER
838
+ );`)
839
+ }
840
+ }
841
+ })
842
+ app.register(sqlGraphQL)
843
+ teardown(app.close.bind(app))
844
+
845
+ await app.ready()
846
+
847
+ const posts = [{
848
+ title: 'Dog',
849
+ longText: 'The dog barks',
850
+ counter: 10
851
+ }, {
852
+ title: 'Cat',
853
+ longText: 'The cat meows',
854
+ counter: 20
855
+ }, {
856
+ title: 'Mouse',
857
+ longText: 'The mouse likes cheese',
858
+ counter: 30
859
+ }, {
860
+ title: 'Duck',
861
+ longText: 'A duck tale',
862
+ counter: 40
863
+ }]
864
+
865
+ await app.inject({
866
+ method: 'POST',
867
+ url: '/graphql',
868
+ body: {
869
+ query: `
870
+ mutation batch($inputs : [PostInput]!) {
871
+ insertPosts(inputs: $inputs) {
872
+ id
873
+ title
874
+ }
875
+ }
876
+ `,
877
+ variables: {
878
+ inputs: posts
879
+ }
880
+ }
881
+ })
882
+
883
+ {
884
+ const res = await app.inject({
885
+ method: 'POST',
886
+ url: '/graphql',
887
+ body: {
888
+ query: `
889
+ query {
890
+ posts {
891
+ id
892
+ title
893
+ longText
894
+ counter
895
+ }
896
+ }
897
+ `
898
+ }
899
+ })
900
+ equal(res.statusCode, 200, 'posts status code')
901
+ same(res.json(), {
902
+ data: {
903
+ posts: [{
904
+ id: '1',
905
+ title: 'Dog',
906
+ longText: 'The dog barks',
907
+ counter: 10
908
+ }, {
909
+ id: '2',
910
+ title: 'Cat',
911
+ longText: 'The cat meows',
912
+ counter: 20
913
+ }, {
914
+ id: '3',
915
+ title: 'Mouse',
916
+ longText: 'The mouse likes cheese',
917
+ counter: 30
918
+ }, {
919
+ id: '4',
920
+ title: 'Duck',
921
+ longText: 'A duck tale',
922
+ counter: 40
923
+ }]
924
+ }
925
+ }, 'posts response')
926
+ }
927
+
928
+ {
929
+ const res = await app.inject({
930
+ method: 'POST',
931
+ url: '/graphql',
932
+ body: {
933
+ query: `
934
+ query {
935
+ posts(where: { longText: { like: "The%" } }) {
936
+ id
937
+ title
938
+ longText
939
+ counter
940
+ }
941
+ }
942
+ `
943
+ }
944
+ })
945
+ equal(res.statusCode, 200, 'where: { longText: { like: "The%" } } status code')
946
+ same(res.json(), {
947
+ data: {
948
+ posts: [{
949
+ id: '1',
950
+ title: 'Dog',
951
+ longText: 'The dog barks',
952
+ counter: 10
953
+ }, {
954
+ id: '2',
955
+ title: 'Cat',
956
+ longText: 'The cat meows',
957
+ counter: 20
958
+ }, {
959
+ id: '3',
960
+ title: 'Mouse',
961
+ longText: 'The mouse likes cheese',
962
+ counter: 30
963
+ }]
964
+ }
965
+ }, 'where: { longText: { like: "The%" } } response')
966
+ }
967
+
968
+ {
969
+ const res = await app.inject({
970
+ method: 'POST',
971
+ url: '/graphql',
972
+ body: {
973
+ query: `
974
+ query {
975
+ posts(where: { longText: { like: "%s" } }) {
976
+ id
977
+ title
978
+ longText
979
+ counter
980
+ }
981
+ }
982
+ `
983
+ }
984
+ })
985
+ equal(res.statusCode, 200, 'where: { longText: { like: "%s" } } status code')
986
+ same(res.json(), {
987
+ data: {
988
+ posts: [{
989
+ id: '1',
990
+ title: 'Dog',
991
+ longText: 'The dog barks',
992
+ counter: 10
993
+ }, {
994
+ id: '2',
995
+ title: 'Cat',
996
+ longText: 'The cat meows',
997
+ counter: 20
998
+ }]
999
+ }
1000
+ }, 'where: { longText: { like: "%s" } } response')
1001
+ }
1002
+
1003
+ {
1004
+ const res = await app.inject({
1005
+ method: 'POST',
1006
+ url: '/graphql',
1007
+ body: {
1008
+ query: `
1009
+ query {
1010
+ posts(where: { longText: { like: "%o%" } }) {
1011
+ id
1012
+ title
1013
+ longText
1014
+ counter
1015
+ }
1016
+ }
1017
+ `
1018
+ }
1019
+ })
1020
+ equal(res.statusCode, 200, 'where: { longText: { like: "%o%" } } status code')
1021
+ same(res.json(), {
1022
+ data: {
1023
+ posts: [{
1024
+ id: '1',
1025
+ title: 'Dog',
1026
+ longText: 'The dog barks',
1027
+ counter: 10
1028
+ }, {
1029
+ id: '2',
1030
+ title: 'Cat',
1031
+ longText: 'The cat meows',
1032
+ counter: 20
1033
+ }, {
1034
+ id: '3',
1035
+ title: 'Mouse',
1036
+ longText: 'The mouse likes cheese',
1037
+ counter: 30
1038
+ }]
1039
+ }
1040
+ }, 'where: { longText: { like: "%o%" } } response')
1041
+ }
1042
+
1043
+ {
1044
+ const res = await app.inject({
1045
+ method: 'POST',
1046
+ url: '/graphql',
1047
+ body: {
1048
+ query: `
1049
+ query {
1050
+ posts(where: { counter: { like: 10 } }) {
1051
+ id
1052
+ title
1053
+ longText
1054
+ counter
1055
+ }
1056
+ }
1057
+ `
1058
+ }
1059
+ })
1060
+ equal(res.statusCode, 200, 'where: { counter: { like: 10 } } status code')
1061
+ same(res.json(), {
1062
+ data: {
1063
+ posts: [{
1064
+ id: '1',
1065
+ title: 'Dog',
1066
+ longText: 'The dog barks',
1067
+ counter: 10
1068
+ }]
1069
+ }
1070
+ }, 'where: { counter: { like: 10 } } response')
1071
+ }
1072
+
1073
+ {
1074
+ const res = await app.inject({
1075
+ method: 'POST',
1076
+ url: '/graphql',
1077
+ body: {
1078
+ query: `
1079
+ query {
1080
+ posts(where: { counter: { like: 40 } }) {
1081
+ id
1082
+ title
1083
+ longText
1084
+ counter
1085
+ }
1086
+ }
1087
+ `
1088
+ }
1089
+ })
1090
+ equal(res.statusCode, 200, 'where: { counter: { like: 40 } } status code')
1091
+ same(res.json(), {
1092
+ data: {
1093
+ posts: [{
1094
+ id: '4',
1095
+ title: 'Duck',
1096
+ longText: 'A duck tale',
1097
+ counter: 40
1098
+ }]
1099
+ }
1100
+ }, 'where: { counter: { like: 40 } } response')
1101
+ }
1102
+ })