graphql-rails-api 0.9.1 → 0.9.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c1751d6f6ae5edf98341bdf30a149cd8aa9ab4dbcb0a627cb25c380cfe15c676
4
- data.tar.gz: 90c9dc17368d908efb70a8ee55837c3d6b117709762c006eaee18ddcd3a820ff
3
+ metadata.gz: 2e7801279a80cf36fceb00b2c734cda2ff89e0c695fa77efa10e706e10fb708b
4
+ data.tar.gz: e0fa94077d38b530f56617ccf14825f330d427c510bf44259f43baf2d28ffc8a
5
5
  SHA512:
6
- metadata.gz: 98cd97012f608dea85e6a5417a4d1ab83ddf3cdae9a51473a014a3c4d45769ce928301a9e57ab32a9c65a76df5e3687cead2046e57a829da18a1309ecfc7ff06
7
- data.tar.gz: d297e02228b4a00c81a9f29eef88c92da12a23698e9e2dc3aa9eab52c3ccf97a471c04aa134d9cb11bb516bc1d2446464ba9b69b8cc0be8d1f84d465deaa2a91
6
+ metadata.gz: 45940578253a9645e325860234d793dc8c00c7fdb69c5758748f45fe66d09c32f08e43d60b3180537953dead3f2b5edf470fe2516cf40a5ed0a237124cd3df8e
7
+ data.tar.gz: 3f830d57e8ae131cae74735ba29ea24e3a8b2391899aea329b18aec18fc3b75647e3d085201211c514fd6d397dba1739ae0471f0ea906e0650c6214fbcf429ee
data/README.md CHANGED
@@ -1,46 +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 discord : Poilon#5412
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.
6
13
 
7
14
  ## Installation
8
15
 
9
- Create a rails app via
10
- ```
11
- rails new project-name --api --database=postgresql
12
- cd project-name
13
- bundle
14
- rails db:create
15
- ```
16
-
17
- Add these lines to your application's Gemfile:
16
+ Add the gem to your application's Gemfile:
18
17
  ```ruby
19
18
  gem 'graphql-rails-api'
20
19
  ```
21
20
 
22
- And then execute:
21
+ Download and install the gem:
23
22
  ```bash
24
23
  $ bundle
25
24
  $ rails generate graphql_rails_api:install
26
25
  ```
27
26
 
27
+ ### Options
28
+ The following options to the `graphql_rails_api:install` command are available:
29
+
30
+
28
31
  To disable PostgreSQL uuid extension, add the option `--no-pg-uuid`
29
32
 
30
33
  To disable ActionCable websocket subscriptions, add the option `--no-action-cable-subs`
31
34
 
32
35
  To disable Apollo compatibility, add the option `--no-apollo-compatibility`
33
36
 
34
- Automatically, `post '/graphql', to: 'graphql#execute'` will be added to your `config/route.rb`
35
- 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`
36
38
 
37
- # Usage
39
+ ## Get Started
38
40
 
39
- ## 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!
40
46
 
47
+ Now You can perform crud mutation on resources:
41
48
  ```bash
42
- $ 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"}}}
43
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
+
44
85
 
45
86
  To disable migration generation, add the option `--no-migration`
46
87
 
@@ -54,40 +95,236 @@ To disable graphql-type generation, add the option `--no-graphql-type`
54
95
 
55
96
  To disable graphql-input-type generation, add the option `--no-graphql-input-type`
56
97
 
57
- 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`
58
-
59
- I made the choice of migrate automatically after generating a resource, to avoid doing it each time.
60
- 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`
61
100
 
62
- ## On generating resources
63
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:
64
105
  ```bash
65
- $ 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
66
119
  ```
67
120
 
68
- 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.
69
123
 
70
- 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
+ ```
71
149
 
72
- It will add a `computer_id` to the `HardDrive` model, and
73
- 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 : `==`, `!=`, `===`, `!==`, `>`, `<`, `>=`, `<=`
74
156
 
75
- 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
+ ```
76
181
 
77
- 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
+ ```
78
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
+ ```
79
220
 
221
+ destroy mutation:
222
+ ```gql
223
+ mutation($id: String!) {
224
+ destroy_city(id: $id) {
225
+ id
226
+ }
227
+ }
228
+ ```
80
229
 
81
- ## Graphql API example
230
+ bulk_create mutation:
231
+ ```gql
232
+ mutation($cities: [CityInputType]!) {
233
+ bulk_create_city(cities: $cities) {
234
+ id
235
+ }
236
+ }
237
+ ```
82
238
 
