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 +4 -4
- data/README.md +269 -32
- data/lib/generators/graphql_rails_api/install_generator.rb +9 -96
- data/lib/graphql/hydrate_query.rb +258 -62
- data/lib/graphql/rails/api/config.rb +3 -4
- data/lib/graphql/rails/api/version.rb +1 -1
- metadata +13 -13
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2e7801279a80cf36fceb00b2c734cda2ff89e0c695fa77efa10e706e10fb708b
|
4
|
+
data.tar.gz: e0fa94077d38b530f56617ccf14825f330d427c510bf44259f43baf2d28ffc8a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 45940578253a9645e325860234d793dc8c00c7fdb69c5758748f45fe66d09c32f08e43d60b3180537953dead3f2b5edf470fe2516cf40a5ed0a237124cd3df8e
|
7
|
+
data.tar.gz: 3f830d57e8ae131cae74735ba29ea24e3a8b2391899aea329b18aec18fc3b75647e3d085201211c514fd6d397dba1739ae0471f0ea906e0650c6214fbcf429ee
|
data/README.md
CHANGED
@@ -1,46 +1,87 @@
|
|
1
|
-
#
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
39
|
+
## Get Started
|
38
40
|
|
39
|
-
|
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
|
-
|
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
|
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
|
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
|
-
|
121
|
+
## About queries
|
122
|
+
3 types of queries are available.
|
69
123
|
|
70
|
-
|
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
|
-
|
73
|
-
|
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
|
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
|
-
|
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
|
-
|
230
|
+
bulk_create mutation:
|
231
|
+
```gql
|
232
|
+
mutation($cities: [CityInputType]!) {
|
233
|
+
bulk_create_city(cities: $cities) {
|
234
|
+
id
|
235
|
+
}
|
236
|
+
}
|
237
|
+
```
|
82
238
|
|
83
|
-
|
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
|
-
|
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
|
-
|
102
|
-
|
103
|
-
|
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
|
-
|
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
|
2
|
-
require
|
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
|
-
|
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
|
-
|
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(:[],
|
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
|
51
|
-
if @
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
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
|
-
|
188
|
+
raise GraphQL::ExecutionError, "Invalid value: #{val}, compare a string with an integer column #{sym}"
|
65
189
|
end
|
66
|
-
|
67
|
-
|
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
|
-
|
206
|
+
def sanitize_sql_like(value)
|
207
|
+
ActiveRecord::Base::sanitize_sql_like(value)
|
208
|
+
end
|
71
209
|
|
72
|
-
|
73
|
-
|
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
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
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
|
95
|
-
|
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
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
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[
|
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?(
|
147
|
-
collection_name = k.gsub(
|
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] = {
|
346
|
+
hash[collection_name] = { "id" => nil }
|
150
347
|
else
|
151
|
-
hash[collection_name][
|
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[
|
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(
|
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("#{
|
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("#{
|
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("#{
|
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
|
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.
|
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-
|
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.
|
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.
|
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:
|
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:
|
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.
|
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
|