graphql-rails-api 0.8.0 → 0.9.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 529885203fa8337d28c94ac45e07cedc82c9393dc671c1dca5ab2880c3904013
4
- data.tar.gz: 214d0d3c85fb42b6d1a7e50cbe3214f169a71240cd3df9fde43120a71c9d826e
3
+ metadata.gz: 2e7801279a80cf36fceb00b2c734cda2ff89e0c695fa77efa10e706e10fb708b
4
+ data.tar.gz: e0fa94077d38b530f56617ccf14825f330d427c510bf44259f43baf2d28ffc8a
5
5
  SHA512:
6
- metadata.gz: a5d2d9873c382edee93417c8dbb63265c08594b98f1461f3b35de18518c12584f568d5c6fad6d30f0e4f067d2e119a66d588d13b99302043bc460c7a22d8236e
7
- data.tar.gz: 8804df58777ba615ece39a245a72457ea0a52db7d22a99411e7c02abfa3de97fd63f60f1d9ecf6f1d3d9fdcf8e464e7c5e6e0e415c3de1ad010cf149687a93e1
6
+ metadata.gz: 45940578253a9645e325860234d793dc8c00c7fdb69c5758748f45fe66d09c32f08e43d60b3180537953dead3f2b5edf470fe2516cf40a5ed0a237124cd3df8e
7
+ data.tar.gz: 3f830d57e8ae131cae74735ba29ea24e3a8b2391899aea329b18aec18fc3b75647e3d085201211c514fd6d397dba1739ae0471f0ea906e0650c6214fbcf429ee
data/README.md CHANGED
@@ -1,48 +1,87 @@
1
- # GraphqlRailsApi
1
+ # Graphql Rails Api
2
+ `graphql-rails-api` is a wrapper around [graphql-ruby](https://graphql-ruby.org/) for rails application. It describes easily your graphql API in a domain driven design way.
2
3
 
3
- `graphql-rails-api` is a gem that provides generators to describe easily your graphql API in a domain driven design way.
4
+ The main purpose of this gem is to earn time providing:
5
+ - A normalized code architecture by mapping graphql resources to the active record models
6
+ - A global graphql service to directly perform crud mutations
7
+ - A set of generators to create or modify graphql resource and active record model at the same time
4
8
 
5
- Need any help or wanna talk with me about it on slack ? Don't hesitate,
6
- https://bit.ly/2KvV8Pk
9
+ ### Notes
10
+ Only postgresql adapter is maintained.
11
+ Only model using uuid as identifier are compatible with generated migrations right now.
12
+ A model User will be created during installation.
7
13
 
8
14
  ## Installation
9
15
 
10
- Create a rails app via
11
- ```
12
- rails new project-name --api --database=postgresql
13
- cd project-name
14
- bundle
15
- rails db:create
16
- ```
17
-
18
- Add these lines to your application's Gemfile:
16
+ Add the gem to your application's Gemfile:
19
17
  ```ruby
20
- gem 'graphql'
21
18
  gem 'graphql-rails-api'
22
19
  ```
23
20
 
24
- And then execute:
21
+ Download and install the gem:
25
22
  ```bash
26
23
  $ bundle
27
24
  $ rails generate graphql_rails_api:install
28
25
  ```
29
26
 
27
+ ### Options
28
+ The following options to the `graphql_rails_api:install` command are available:
29
+
30
+
30
31
  To disable PostgreSQL uuid extension, add the option `--no-pg-uuid`
31
32
 
32
33
  To disable ActionCable websocket subscriptions, add the option `--no-action-cable-subs`
33
34
 
34
35
  To disable Apollo compatibility, add the option `--no-apollo-compatibility`
35
36
 
36
- Automatically, `post '/graphql', to: 'graphql#execute'` will be added to your `config/route.rb`
37
- To avoid this, you can just add the option `--no-generate-graphql-route`
37
+ To avoid the addition of a new post '/graphql' route , add the option `--no-generate-graphql-route`
38
38
 
39
- # Usage
39
+ ## Get Started
40
40
 
41
- ## Resources generation
41
+ Generate a new active record model with its graphql type and input type.
42
+ ```bash
43
+ $ rails generate graphql_resource city name:string
44
+ ```
45
+ Reboot the rails server, and you're good to go!
42
46
 
47
+ Now You can perform crud mutation on resources:
43
48
  ```bash
44
- $ rails generate graphql_resource resource_name field1:string field2:float belongs_to:other_resource_name has_many:other_resources_name many_to_many:other_resources_name
49
+ curl -X POST http://localhost:3000/graphql \
50
+ -H "content-type: application/json" \
51
+ -d '{ "query":"mutation($name: String) { create_city(city: { name: $name }) { name } }", "variables": {"name":"Paris"} }'
52
+
53
+ => {"data":{"create_city":{"name":"Paris"}}}
45
54
  ```
55
+ You can perform queries as well:
56
+ ```bash
57
+ curl -X POST http://localhost:3000/graphql \
58
+ -H "content-type: application/json" \
59
+ -d '{ "query": "{ cities { name } }" }'
60
+
61
+ => {"data":{"cities":[ {"name":"Paris"} ] } }
62
+ ```
63
+
64
+ ## Generators
65
+
66
+ ```bash
67
+ $ rails generate graphql_resource computer code:string price:integer power_bench:float belongs_to:user has_many:hard_drives many_to_many:tags
68
+ ```
69
+
70
+
71
+ This line will create the data migration, the model and the graphql type of the Computer resource.
72
+
73
+ It will automatically add `has_many :computers` to the User model
74
+
75
+ It will add a `computer_id` to the `HardDrive` model, and
76
+
77
+ respectively the `has_many :hard_drives` and `belongs_to :computer` to the `Computer` and `HardDrive` models.
78
+
79
+ The `many_to_many` option will make the `has_many through` association and create the join table between tag and computer.
80
+
81
+ All of these relations will be propagated to the graphql types.
82
+
83
+ ### Options
84
+
46
85
 
47
86
  To disable migration generation, add the option `--no-migration`
48
87
 
@@ -56,40 +95,236 @@ To disable graphql-type generation, add the option `--no-graphql-type`
56
95
 
57
96
  To disable graphql-input-type generation, add the option `--no-graphql-input-type`
58
97
 
59
- To disable propagation (has_many creating the id in the other table, many to many creating the join table, and apply to the graphql types), add the option `--no-propagation`
60
-
61
- I made the choice of migrate automatically after generating a resource, to avoid doing it each time.
62
- You can of course disable the automatic migrate by adding the option `--no-migrate`
98
+ To disable propagation (has_many creating the id in the other table, many to many creating the join table and apply to the graphql types), add the option `--no-propagation`
99
+ To avoid running migrations after a resource generation, add the option `--no-migrate`
63
100
 
64
- ## On generating resources
65
101
 
102
+ ### Note on enum
103
+ The library handle enum with an integer column in the model table. Enum is defined into the active record model.
104
+ Example:
66
105
  ```bash
67
- $ rails generate graphql_resource computer code:string price:integer power_bench:float belongs_to:user has_many:hard_drives many_to_many:tags
106
+ $ rails generate graphql_resource house energy_grade:integer belongs_to:user belongs_to:city
107
+ ```
108
+ house.rb
109
+ ```ruby
110
+ class House < ApplicationRecord
111
+ belongs_to :city
112
+ belongs_to :user
113
+ enum energy_grade: {
114
+ good: 0,
115
+ average: 1,
116
+ bad: 2,
117
+ }
118
+ end
68
119
  ```
69
120
 
70
- This line will create the data migration, the model and the graphql type of the Computer resource.
121
+ ## About queries
122
+ 3 types of queries are available.
71
123
 
72
- It will automatically add `has_many :computers` to the User model
124
+ show query:
125
+ ```gql
126
+ query($id: String!) {
127
+ city(id: $id) {
128
+ id
129
+ }
130
+ }
131
+ ```
132
+ index query:
133
+ A max number of 1000 results are return.
134
+ ```gql
135
+ query($page: String, per_page: String, $filter: String, $order_by: String) {
136
+ cities {
137
+ id
138
+ }
139
+ }
140
+ ```
141
+ index query with pagination: Add the suffix `paginated_` to any index query to use pagination
142
+ ```gql
143
+ query($page: String, per_page: String, $filter: String, $order_by: String) {
144
+ paginated_cities {
145
+ id
146
+ }
147
+ }
148
+ ```
73
149
 
74
- It will add a `computer_id` to the `HardDrive` model, and
75
- respectively the `has_many :hard_drives` and `belongs_to :computer` to the `Computer` and `HardDrive` models.
150
+ ### Filter query results
151
+ `filter` is a not required string argument used to filter data based on conditions made on active record model fields.
152
+ You can use :
153
+ - parenthesis : `()`
154
+ - Logical operators : `&&` , `||`
155
+ - Comparaisons operators : `==`, `!=`, `===`, `!==`, `>`, `<`, `>=`, `<=`
76
156
 
77
- The `many_to_many` option will make the `has_many through` association and create the join table between tag and computer.
157
+ The operators `===` and `!==` are used to perform case sensitive comparaisons on string fields
158
+ Example:
159
+ The following model is generated
160
+ ```
161
+ rails generate graphql_resource house \
162
+ street:string \
163
+ number:integer \
164
+ price:float \
165
+ energy_grade:integer \
166
+ principal:boolean \
167
+ belongs_to:user \
168
+ belongs_to:city
169
+ ```
170
+ The following filter values can be used:
171
+ ```ruby
172
+ "street != 'candlewood lane'"
173
+ "street !== 'Candlewood Lane'"
174
+ "number <= 50"
175
+ "price != 50000"
176
+ "build_at <= '#{DateTime.now - 2.years}'"
177
+ "user.email == 'jason@gmail.com'"
178
+ "city.name != 'Berlin'"
179
+ "street != 'candlewood lane' && (city.name != 'Berlin' || user.email == 'jason@gmail.com')"
180
+ ```
78
181
 
79
- All of these relations will be propagated to the graphql types.
182
+ ### Order query result argument
183
+ `order_by` is a non mandatory string argument used to order the returned data.
184
+ With the model above the following order_by values can be used:
185
+ ```ruby
186
+ "street DESC"
187
+ "number ASC"
188
+ "user.email ASC"
189
+ ```
190
+ ## About mutations
191
+ The graphql-rails-api application service can handle 5 types of mutation on generated models.
192
+
193
+ create mutation:
194
+ ```gql
195
+ mutation($name: String!) {
196
+ create_city(
197
+ city: {
198
+ name: $name
199
+ }
200
+ ) {
201
+ id
202
+ }
203
+ }
204
+ ```
80
205
 
206
+ update mutation:
207
+ ```gql
208
+ mutation($id: String!, $name: String) {
209
+ update_city(
210
+ id: $id
211
+ city: {
212
+ name: $name
213
+ }
214
+ ) {
215
+ id
216
+ name
217
+ }
218
+ }
219
+ ```
81
220
 
221
+ destroy mutation:
222
+ ```gql
223
+ mutation($id: String!) {
224
+ destroy_city(id: $id) {
225
+ id
226
+ }
227
+ }
228
+ ```
82
229
 
83
- ## Graphql API example
230
+ bulk_create mutation:
231
+ ```gql
232
+ mutation($cities: [CityInputType]!) {
233
+ bulk_create_city(cities: $cities) {
234
+ id
235
+ }
236
+ }
237
+ ```
84
238
 
85
- Example of a backend API: https://github.com/Poilon/graphql-rails-api-example
239
+ bulk_update:
240
+ ```gql
241
+ mutation($cities: [CityInputType]!) {
242
+ bulk_update_city(cities: $cities) {
243
+ id
244
+ }
245
+ }
246
+ ```
86
247
 
248
+ You can override the default application service for all mutation by defining your own method into the corresponding graphql service:
87
249
 
250
+ Example:
251
+
252
+ app/graphql/cities/service.rb
253
+ ```ruby
254
+ module Cities
255
+ class Service < ApplicationService
256
+ def create
257
+ return graphql_error('Forbidden') if params[:name] == 'Forbidden city'
258
+
259
+ super
260
+ end
261
+ end
262
+ end
263
+ ```
264
+ ## Custom mutation resource services
265
+
266
+ To defined your own custom mutation create a file to defined the mutation type and define the correponding methods.
267
+
268
+ Example:
269
+
270
+ app/graphql/cities/mutations/custom.rb
271
+ ```ruby
272
+ Cities::Mutations::Custom = GraphQL::Field.define do
273
+ description 'Im a custom mutation'
274
+ type Cities::Type
275
+
276
+ argument :id, !types.String
277
+ argument :name, !types.String
278
+
279
+ resolve ApplicationService.call(:city, :custom)
280
+ end
281
+ ```
282
+
283
+ app/graphql/cities/service.rb
284
+ ```ruby
285
+ module Cities
286
+ class Service < ApplicationService
287
+ def custom
288
+ ...
289
+ end
290
+ end
291
+ end
292
+ ```
293
+
294
+ ## TODO
295
+
296
+ - Clean error management : catching on raise ... no unexpected json blabla
297
+ - Query type
298
+ - remove me type
299
+ - add paginated_resource types
300
+ - Logging on generators actions
301
+ - Don't create model user if it exists
302
+ - Config variable for max number of result (Per page on query)
303
+ - Handle id type != uuid (generated migration)
304
+ - Filter:
305
+ - handle association multiple level
306
+ - Order
307
+ - handle order on multiple field
308
+ - handle order on association multiple level
309
+ - case sensitivity on order by string / text
310
+ - Write Spec to test visible_for
311
+ - Write Spec to test filter order and visible for together
312
+ - Write spec to test installation and generators
313
+ - Documentation about user authentication and scope
314
+ - describe authenticated_user
315
+ - describe visible_for
316
+ - describe writable_by
317
+
318
+ ## Graphql API example
319
+
320
+ Example of a backend API: https://github.com/Poilon/graphql-rails-api-example
88
321
 
89
322
  ## Contributing
90
323
 
91
324
  You can post any issue, and contribute by making pull requests.
92
325
  Don't hesitate, even the shortest pull request is great help. <3
93
326
 
327
+ Need any help or wanna talk with me on discord : Poilon#5412
328
+
94
329
  ## License
95
330
  The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
@@ -245,10 +245,10 @@ t.#{@id_db_type} :#{resource.underscore.singularize}_id
245
245
  file_name = "app/models/#{model.underscore.singularize}.rb"
246
246
  return if !File.exist?(file_name) || File.read(file_name).include?(line)
247
247
 
248
- line_count = `wc -l "#{file_name}"`.strip.split(' ')[0].to_i
249
-
248
+ file = open(file_name)
249
+ line_count = file.readlines.size
250
250
  line_nb = 0
251
- File.open(file_name).each do |l|
251
+ file.each do |l|
252
252
  line_nb += 1
253
253
  break if l.include?('ApplicationRecord')
254
254
  end
@@ -8,6 +8,7 @@ class GraphqlAllConnectionsGenerator < Rails::Generators::NamedBase
8
8
  end
9
9
 
10
10
  def generate_connection(dir, resource)
11
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
11
12
  File.write(
12
13
  "#{dir}/connection.rb",
13
14
  <<~STRING
@@ -3,7 +3,7 @@ class GraphqlMutationsGenerator < Rails::Generators::NamedBase
3
3
  def generate
4
4
  resource = file_name.underscore.singularize
5
5
  dir = "app/graphql/#{resource.pluralize}/mutations"
6
- system("mkdir -p #{dir}")
6
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
7
7
  generate_create_mutation(dir, resource)
8
8
  generate_update_mutation(dir, resource)
9
9
  generate_destroy_mutation(dir, resource)
@@ -8,7 +8,9 @@ module GraphqlRailsApi
8
8
 
9
9
  def generate_files
10
10
  @app_name = File.basename(Rails.root.to_s).underscore
11
- system('mkdir -p app/graphql/')
11
+
12
+ folder = 'app/graphql/'
13
+ FileUtils.mkdir_p(folder) unless File.directory?(folder)
12
14
 
13
15
  write_uuid_extensions_migration
14
16
 
@@ -20,9 +22,7 @@ module GraphqlRailsApi
20
22
 
21
23
  write_controller
22
24
 
23
- write_websocket_models
24
- write_websocket_connection
25
- write_subscriptions_channel
25
+ system 'rails g graphql_resource user first_name:string last_name:string email:string'
26
26
 
27
27
  write_application_record_methods
28
28
  write_initializer
@@ -32,12 +32,6 @@ module GraphqlRailsApi
32
32
 
33
33
  private
34
34
 
35
- def write_websocket_models
36
- system 'rails g graphql_resource user first_name:string last_name:string email:string'
37
- system 'rails g graphql_resource websocket_connection belongs_to:user connection_identifier:string'
38
- system 'rails g graphql_resource subscribed_query belongs_to:websocket_connection result_hash:string query:string'
39
- end
40
-
41
35
  def write_route
42
36
  route_file = File.read('config/routes.rb')
43
37
  return if route_file.include?('graphql')
@@ -47,8 +41,7 @@ module GraphqlRailsApi
47
41
  route_file.gsub(
48
42
  "Rails.application.routes.draw do\n",
49
43
  "Rails.application.routes.draw do\n" \
50
- " post '/graphql', to: 'graphql#execute'\n" \
51
- " mount ActionCable.server => '/cable'\n"
44
+ " post '/graphql', to: 'graphql#execute'\n"
52
45
  )
53
46
  )
54
47
  end
@@ -89,25 +82,13 @@ module GraphqlRailsApi
89
82
  all
90
83
  end
91
84
 
92
- def self.broadcast_queries
93
- WebsocketConnection.all.each do |wsc|
94
- wsc.subscribed_queries.each do |sq|
95
- result = #{@app_name.camelize}Schema.execute(sq.query, context: { current_user: wsc.user })
96
- hex = Digest::SHA1.hexdigest(result.to_s)
97
- next if sq.result_hash == hex
98
-
99
- sq.update_attributes(result_hash: hex)
100
- SubscriptionsChannel.broadcast_to(wsc, query: sq.query, result: result.to_s)
101
- end
102
- end
103
- end
104
85
 
105
- STRING
106
- )
107
- end
86
+ STRING
87
+ )
88
+ end
108
89
 