83
- 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
+ ```
84
247
 
248
+ You can override the default application service for all mutation by defining your own method into the corresponding graphql service:
85
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
86
321
 
87
322
  ## Contributing
88
323
 
89
324
  You can post any issue, and contribute by making pull requests.
90
325
  Don't hesitate, even the shortest pull request is great help. <3
91
326
 
327
+ Need any help or wanna talk with me on discord : Poilon#5412
328
+
92
329
  ## License
93
330
  The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
@@ -8,10 +8,10 @@ module GraphqlRailsApi
8
8
 
9
9
  def generate_files
10
10
  @app_name = File.basename(Rails.root.to_s).underscore
11
-
11
+
12
12
  folder = 'app/graphql/'
13
13
  FileUtils.mkdir_p(folder) unless File.directory?(folder)
14
-
14
+
15
15
  write_uuid_extensions_migration
16
16
 
17
17
  write_service
@@ -22,9 +22,7 @@ module GraphqlRailsApi
22
22
 
23
23
  write_controller
24
24
 
25
- write_websocket_models
26
- write_websocket_connection
27
- write_subscriptions_channel
25
+ system 'rails g graphql_resource user first_name:string last_name:string email:string'
28
26
 
29
27
  write_application_record_methods
30
28
  write_initializer
@@ -34,12 +32,6 @@ module GraphqlRailsApi
34
32
 
35
33
  private
36
34
 
37
- def write_websocket_models
38
- system 'rails g graphql_resource user first_name:string last_name:string email:string'
39
- system 'rails g graphql_resource websocket_connection belongs_to:user connection_identifier:string'
40
- system 'rails g graphql_resource subscribed_query belongs_to:websocket_connection result_hash:string query:string'
41
- end
42
-
43
35
  def write_route
44
36
  route_file = File.read('config/routes.rb')
45
37
  return if route_file.include?('graphql')
@@ -49,8 +41,7 @@ module GraphqlRailsApi
49
41
  route_file.gsub(
50
42
  "Rails.application.routes.draw do\n",
51
43
  "Rails.application.routes.draw do\n" \
52
- " post '/graphql', to: 'graphql#execute'\n" \
53
- " mount ActionCable.server => '/cable'\n"
44
+ " post '/graphql', to: 'graphql#execute'\n"
54
45
  )
55
46
  )
56
47
  end
@@ -91,25 +82,13 @@ module GraphqlRailsApi
91
82
  all
92
83
  end
93
84
 
94
- def self.broadcast_queries
95
- WebsocketConnection.all.each do |wsc|
96
- wsc.subscribed_queries.each do |sq|
97
- result = #{@app_name.camelize}Schema.execute(sq.query, context: { current_user: wsc.user })
98
- hex = Digest::SHA1.hexdigest(result.to_s)
99
- next if sq.result_hash == hex
100
85
 
101
- sq.update_attributes(result_hash: hex)
102
- SubscriptionsChannel.broadcast_to(wsc, query: sq.query, result: result.to_s)
103
- end
104
- end
105
- end
106
-
107
- STRING
108
- )
109
- end
86
+ STRING
87
+ )
88
+ end
110
89
 
111
90
  def write_require_application_rb
112
- 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")
113
92
  end
114
93
 
115
94
  def write_uuid_extensions_migration
@@ -121,8 +100,7 @@ module GraphqlRailsApi
121
100
  class UuidPgExtensions < ActiveRecord::Migration[5.2]
122
101
 
123
102
  def change
124
- execute 'CREATE EXTENSION "pgcrypto" SCHEMA pg_catalog;'
125
- execute 'CREATE EXTENSION "uuid-ossp" SCHEMA pg_catalog;'
103
+ enable_extension 'pgcrypto'
126
104
  end
127
105
 
128
106
  end
@@ -141,70 +119,6 @@ module GraphqlRailsApi
141
119
  )
142
120
  end
143
121
 
144
- def write_websocket_connection
145
- File.write(
146
- 'app/channels/application_cable/connection.rb',
147
- <<~'STRING'
148
- module ApplicationCable
149
- class Connection < ActionCable::Connection::Base
150
-
151
- identified_by :websocket_connection
152
-
153
- def connect
154
- # Check authentication, and define current user
155
- self.websocket_connection = WebsocketConnection.create(
156
- # user_id: current_user.id
157
- )
158
- end
159
-
160
- end
161
- end
162
- STRING
163
- )
164
- end
165
-
166
- def write_subscriptions_channel
167
- File.write(
168
- 'app/channels/subscriptions_channel.rb',
169
- <<~STRING
170
- class SubscriptionsChannel < ApplicationCable::Channel
171
-
172
- def subscribed
173
- stream_for(websocket_connection)
174
- websocket_connection.update_attributes(connection_identifier: connection.connection_identifier)
175
- ci = ActionCable.server.connections.map(&:connection_identifier)
176
- WebsocketConnection.all.each do |wsc|
177
- wsc.destroy unless ci.include?(wsc.connection_identifier)
178
- end
179
- end
180
-
181
- def subscribe_to_query(data)
182
- websocket_connection.subscribed_queries.find_or_create_by(query: data['query'])
183
- SubscriptionsChannel.broadcast_to(
184
- websocket_connection,
185
- query: data['query'],
186
- result: #{@app_name.camelize}Schema.execute(data['query'], context: { current_user: websocket_connection.user })
187
- )
188
- end
189
-
190
- def unsubscribe_to_query(data)
191
- websocket_connection.subscribed_queries.find_by(query: data['query'])&.destroy
192
- end
193
-
194
- def unsubscribed
195
- websocket_connection.destroy
196
- ci = ActionCable.server.connections.map(&:connection_identifier)
197
- WebsocketConnection.all.each do |wsc|
198
- wsc.destroy unless ci.include?(wsc.connection_identifier)
199
- end
200
- end
201
-
202
- end
203
-
204
- STRING
205
- )
206
- end
207
-
208
122
  def write_controller
209
123
  File.write(
210
124
  'app/controllers/graphql_controller.rb',
@@ -219,7 +133,6 @@ module GraphqlRailsApi
219
133
  context: { current_user: authenticated_user },
220
134
  operation_name: params[:operationName]
221
135
  )
222
- ApplicationRecord.broadcast_queries
223
136
  render json: result
224
137
  end
225
138
 
@@ -1,20 +1,24 @@
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
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]
13
11
  @check_visibility = check_visibility
12
+
13
+ if id.present? && !valid_id?(id)
14
+ raise GraphQL::ExecutionError, "Invalid id: #{id}"
15
+ end
16
+
14
17
  @id = id
15
18
  @user = user
16
- @page = page || 1
17
- @per_page = per_page || 1000
19
+ @page = page&.to_i || 1
20
+ @per_page = per_page&.to_i || 1000
21
+ @per_page = 1000 if @per_page > 1000
18
22
  end
19
23
 
20
24
  def run
@@ -24,81 +28,275 @@ module Graphql
24
28
  else
25
29
  @model = @model.limit(@per_page)
26
30
  @model = @model.offset(@per_page * (@page - 1))
27
- filter_and_order
31
+
32
+ transform_filter if @filter.present?
33
+ transform_order if @order_by.present?
34
+
28
35
  deep_pluck_to_structs(@context&.irep_node)
29
36
  end
30
37
  end
31
38
 
32
39
  def paginated_run
33
- filter_and_order
40
+ transform_filter if @filter.present?
41
+ transform_order if @order_by.present?
34
42
 
35
43
  @total = @model.length
36
44
  @model = @model.limit(@per_page)
37
45
  @model = @model.offset(@per_page * (@page - 1))
38
46
 
39
- ::Rails.logger.info(@model.to_sql)
40
47
  OpenStruct.new(
41
- data: deep_pluck_to_structs(@context&.irep_node&.typed_children&.values&.first.try(:[], 'data')),
48
+ data: deep_pluck_to_structs(@context&.irep_node&.typed_children&.values&.first.try(:[], "data")),
42
49
  total_count: @total,
43
50
  per_page: @per_page,
44
- page: @page
51
+ page: @page,
45
52
  )
46
53
  end
47
54
 
48
55
  private
49
56
 
50
- def filter_and_order
51
- if @filter
52
- transformed_filter = transform_filter(@filter)
53
- to_join = transformed_filter.split(/AND|OR|like|ilike/).map do |expression|
54
- expression.strip.split(/!=|=|IS/).first.strip
55
- end.select { |e| e.include?('.') }.map { |e| e.split('.').first }.map(&:to_sym)
56
- to_join.reject { |j| j.to_s.pluralize.to_sym == @model.klass.to_s.pluralize.underscore.to_sym }.each do |j|
57
- @model = @model.left_joins(j).distinct
58
- end
59
- transformed_filter = transformed_filter.split(/(AND|OR|like|ilike)/).map do |e|
60
- arr = e.split(/(!=|=|IS)/)
61
- if arr.first.include?('.')
62
- arr.first.split('.').first.pluralize + '.' + arr.first.split('.').last + arr[1].to_s + arr[2].to_s
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
163
+
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]
63
187
  else
64
- arr.join
188
+ raise GraphQL::ExecutionError, "Invalid value: #{val}, compare a string with an integer column #{sym}"
65
189
  end
66
- end.join
67
- @model = @model.where(transformed_filter)
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"
68
203
  end
204
+ end
69
205
 
70
- return unless @order_by
206
+ def sanitize_sql_like(value)
207
+ ActiveRecord::Base::sanitize_sql_like(value)
208
+ end
71
209
 
72
- sign = @order_by.split(' ').last.downcase == 'desc' ? 'desc' : 'asc'
73
- column = @order_by.split(' ').first
74
- if column.include?('.')
75
- @model = @model.left_joins(column.split('.').first.to_sym)
76
- string_type = %i[string text].include?(
77
- evaluate_model(@model, column.split('.').first).columns_hash[column.split('.').last]&.type
78
- )
210
+ def handle_NotEqualNode(node, model)
211
+ arel_field, model, type, value = handle_operator_node(node, model)
79
212
 
80
- @to_select_to_add = if string_type
81
- "upper(#{column.split('.').first.pluralize}.#{column.split('.').last})"
82
- else
83
- column.split('.').first.pluralize + '.' + column.split('.').last
84
- end
85
- @model = @model.select(@to_select_to_add)
86
- column = "#{column.split('.').first.pluralize}.#{column.split('.').last}"
87
- @model = @model.order(Arel.sql("#{string_type ? "upper(#{column})" : column} #{sign}"))
88
- elsif @order_by
89
- column = "upper(#{model_name}.#{column})" if %i[string text].include?(@model.columns_hash[column]&.type)
90
- @model = @model.order("#{column} #{sign}")
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))
91
219
  end
92
220
  end
93
221
 
94
- def transform_filter(filter)
95
- parsed_filter = RKelly::Parser.new.parse(filter.gsub('like', ' | '))&.to_ecma
96
- return '' unless parsed_filter
222
+ def handle_NotStrictEqualNode(node, model)
223
+ arel_field, model, type, value = handle_operator_node(node, model)
97
224
 
98
- @model.klass.defined_enums.values.reduce(:merge)&.each { |k, v| parsed_filter.gsub!("= #{k}", "= #{v}") }
99
- parsed_filter.gsub(' | ', ' ilike ').gsub('||', 'OR').gsub('&&', 'AND').gsub('===', '=').gsub('==', '=').gsub(
100
- '!= null', 'IS NOT NULL'
101
- ).gsub('= null', 'IS NULL').delete(';')
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
245
+
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/)
284
+ end
285
+
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
102
300
  end
103
301
 
104
302
  def deep_pluck_to_structs(irep_node)
@@ -129,7 +327,7 @@ module Graphql
129
327
  def hash_to_array_of_hashes(hash, parent_class)
130
328
  return if parent_class.nil? || hash.nil?
131
329
 
132
- hash['id'] = nil if hash['id'].blank?
330
+ hash["id"] = nil if hash["id"].blank?
133
331
  fetch_ids_from_relation(hash)
134
332
 
135
333
  hash.each_with_object([]) do |(k, v), arr|
@@ -137,18 +335,17 @@ module Graphql
137
335
  next arr << v if parent_class.new.attributes.key?(v)
138
336
 
139
337
  klass = evaluate_model(parent_class, k)
140
- @models << klass.to_s unless @models.include?(klass.to_s)
141
338
  arr << { k.to_sym => hash_to_array_of_hashes(v, klass) } if klass.present? && v.present?
142
339
  end
143
340
  end
144
341
 
145
342
  def fetch_ids_from_relation(hash)
146
- hash.select { |k, _| k.ends_with?('_ids') }.each do |(k, _)|
147
- collection_name = k.gsub('_ids', '').pluralize
343
+ hash.select { |k, _| k.ends_with?("_ids") }.each do |(k, _)|
344
+ collection_name = k.gsub("_ids", "").pluralize
148
345
  if hash[collection_name].blank?
149
- hash[collection_name] = { 'id' => nil }
346
+ hash[collection_name] = { "id" => nil }
150
347
  else
151
- hash[collection_name]['id'] = nil
348
+ hash[collection_name]["id"] = nil
152
349
  end
153
350
  end
154
351
  end
@@ -177,7 +374,7 @@ module Graphql
177
374
 
178
375
  def parse_fields(irep_node)
179
376
  fields = irep_node&.scoped_children&.values&.first
180
- fields = fields['edges'].scoped_children.values.first['node']&.scoped_children&.values&.first if fields&.key?('edges')
377
+ fields = fields["edges"].scoped_children.values.first["node"]&.scoped_children&.values&.first if fields&.key?("edges")
181
378
  return if fields.blank?
182
379
 
183
380
  fields.each_with_object({}) do |(k, v), h|
@@ -186,8 +383,7 @@ module Graphql
186
383
  end
187
384
 
188
385
  def model_name
189
- @model.class.to_s.split('::').first.underscore.pluralize
386
+ @model.class.to_s.split("::").first.underscore.pluralize
190
387
  end
191
-
192
388
  end
193
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.9.1'.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.9.1
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: 2022-06-30 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.1.4
48
- - - "~>"
49
- - !ruby/object:Gem::Version
50
- version: 7.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.1.4
58
- - - "~>"
59
- - !ruby/object:Gem::Version
60
- version: 7.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.8
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