graphql-rails-api 0.9.1 → 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: 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