109
90
  def write_require_application_rb
110
- write_at('config/application.rb', 5, "require 'graphql/hydrate_query'\nrequire 'rkelly'\n")
91
+ write_at('config/application.rb', 5, "require 'graphql/hydrate_query'\nrequire 'rkelly'\nrequire 'graphql'\n")
111
92
  end
112
93
 
113
94
  def write_uuid_extensions_migration
@@ -119,8 +100,7 @@ module GraphqlRailsApi
119
100
  class UuidPgExtensions < ActiveRecord::Migration[5.2]
120
101
 
121
102
  def change
122
- execute 'CREATE EXTENSION "pgcrypto" SCHEMA pg_catalog;'
123
- execute 'CREATE EXTENSION "uuid-ossp" SCHEMA pg_catalog;'
103
+ enable_extension 'pgcrypto'
124
104
  end
125
105
 
126
106
  end
@@ -139,70 +119,6 @@ module GraphqlRailsApi
139
119
  )
140
120
  end
141
121
 
142
- def write_websocket_connection
143
- File.write(
144
- 'app/channels/application_cable/connection.rb',
145
- <<~'STRING'
146
- module ApplicationCable
147
- class Connection < ActionCable::Connection::Base
148
-
149
- identified_by :websocket_connection
150
-
151
- def connect
152
- # Check authentication, and define current user
153
- self.websocket_connection = WebsocketConnection.create(
154
- # user_id: current_user.id
155
- )
156
- end
157
-
158
- end
159
- end
160
- STRING
161
- )
162
- end
163
-
164
- def write_subscriptions_channel
165
- File.write(
166
- 'app/channels/subscriptions_channel.rb',
167
- <<~STRING
168
- class SubscriptionsChannel < ApplicationCable::Channel
169
-
170
- def subscribed
171
- stream_for(websocket_connection)
172
- websocket_connection.update_attributes(connection_identifier: connection.connection_identifier)
173
- ci = ActionCable.server.connections.map(&:connection_identifier)
174
- WebsocketConnection.all.each do |wsc|
175
- wsc.destroy unless ci.include?(wsc.connection_identifier)
176
- end
177
- end
178
-
179
- def subscribe_to_query(data)
180
- websocket_connection.subscribed_queries.find_or_create_by(query: data['query'])
181
- SubscriptionsChannel.broadcast_to(
182
- websocket_connection,
183
- query: data['query'],
184
- result: #{@app_name.camelize}Schema.execute(data['query'], context: { current_user: websocket_connection.user })
185
- )
186
- end
187
-
188
- def unsubscribe_to_query(data)
189
- websocket_connection.subscribed_queries.find_by(query: data['query'])&.destroy
190
- end
191
-
192
- def unsubscribed
193
- websocket_connection.destroy
194
- ci = ActionCable.server.connections.map(&:connection_identifier)
195
- WebsocketConnection.all.each do |wsc|
196
- wsc.destroy unless ci.include?(wsc.connection_identifier)
197
- end
198
- end
199
-
200
- end
201
-
202
- STRING
203
- )
204
- end
205
-
206
122
  def write_controller
207
123
  File.write(
208
124
  'app/controllers/graphql_controller.rb',
@@ -217,7 +133,6 @@ module GraphqlRailsApi
217
133
  context: { current_user: authenticated_user },
218
134
  operation_name: params[:operationName]
219
135
  )
220
- ApplicationRecord.broadcast_queries
221
136
  render json: result
222
137
  end
223
138
 
@@ -103,7 +103,7 @@ class GraphqlResourceGenerator < Rails::Generators::NamedBase
103
103
  end
104
104
 
105
105
  def generate_basic_mutations(resource)
106
- system("mkdir -p #{@mutations_directory}")
106
+ FileUtils.mkdir_p(@mutations_directory) unless File.directory?(@mutations_directory)
107
107
  system("rails generate graphql_mutations #{resource}")
108
108
 
109
109
  # Graphql Input Type
@@ -126,7 +126,8 @@ class GraphqlResourceGenerator < Rails::Generators::NamedBase
126
126
  end
127
127
 
128
128
  def generate_graphql_input_type(resource)
129
- system("mkdir -p #{@mutations_directory}")
129
+ FileUtils.mkdir_p(@mutations_directory) unless File.directory?(@mutations_directory)
130
+
130
131
  File.write(
131
132
  "#{@mutations_directory}/input_type.rb",
132
133
  <<~STRING
@@ -241,6 +242,9 @@ class GraphqlResourceGenerator < Rails::Generators::NamedBase
241
242
  end
242
243
 
243
244
  def generate_service(resource)
245
+
246
+ FileUtils.mkdir_p("app/graphql/#{resource.pluralize}/") unless File.directory?("app/graphql/#{resource.pluralize}/")
247
+
244
248
  File.write(
245
249
  "app/graphql/#{resource.pluralize}/service.rb",
246
250
  <<~STRING
@@ -308,13 +312,13 @@ t.#{@id_db_type} :#{resource.underscore.singularize}_id
308
312
  end
309
313
 
310
314
  def add_to_model(model, line)
311
- file_name = "app/models/#{model.underscore.singularize}.rb"
315
+ file_name = File.join("app","models","#{model.underscore.singularize}.rb")
312
316
  return if !File.exist?(file_name) || File.read(file_name).include?(line)
313
317
 
314
- line_count = `wc -l "#{file_name}"`.strip.split(' ')[0].to_i
315
-
318
+ file = open(file_name)
319
+ line_count = file.readlines.size
316
320
  line_nb = 0
317
- File.open(file_name).each do |l|
321
+ file.each do |l|
318
322
  line_nb += 1
319
323
  break if l.include?('ApplicationRecord')
320
324
  end
@@ -1,40 +1,311 @@
1
- require 'deep_pluck'
2
- require 'rkelly'
1
+ require "deep_pluck"
2
+ require "rkelly"
3
3
 
4
4
  module Graphql
5
5
  class HydrateQuery
6
-
7
- def initialize(model, context, order_by: nil, filter: nil, id: nil, user: nil, page: nil, per_page: 10)
6
+ def initialize(model, context, order_by: nil, filter: nil, check_visibility: true, id: nil, user: nil, page: nil, per_page: nil)
8
7
  @context = context
9
8
  @filter = filter
10
9
  @order_by = order_by
11
10
  @model = model
12
- @models = [model_name.singularize.camelize]
11
+ @check_visibility = check_visibility
12
+
13
+ if id.present? && !valid_id?(id)
14
+ raise GraphQL::ExecutionError, "Invalid id: #{id}"
15
+ end
16
+
13
17
  @id = id
14
18
  @user = user
15
- @page = page
16
- @per_page = per_page
19
+ @page = page&.to_i || 1
20
+ @per_page = per_page&.to_i || 1000
21
+ @per_page = 1000 if @per_page > 1000
17
22
  end
18
23
 
19
24
  def run
20
- @model = @model.where(transform_filter(@filter)) if @filter
21
- @model = @model.order(@order_by) if @order_by
25
+ if @id
26
+ @model = @model.where(id: @id)
27
+ deep_pluck_to_structs(@context&.irep_node).first
28
+ else
29
+ @model = @model.limit(@per_page)
30
+ @model = @model.offset(@per_page * (@page - 1))
31
+
32
+ transform_filter if @filter.present?
33
+ transform_order if @order_by.present?
34
+
35
+ deep_pluck_to_structs(@context&.irep_node)
36
+ end
37
+ end
38
+
39
+ def paginated_run
40
+ transform_filter if @filter.present?
41
+ transform_order if @order_by.present?
42
+
43
+ @total = @model.length
44
+ @model = @model.limit(@per_page)
45
+ @model = @model.offset(@per_page * (@page - 1))
46
+
47
+ OpenStruct.new(
48
+ data: deep_pluck_to_structs(@context&.irep_node&.typed_children&.values&.first.try(:[], "data")),
49
+ total_count: @total,
50
+ per_page: @per_page,
51
+ page: @page,
52
+ )
53
+ end
54
+
55
+ private
56
+
57
+ def transform_order
58
+ return if @order_by.blank?
59
+
60
+ sign = @order_by.split(" ").last.downcase == "desc" ? "desc" : "asc"
61
+ column = @order_by.split(" ").first.strip
62
+
63
+ if column.include?(".")
64
+ associated_model = column.split(".").first
65
+ accessor = column.split(".").last
66
+ assoc = get_assoc!(@model, associated_model)
67
+ field_type = get_field_type!(assoc.klass, accessor)
68
+ @model = @model.left_joins(associated_model.to_sym)
69
+ ordered_field = "#{associated_model.pluralize}.#{accessor}"
70
+ else
71
+ field_type = get_field_type!(@model, column)
72
+ ordered_field = "#{model_name.pluralize}.#{column}"
73
+ end
74
+
75
+ if %i[string text].include?(field_type)
76
+ @model = @model.order(Arel.sql("upper(#{ordered_field}) #{sign}"))
77
+ else
78
+ @model = @model.order(Arel.sql("#{ordered_field} #{sign}"))
79
+ end
80
+ end
81
+
82
+ def transform_filter
83
+ return if @filter.blank?
84
+
85
+ ast = RKelly::Parser.new.parse(@filter)
86
+ exprs = ast.value
87
+ if exprs.count != 1
88
+ raise GraphQL::ExecutionError, "Invalid filter: #{@filter}, only one expression allowed"
89
+ end
90
+
91
+ @model = handle_node(exprs.first.value, @model)
92
+
93
+ if @need_distinct_results
94
+ @model = @model.distinct
95
+ end
96
+
97
+ rescue RKelly::SyntaxError => e
98
+ raise GraphQL::ExecutionError, "Invalid filter: #{e.message}"
99
+ end
100
+
101
+ def handle_node(node, model)
102
+ if node.class == RKelly::Nodes::ParentheticalNode
103
+ handle_ParentheticalNode(node, model)
104
+ elsif node.class == RKelly::Nodes::LogicalAndNode
105
+ handle_LogicalAndNode(node, model)
106
+ elsif node.class == RKelly::Nodes::LogicalOrNode
107
+ handle_LogicalOrNode(node, model)
108
+ elsif node.class == RKelly::Nodes::NotEqualNode
109
+ handle_NotEqualNode(node, model)
110
+ elsif node.class == RKelly::Nodes::EqualNode
111
+ handle_EqualNode(node, model)
112
+ elsif node.class == RKelly::Nodes::StrictEqualNode
113
+ handle_StrictEqualNode(node, model)
114
+ elsif node.class == RKelly::Nodes::NotStrictEqualNode
115
+ handle_NotStrictEqualNode(node, model)
116
+ elsif node.class == RKelly::Nodes::GreaterOrEqualNode
117
+ handle_GreaterOrEqualNode(node, model)
118
+ elsif node.class == RKelly::Nodes::LessOrEqualNode
119
+ handle_LessOrEqualNode(node, model)
120
+ elsif node.class == RKelly::Nodes::LessNode
121
+ handle_LessNode(node, model)
122
+ elsif node.class == RKelly::Nodes::GreaterNode
123
+ handle_GreaterNode(node, model)
124
+ else
125
+ raise GraphQL::ExecutionError, "Invalid filter: #{node.class} unknown operator"
126
+ end
127
+ end
128
+
129
+ def handle_ParentheticalNode(node, model)
130
+ handle_node(node.value, model)
131
+ end
132
+
133
+ def handle_LogicalAndNode(node, model)
134
+ handle_node(node.left, model).and(handle_node(node.value, model))
135
+ end
136
+
137
+ def handle_LogicalOrNode(node, model)
138
+ handle_node(node.left, model).or(handle_node(node.value, model))
139
+ end
140
+
141
+ def handle_dot_accessor_node(node, model)
142
+ associated_model = node.left.value.value
143
+ accessor = node.left.accessor
144
+ assoc = get_assoc!(model, associated_model)
145
+ field_type = get_field_type!(assoc.klass, accessor)
146
+
147
+ if assoc.association_class == ActiveRecord::Associations::HasManyAssociation
148
+ @need_distinct_results = true
149
+ end
150
+
151
+ model = model.left_joins(associated_model.to_sym)
152
+ # field = "#{associated_model.pluralize}.#{accessor}"
153
+ value = value_from_node(node.value, field_type, accessor.to_sym, model)
154
+ [assoc.klass.arel_table[accessor], model, field_type, value]
155
+ end
156
+
157
+ def handle_resolve_node(node, model)
158
+ field = node.left.value
159
+ field_type = get_field_type!(model, field)
160
+ value = value_from_node(node.value, field_type, field.to_sym, model)
161
+ [model.klass.arel_table[field], model, field_type, value]
162
+ end
22
163
 
23
- @model = @model.limit(@per_page) if @per_page
24
- @model = @model.offset(@per_page * (@page - 1)) if @page
164
+ def handle_operator_node(node, model)
165
+ if node.left.class == RKelly::Nodes::DotAccessorNode
166
+ handle_dot_accessor_node(node, model)
167
+ elsif node.left.class == RKelly::Nodes::ResolveNode
168
+ handle_resolve_node(node, model)
169
+ else
170
+ raise GraphQL::ExecutionError, "Invalid left value: #{node.left.class}"
171
+ end
172
+ end
173
+
174
+ def value_from_node(node, sym_type, sym, model)
175
+ if node.class == RKelly::Nodes::StringNode
176
+ val = node.value.gsub(/^'|'$|^"|"$/, "")
177
+ if sym_type == :datetime
178
+ DateTime.parse(val)
179
+ elsif sym_type == :date
180
+ Date.parse(val)
181
+ elsif sym_type == :integer
182
+ # Enums are handled here : We are about to compare a string with an integer column
183
+ # If the symbol and the value correspond to an existing enum into the model
184
+ if model.klass.defined_enums[sym.to_s]&.keys&.include?(val)
185
+ # return the corresponding enum value
186
+ model.klass.defined_enums[sym.to_s][val]
187
+ else
188
+ raise GraphQL::ExecutionError, "Invalid value: #{val}, compare a string with an integer column #{sym}"
189
+ end
190
+ else
191
+ val
192
+ end
193
+ elsif node.class == RKelly::Nodes::NumberNode
194
+ node.value
195
+ elsif node.class == RKelly::Nodes::TrueNode
196
+ true
197
+ elsif node.class == RKelly::Nodes::FalseNode
198
+ false
199
+ elsif node.class == RKelly::Nodes::NullNode
200
+ nil
201
+ else
202
+ raise GraphQL::ExecutionError, "Invalid filter: #{node} unknown rvalue node"
203
+ end
204
+ end
205
+
206
+ def sanitize_sql_like(value)
207
+ ActiveRecord::Base::sanitize_sql_like(value)
208
+ end
209
+
210
+ def handle_NotEqualNode(node, model)
211
+ arel_field, model, type, value = handle_operator_node(node, model)
212
+
213
+ if value.nil?
214
+ model.where.not(arel_field.eq(nil))
215
+ elsif type == :text || type == :string
216
+ model.where.not(arel_field.lower.matches(sanitize_sql_like(value.downcase)))
217
+ else
218
+ model.where.not(arel_field.eq(value))
219
+ end
220
+ end
221
+
222
+ def handle_NotStrictEqualNode(node, model)
223
+ arel_field, model, type, value = handle_operator_node(node, model)
224
+
225
+ if value.nil?
226
+ model.where.not(arel_field.eq(nil))
227
+ elsif type == :text || type == :string
228
+ model.where.not(arel_field.matches(sanitize_sql_like(value), false, true))
229
+ else
230
+ model.where.not(arel_field.eq(value))
231
+ end
232
+ end
233
+
234
+ def handle_EqualNode(node, model)
235
+ arel_field, model, type, value = handle_operator_node(node, model)
236
+
237
+ if value.nil?
238
+ model.where(arel_field.eq(nil))
239
+ elsif type == :text || type == :string
240
+ model.where(arel_field.lower.matches(sanitize_sql_like(value.downcase)))
241
+ else
242
+ model.where(arel_field.eq(value))
243
+ end
244
+ end
25
245
 
26
- @model = @model.where(id: @id) if @id
27
- plucked = DeepPluck::Model.new(@model.visible_for(user: @user), user: @user).add(
28
- hash_to_array_of_hashes(parse_fields(@context&.irep_node), @model)
29
- ).load_all
30
- result = plucked_attr_to_structs(plucked, model_name.singularize.camelize.constantize)&.compact
31
- @id ? result.first : result
246
+ def handle_StrictEqualNode(node, model)
247
+ arel_field, model, type, value = handle_operator_node(node, model)
248
+
249
+ if value.nil?
250
+ model.where(arel_field.eq(nil))
251
+ elsif type == :text || type == :string
252
+ model.where(arel_field.matches(sanitize_sql_like(value), false, true))
253
+ else
254
+ model.where(arel_field.eq(value))
255
+ end
256
+ end
257
+
258
+ def handle_GreaterOrEqualNode(node, model)
259
+ arel_field, model, type, value = handle_operator_node(node, model)
260
+ model.where(arel_field.gteq(value))
261
+ end
262
+
263
+ def handle_LessOrEqualNode(node, model)
264
+ arel_field, model, type, value = handle_operator_node(node, model)
265
+ model.where(arel_field.lteq(value))
266
+ end
267
+
268
+ def handle_LessNode(node, model)
269
+ arel_field, model, type, value = handle_operator_node(node, model)
270
+ model.where(arel_field.lt(value))
271
+ end
272
+
273
+ def handle_GreaterNode(node, model)
274
+ arel_field, model, type, value = handle_operator_node(node, model)
275
+ model.where(arel_field.gt(value))
276
+ end
277
+
278
+ def valid_id?(id)
279
+ valid_uuid?(id) || id.is_a?(Integer)
280
+ end
281
+
282
+ def valid_uuid?(id)
283
+ id.to_s.match(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/)
32
284
  end
33
285
 
34
- def transform_filter(filter)
35
- parsed_filter = RKelly::Parser.new.parse(filter.gsub('like', ' | ')).to_ecma
36
- parsed_filter.gsub(' | ', ' like ').
37
- gsub('||', 'OR').gsub('&&', 'AND').gsub('===', '=').gsub('==', '=').delete(';')
286
+ def get_assoc!(model, assoc_name)
287
+ assoc = model.reflect_on_association(assoc_name)
288
+ unless assoc.present?
289
+ raise GraphQL::ExecutionError, "Invalid filter: #{assoc_name} is not an association of #{model}"
290
+ end
291
+ assoc
292
+ end
293
+
294
+ def get_field_type!(model, field_name)
295
+ field = model.column_for_attribute(field_name.to_sym)
296
+ unless field.present?
297
+ raise GraphQL::ExecutionError, "Invalid filter: #{field_name} is not a field of #{model}"
298
+ end
299
+ field.type
300
+ end
301
+
302
+ def deep_pluck_to_structs(irep_node)
303
+ plucked_attr_to_structs(
304
+ DeepPluck::Model.new(@model.visible_for(user: @user), user: @user).add(
305
+ ((hash_to_array_of_hashes(parse_fields(irep_node), @model) || []) + [@to_select_to_add]).compact
306
+ ).load_all,
307
+ model_name.singularize.camelize.constantize
308
+ )&.compact
38
309
  end
39
310
 
40
311
  def plucked_attr_to_structs(arr, parent_model)
@@ -56,7 +327,7 @@ module Graphql
56
327
  def hash_to_array_of_hashes(hash, parent_class)
57
328
  return if parent_class.nil? || hash.nil?
58
329
 
59
- hash['id'] = nil if hash['id'].blank?
330
+ hash["id"] = nil if hash["id"].blank?
60
331
  fetch_ids_from_relation(hash)
61
332
 
62
333
  hash.each_with_object([]) do |(k, v), arr|
@@ -64,18 +335,17 @@ module Graphql
64
335
  next arr << v if parent_class.new.attributes.key?(v)
65
336
 
66
337
  klass = evaluate_model(parent_class, k)
67
- @models << klass.to_s unless @models.include?(klass.to_s)
68
338
  arr << { k.to_sym => hash_to_array_of_hashes(v, klass) } if klass.present? && v.present?
69
339
  end
70
340
  end
71
341
 
72
342
  def fetch_ids_from_relation(hash)
73
- hash.select { |k, _| k.ends_with?('_ids') }.each do |(k, _)|
74
- collection_name = k.gsub('_ids', '').pluralize
343
+ hash.select { |k, _| k.ends_with?("_ids") }.each do |(k, _)|
344
+ collection_name = k.gsub("_ids", "").pluralize
75
345
  if hash[collection_name].blank?
76
- hash[collection_name] = { 'id' => nil }
346
+ hash[collection_name] = { "id" => nil }
77
347
  else
78
- hash[collection_name]['id'] = nil
348
+ hash[collection_name]["id"] = nil
79
349
  end
80
350
  end
81
351
  end
@@ -104,9 +374,7 @@ module Graphql
104
374
 
105
375
  def parse_fields(irep_node)
106
376
  fields = irep_node&.scoped_children&.values&.first
107
- if fields.key?('edges')
108
- fields = fields['edges'].scoped_children.values.first['node']&.scoped_children&.values&.first
109
- end
377
+ fields = fields["edges"].scoped_children.values.first["node"]&.scoped_children&.values&.first if fields&.key?("edges")
110
378
  return if fields.blank?
111
379
 
112
380
  fields.each_with_object({}) do |(k, v), h|
@@ -115,8 +383,7 @@ module Graphql
115
383
  end
116
384
 
117
385
  def model_name
118
- @model.class.to_s.split('::').first.underscore.pluralize
386
+ @model.class.to_s.split("::").first.underscore.pluralize
119
387
  end
120
-
121
388
  end
122
389
  end
@@ -6,23 +6,22 @@ module Graphql
6
6
  include Singleton
7
7
 
8
8
  def self.query_resources
9
- Dir.glob("#{File.expand_path('.')}/app/graphql/*/type.rb").map do |dir|
9
+ Dir.glob("#{::Rails.root}/app/graphql/*/type.rb").map do |dir|
10
10
  dir.split('/').last(2).first
11
11
  end
12
12
  end
13
13
 
14
14
  def self.mutation_resources
15
- mutations = Dir.glob("#{File.expand_path('.')}/app/graphql/*/mutations/*.rb").reject do |e|
15
+ mutations = Dir.glob("#{::Rails.root}/app/graphql/*/mutations/*.rb").reject do |e|
16
16
  e.end_with?('type.rb', 'types.rb')
17
17
  end
18
18
  mutations = mutations.map { |e| e.split('/').last.gsub('.rb', '') }.uniq
19
19
  mutations.each_with_object({}) do |meth, h|
20
- h[meth] = Dir.glob("#{File.expand_path('.')}/app/graphql/*/mutations/#{meth}.rb").map do |dir|
20
+ h[meth] = Dir.glob("#{::Rails.root}/app/graphql/*/mutations/#{meth}.rb").map do |dir|
21
21
  dir.split('/').last(3).first
22
22
  end
23
23
  end
24
24
  end
25
-
26
25
  end
27
26
  end
28
27
  end
@@ -1,7 +1,7 @@
1
1
  module Graphql
2
2
  module Rails
3
3
  module Api
4
- VERSION = '0.8.0'.freeze
4
+ VERSION = '0.9.2'.freeze
5
5
  end
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphql-rails-api
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - poilon
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-12-12 00:00:00.000000000 Z
11
+ date: 2022-08-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: graphql
@@ -16,14 +16,20 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '1.7'
19
+ version: '1.12'
20
+ - - "<="
21
+ - !ruby/object:Gem::Version
22
+ version: 1.12.13
20
23
  type: :runtime
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
23
26
  requirements:
24
27
  - - "~>"
25
28
  - !ruby/object:Gem::Version
26
- version: '1.7'
29
+ version: '1.12'
30
+ - - "<="
31
+ - !ruby/object:Gem::Version
32
+ version: 1.12.13
27
33
  - !ruby/object:Gem::Dependency
28
34
  name: deep_pluck_with_authorization
29
35
  requirement: !ruby/object:Gem::Requirement
@@ -44,20 +50,14 @@ dependencies:
44
50
  requirements:
45
51
  - - ">="
46
52
  - !ruby/object:Gem::Version
47
- version: 6.0.0
48
- - - "~>"
49
- - !ruby/object:Gem::Version
50
- version: 6.0.0
53
+ version: 5.1.4
51
54
  type: :runtime
52
55
  prerelease: false
53
56
  version_requirements: !ruby/object:Gem::Requirement
54
57
  requirements:
55
58
  - - ">="
56
59
  - !ruby/object:Gem::Version
57
- version: 6.0.0
58
- - - "~>"
59
- - !ruby/object:Gem::Version
60
- version: 6.0.0
60
+ version: 5.1.4
61
61
  - !ruby/object:Gem::Dependency
62
62
  name: rkelly-remix
63
63
  requirement: !ruby/object:Gem::Requirement
@@ -114,7 +114,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
114
114
  - !ruby/object:Gem::Version
115
115
  version: '0'
116
116
  requirements: []
117
- rubygems_version: 3.0.6
117
+ rubygems_version: 3.3.7
118
118
  signing_key:
119
119
  specification_version: 4
120
120
  summary: Graphql rails api framework to create easily graphql api with rails