graphiti 1.13.0 → 2.0.0.beta.1
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 +4 -4
- data/.github/workflows/ci.yml +6 -37
- data/.standard.yml +1 -1
- data/Appraisals +0 -15
- data/CHANGELOG.md +9 -3
- data/UPGRADING.md +71 -0
- data/graphiti.gemspec +1 -1
- data/lib/graphiti/errors.rb +19 -13
- data/lib/graphiti/query.rb +1 -0
- data/lib/graphiti/request_validators/validator.rb +0 -1
- data/lib/graphiti/resource/interface.rb +1 -21
- data/lib/graphiti/resource/persistence.rb +36 -56
- data/lib/graphiti/resource.rb +11 -2
- data/lib/graphiti/resource_proxy.rb +86 -11
- data/lib/graphiti/runner.rb +2 -1
- data/lib/graphiti/serializer.rb +1 -0
- data/lib/graphiti/util/hash.rb +9 -2
- data/lib/graphiti/util/persistence.rb +39 -3
- data/lib/graphiti/version.rb +1 -1
- metadata +5 -6
- data/gemfiles/rails_5_2.gemfile +0 -18
- data/gemfiles/rails_5_2_graphiti_rails.gemfile +0 -19
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a741f7953d1f54e730d2e801715f3b1d718044a4e0bd59cfd8bb3b364373e715
|
|
4
|
+
data.tar.gz: 99391699d407dfb92178cf851cce313d691ea930eb0ada81abe764b7a8310087
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 77d02ef7c1a525c286d56677ef103f56c7ea80873f565c6db72d2208d887fac967c8f9cddc6a06f940c4a62c6d7faf58390f5331f18fd2a562aa855fff002afd
|
|
7
|
+
data.tar.gz: e4e83880da4609da20727c8e8bf7b21cddc6e7436d571218da2f874e682dfdbe03c6960799e12614716b936881a8af95ee0459d6458afcaf5f3818dae198543e
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -2,7 +2,7 @@ name: CI
|
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
4
|
push:
|
|
5
|
-
branches: [main]
|
|
5
|
+
branches: [main, beta]
|
|
6
6
|
pull_request: {}
|
|
7
7
|
workflow_dispatch: {}
|
|
8
8
|
|
|
@@ -29,7 +29,6 @@ jobs:
|
|
|
29
29
|
fail-fast: false
|
|
30
30
|
matrix:
|
|
31
31
|
ruby:
|
|
32
|
-
- "2.7"
|
|
33
32
|
- "3.0"
|
|
34
33
|
- "3.1"
|
|
35
34
|
- "3.2"
|
|
@@ -37,10 +36,8 @@ jobs:
|
|
|
37
36
|
- "3.4"
|
|
38
37
|
gemfile:
|
|
39
38
|
- Gemfile
|
|
40
|
-
- gemfiles/rails_5_2.gemfile
|
|
41
39
|
- gemfiles/rails_6.gemfile
|
|
42
40
|
- gemfiles/rails_7.gemfile
|
|
43
|
-
- gemfiles/rails_5_2_graphiti_rails.gemfile
|
|
44
41
|
- gemfiles/rails_6_graphiti_rails.gemfile
|
|
45
42
|
- gemfiles/rails_7_graphiti_rails.gemfile
|
|
46
43
|
- gemfiles/rails_7_1_graphiti_rails.gemfile
|
|
@@ -70,14 +67,10 @@ jobs:
|
|
|
70
67
|
# Skip some extra variants
|
|
71
68
|
- gemfile: Gemfile
|
|
72
69
|
appraisal: true
|
|
73
|
-
- gemfile: gemfiles/rails_5_2.gemfile
|
|
74
|
-
appraisal: false
|
|
75
70
|
- gemfile: gemfiles/rails_6.gemfile
|
|
76
71
|
appraisal: false
|
|
77
72
|
- gemfile: gemfiles/rails_7.gemfile
|
|
78
73
|
appraisal: false
|
|
79
|
-
- gemfile: gemfiles/rails_5_2_graphiti_rails.gemfile
|
|
80
|
-
appraisal: false
|
|
81
74
|
- gemfile: gemfiles/rails_6_graphiti_rails.gemfile
|
|
82
75
|
appraisal: false
|
|
83
76
|
- gemfile: gemfiles/rails_7_graphiti_rails.gemfile
|
|
@@ -92,30 +85,22 @@ jobs:
|
|
|
92
85
|
appraisal: false
|
|
93
86
|
|
|
94
87
|
# Rails 8.1 needs ruby 3.2 +
|
|
95
|
-
- gemfile: gemfiles/rails_8_1_graphiti_rails.gemfile
|
|
96
|
-
ruby: 2.7
|
|
97
88
|
- gemfile: gemfiles/rails_8_1_graphiti_rails.gemfile
|
|
98
89
|
ruby: 3.0
|
|
99
90
|
- gemfile: gemfiles/rails_8_1_graphiti_rails.gemfile
|
|
100
91
|
ruby: 3.1
|
|
101
92
|
|
|
102
93
|
# Rails 8 needs ruby 3.2 +
|
|
103
|
-
- gemfile: gemfiles/rails_8_0_graphiti_rails.gemfile
|
|
104
|
-
ruby: 2.7
|
|
105
94
|
- gemfile: gemfiles/rails_8_0_graphiti_rails.gemfile
|
|
106
95
|
ruby: 3.0
|
|
107
96
|
- gemfile: gemfiles/rails_8_0_graphiti_rails.gemfile
|
|
108
97
|
ruby: 3.1
|
|
109
98
|
|
|
110
99
|
# Rails 7.2 needs ruby 3.1 +
|
|
111
|
-
- gemfile: gemfiles/rails_7_2_graphiti_rails.gemfile
|
|
112
|
-
ruby: 2.7
|
|
113
100
|
- gemfile: gemfiles/rails_7_2_graphiti_rails.gemfile
|
|
114
101
|
ruby: 3.0
|
|
115
102
|
|
|
116
103
|
# Rails 7.1 needs ruby 3.0 +
|
|
117
|
-
- gemfile: gemfiles/rails_7_1_graphiti_rails.gemfile
|
|
118
|
-
ruby: 2.7
|
|
119
104
|
- gemfile: gemfiles/rails_7_1_graphiti_rails.gemfile
|
|
120
105
|
ruby: 3.0
|
|
121
106
|
|
|
@@ -133,27 +118,6 @@ jobs:
|
|
|
133
118
|
- gemfile: gemfiles/rails_6_graphiti_rails.gemfile
|
|
134
119
|
ruby: 3.4
|
|
135
120
|
|
|
136
|
-
# Rails 5 can't run on Ruby 3
|
|
137
|
-
- gemfile: gemfiles/rails_5_2.gemfile
|
|
138
|
-
ruby: 3.0
|
|
139
|
-
- gemfile: gemfiles/rails_5_2_graphiti_rails.gemfile
|
|
140
|
-
ruby: 3.0
|
|
141
|
-
- gemfile: gemfiles/rails_5_2.gemfile
|
|
142
|
-
ruby: 3.1
|
|
143
|
-
- gemfile: gemfiles/rails_5_2_graphiti_rails.gemfile
|
|
144
|
-
ruby: 3.1
|
|
145
|
-
- gemfile: gemfiles/rails_5_2.gemfile
|
|
146
|
-
ruby: 3.2
|
|
147
|
-
- gemfile: gemfiles/rails_5_2_graphiti_rails.gemfile
|
|
148
|
-
ruby: 3.2
|
|
149
|
-
- gemfile: gemfiles/rails_5_2.gemfile
|
|
150
|
-
ruby: 3.3
|
|
151
|
-
- gemfile: gemfiles/rails_5_2_graphiti_rails.gemfile
|
|
152
|
-
ruby: 3.3
|
|
153
|
-
- gemfile: gemfiles/rails_5_2.gemfile
|
|
154
|
-
ruby: 3.4
|
|
155
|
-
- gemfile: gemfiles/rails_5_2_graphiti_rails.gemfile
|
|
156
|
-
ruby: 3.4
|
|
157
121
|
continue-on-error: ${{ matrix.ruby == 'ruby-head' }}
|
|
158
122
|
env: # $BUNDLE_GEMFILE must be set at the job level, so it is set for all steps
|
|
159
123
|
BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}
|
|
@@ -171,6 +135,8 @@ jobs:
|
|
|
171
135
|
publish:
|
|
172
136
|
name: Release
|
|
173
137
|
runs-on: ubuntu-latest
|
|
138
|
+
# Only main auto-releases. Beta releases are cut by manually running
|
|
139
|
+
# "Generate New Release" with the beta branch selected.
|
|
174
140
|
if: github.ref == 'refs/heads/main'
|
|
175
141
|
needs: [test]
|
|
176
142
|
steps:
|
|
@@ -178,4 +144,7 @@ jobs:
|
|
|
178
144
|
uses: benc-uk/workflow-dispatch@v1
|
|
179
145
|
with:
|
|
180
146
|
workflow: Generate New Release
|
|
147
|
+
# Without an explicit ref the dispatch runs against the default
|
|
148
|
+
# branch rather than the branch that triggered it.
|
|
149
|
+
ref: ${{ github.ref }}
|
|
181
150
|
token: ${{ secrets.GITHUB_TOKEN }}
|
data/.standard.yml
CHANGED
data/Appraisals
CHANGED
|
@@ -1,18 +1,3 @@
|
|
|
1
|
-
appraise "rails-5_2" do
|
|
2
|
-
gem "rails", "~> 5.2.0"
|
|
3
|
-
gem "rspec-rails"
|
|
4
|
-
gem "sqlite3", "~> 1.4.0"
|
|
5
|
-
gem "database_cleaner"
|
|
6
|
-
end
|
|
7
|
-
|
|
8
|
-
appraise "rails-5_2-graphiti-rails" do
|
|
9
|
-
gem "rails", "~> 5.2.0"
|
|
10
|
-
gem "rspec-rails"
|
|
11
|
-
gem "sqlite3", "~> 1.4.0"
|
|
12
|
-
gem "database_cleaner"
|
|
13
|
-
gem "graphiti-rails", "~> 0.4.0"
|
|
14
|
-
end
|
|
15
|
-
|
|
16
1
|
appraise "rails-6" do
|
|
17
2
|
gem "rails", "~> 6.0"
|
|
18
3
|
gem "rspec-rails"
|
data/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
graphiti changelog
|
|
2
2
|
|
|
3
|
-
# [
|
|
3
|
+
# [2.0.0-beta.1](https://github.com/graphiti-api/graphiti/compare/v1.12.2...v2.0.0-beta.1) (2026-07-30)
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
### Features
|
|
7
7
|
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* drop Ruby 2.7 and Rails 5.2 support ([e905ddb](https://github.com/graphiti-api/graphiti/commit/e905ddb5c842299ffbfe5ec3c4ff98ec9b603a51))
|
|
9
|
+
* the model you inspect is the model that saves ([#465](https://github.com/graphiti-api/graphiti/issues/465)) ([a905fff](https://github.com/graphiti-api/graphiti/commit/a905fffc3a2aa5e663ff4cadcd632e68f053c81a))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### BREAKING CHANGES
|
|
13
|
+
|
|
14
|
+
* around_persistence hooks receive the assigned model instead of the attributes hash. Move attribute-hash modifications to before_attributes, or set values on the model. Custom create/update overrides that should receive a pre-assigned model must accept an assigned_model: keyword. See UPGRADING.md
|
|
15
|
+
* Ruby >= 3.0 / Rails >= 6 are now required.
|
|
10
16
|
|
|
11
17
|
## [1.12.2](https://github.com/graphiti-api/graphiti/compare/v1.12.1...v1.12.2) (2026-07-29)
|
|
12
18
|
|
data/UPGRADING.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Upgrading Graphiti
|
|
2
|
+
|
|
3
|
+
## 1.x to 2.0
|
|
4
|
+
|
|
5
|
+
### Requirements
|
|
6
|
+
|
|
7
|
+
Graphiti 2.0 requires Ruby 3.0+ and Rails 6.0+ (when using Rails).
|
|
8
|
+
|
|
9
|
+
### The model you inspect is the model that saves
|
|
10
|
+
|
|
11
|
+
Proxies returned by `build` and `find` now apply the request payload lazily, and expose the resulting model before anything is written:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# Creates
|
|
15
|
+
resource = MyResource.build(params)
|
|
16
|
+
resource.data # unsaved model, attributes applied
|
|
17
|
+
resource.data.valid? # inspect before committing to anything
|
|
18
|
+
resource.save # persists that same instance
|
|
19
|
+
|
|
20
|
+
# Updates
|
|
21
|
+
resource = MyResource.find(params)
|
|
22
|
+
resource.assign_attributes(params)
|
|
23
|
+
resource.data.changed # dirty tracking works
|
|
24
|
+
resource.update
|
|
25
|
+
|
|
26
|
+
# or assign and save in one call, Rails-style
|
|
27
|
+
resource = MyResource.find(params)
|
|
28
|
+
resource.update(params)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`assign_attributes` is idempotent per payload, validation still runs before assignment, and the instance you inspect is the instance that saves.
|
|
32
|
+
|
|
33
|
+
### Breaking: around_persistence receives the model, not the attributes hash
|
|
34
|
+
|
|
35
|
+
To make the above hold, attributes are assigned to the model once, before the persistence hooks fire. `around_persistence` now wraps the save of an already-assigned model and receives that model:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
# 1.x
|
|
39
|
+
def do_around_persistence(attributes)
|
|
40
|
+
attributes[:tenant_id] = current_tenant.id
|
|
41
|
+
model = yield
|
|
42
|
+
model.log_saved!
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# 2.0
|
|
46
|
+
def do_around_persistence(model)
|
|
47
|
+
model.tenant_id = current_tenant.id # last chance to touch the model before save, inside the transaction
|
|
48
|
+
saved = yield
|
|
49
|
+
saved.log_saved!
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
To migrate, move attribute-hash modifications to `before_attributes` (which still receives the mutable hash, before assignment), or set the value on the model as above. Hooks that only wrap their yield - transactions, timing, post-save side effects - need no changes. Graphiti 1.x releases warn at runtime when a hook would be affected.
|
|
54
|
+
|
|
55
|
+
`before/around/after_attributes` and `before/around/after_save` are unchanged.
|
|
56
|
+
|
|
57
|
+
### Breaking: custom create/update overrides and pre-assigned models
|
|
58
|
+
|
|
59
|
+
If a resource overrides `create` or `update` and callers inspect the model before saving, the override must accept the already-assigned model:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
def create(attributes, meta = nil, assigned_model: nil)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Without the keyword, saving a proxy whose model was inspected raises `Graphiti::Errors::AssignedModelNotSupported` rather than silently discarding changes made to the inspected instance. Overridden resources used only via plain `save` are unaffected.
|
|
66
|
+
|
|
67
|
+
### Fine print
|
|
68
|
+
|
|
69
|
+
- If you inspect the model before saving, the attributes callbacks run at inspection time (in your controller, outside the save transaction). On the plain `save` path they run inside the transaction, at the same point as 1.x.
|
|
70
|
+
- Writable guards judge persisted state: a guard asking for the model gets a fresh build/find, never the current request's unsaved changes. A payload cannot influence its own authorization.
|
|
71
|
+
- Sideposted child models are still built and assigned during save; `data` exposes the pre-assigned root model only.
|
data/graphiti.gemspec
CHANGED
|
@@ -16,7 +16,7 @@ Gem::Specification.new do |spec|
|
|
|
16
16
|
spec.bindir = "exe"
|
|
17
17
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
18
18
|
spec.require_paths = ["lib"]
|
|
19
|
-
spec.required_ruby_version = ">=
|
|
19
|
+
spec.required_ruby_version = ">= 3.0"
|
|
20
20
|
|
|
21
21
|
# TODO: remove once the 1.12 upgrade window has passed
|
|
22
22
|
spec.post_install_message = <<~MSG
|
data/lib/graphiti/errors.rb
CHANGED
|
@@ -84,6 +84,25 @@ module Graphiti
|
|
|
84
84
|
end
|
|
85
85
|
end
|
|
86
86
|
|
|
87
|
+
class AssignedModelNotSupported < Base
|
|
88
|
+
def initialize(resource_class, method_name)
|
|
89
|
+
@resource_class = resource_class
|
|
90
|
+
@method_name = method_name
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def message
|
|
94
|
+
<<~MSG
|
|
95
|
+
#{@resource_class}: the model was already assigned via #assign_attributes (or #data before save), but ##{@method_name} is overridden without a parameter to receive it, so changes made to the unsaved model would be silently lost.
|
|
96
|
+
|
|
97
|
+
Accept and use the assigned model in your override:
|
|
98
|
+
|
|
99
|
+
def #{@method_name}(attributes, meta = nil, assigned_model: nil)
|
|
100
|
+
|
|
101
|
+
or persist with #save alone, without inspecting the model first. See UPGRADING.md.
|
|
102
|
+
MSG
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
87
106
|
class RemoteWrite < Base
|
|
88
107
|
def initialize(resource_class)
|
|
89
108
|
@resource_class = resource_class
|
|
@@ -467,19 +486,6 @@ module Graphiti
|
|
|
467
486
|
end
|
|
468
487
|
end
|
|
469
488
|
|
|
470
|
-
class InvalidWrapModel < Base
|
|
471
|
-
def initialize(resource_class, model)
|
|
472
|
-
@resource_class = resource_class
|
|
473
|
-
@model = model
|
|
474
|
-
end
|
|
475
|
-
|
|
476
|
-
def message
|
|
477
|
-
<<~MSG
|
|
478
|
-
#{@resource_class.name}.wrap was given a #{@model.class}, but this resource is for #{@resource_class.model} (subclasses are fine).
|
|
479
|
-
MSG
|
|
480
|
-
end
|
|
481
|
-
end
|
|
482
|
-
|
|
483
489
|
class ResourceEndpointConflict < Base
|
|
484
490
|
def initialize(path, action, resource_a, resource_b)
|
|
485
491
|
@path = path
|
data/lib/graphiti/query.rb
CHANGED
|
@@ -4,6 +4,7 @@ module Graphiti
|
|
|
4
4
|
class Query
|
|
5
5
|
attr_reader :resource, :association_name, :params, :action
|
|
6
6
|
|
|
7
|
+
# TODO: make the trailing optionals keywords once the satellite gems are rolled in - see Runner#query for the nil-padding this forces
|
|
7
8
|
def initialize(resource, params, association_name = nil, nested_include = nil, parents = [], action = nil)
|
|
8
9
|
@resource = resource
|
|
9
10
|
@association_name = association_name
|
|
@@ -17,7 +17,6 @@ module Graphiti
|
|
|
17
17
|
return true unless @params.has_key?(:data)
|
|
18
18
|
|
|
19
19
|
resource = @root_resource
|
|
20
|
-
|
|
21
20
|
if @params[:data].has_key?(:type)
|
|
22
21
|
if (meta_type = deserialized_payload.meta[:type].try(:to_sym))
|
|
23
22
|
if @root_resource.type != meta_type && @root_resource.polymorphic?
|
|
@@ -50,31 +50,11 @@ module Graphiti
|
|
|
50
50
|
def build(params, base_scope = nil)
|
|
51
51
|
validate_request!(params)
|
|
52
52
|
runner = Runner.new(self, params)
|
|
53
|
-
runner.proxy(base_scope, single: true, raise_on_missing: true)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
# Wrap models fetched outside graphiti so they render like any other proxy
|
|
57
|
-
def wrap(models, base_scope = nil)
|
|
58
|
-
validate_wrap_models!(models)
|
|
59
|
-
runner = Runner.new(self, {}, nil, :find)
|
|
60
|
-
runner.proxy(base_scope, bypass_required_filters: true).tap do |proxy|
|
|
61
|
-
proxy.data = models
|
|
62
|
-
end
|
|
53
|
+
runner.proxy(base_scope, single: true, raise_on_missing: true, assign_action: :create)
|
|
63
54
|
end
|
|
64
55
|
|
|
65
56
|
private
|
|
66
57
|
|
|
67
|
-
# Skip polymorphic parents - resource_for_model raises a better error for unknown children
|
|
68
|
-
def validate_wrap_models!(models)
|
|
69
|
-
return if abstract_class? || (polymorphic? && !polymorphic_child?)
|
|
70
|
-
|
|
71
|
-
[models].flatten.compact.each do |model|
|
|
72
|
-
unless model.is_a?(self.model)
|
|
73
|
-
raise Errors::InvalidWrapModel.new(self, model)
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
|
|
78
58
|
def caching_options
|
|
79
59
|
{cache: @cache_resource, cache_expires_in: @cache_expires_in, cache_tag: @cache_tag}
|
|
80
60
|
end
|
|
@@ -69,46 +69,57 @@ module Graphiti
|
|
|
69
69
|
end
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
# +model_instance+ is the already-built model from a previous #assign
|
|
73
|
+
# (see ResourceProxy#assign_attributes). When given, attributes are
|
|
74
|
+
# applied to it rather than to a freshly built/found model.
|
|
75
|
+
def assign(assign_params, meta = nil, action_name = nil, model_instance: nil)
|
|
76
|
+
# Only update strips :id (it identifies the record to find) - a create
|
|
77
|
+
# payload may legitimately carry a client-supplied id to assign.
|
|
78
|
+
if action_name == :update
|
|
79
|
+
id = assign_params[:id]
|
|
80
|
+
assign_params = assign_params.except(:id)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
run_callbacks :attributes, action_name, assign_params, meta do |params|
|
|
84
|
+
model_instance ||= if action_name == :update
|
|
85
|
+
self.class._find(id: id).data
|
|
86
|
+
else
|
|
87
|
+
call_with_meta(:build, model, meta)
|
|
88
|
+
end
|
|
89
|
+
call_with_meta(:assign_attributes, model_instance, params, meta)
|
|
90
|
+
model_instance
|
|
91
|
+
end
|
|
75
92
|
|
|
76
|
-
|
|
77
|
-
|
|
93
|
+
model_instance
|
|
94
|
+
end
|
|
78
95
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
96
|
+
# Attributes are assigned before the persistence callbacks fire, so
|
|
97
|
+
# around_persistence receives the assigned model - its pre-yield
|
|
98
|
+
# position is the last chance to touch the model before save, inside
|
|
99
|
+
# the transaction. Modify attributes in before_attributes instead.
|
|
100
|
+
def create(create_params, meta = nil, assigned_model: nil)
|
|
101
|
+
model_instance = assigned_model || assign(create_params, meta, :create)
|
|
84
102
|
|
|
103
|
+
run_callbacks :persistence, :create, model_instance, meta do
|
|
85
104
|
run_callbacks :save, :create, model_instance, meta do
|
|
86
105
|
model_instance = call_with_meta(:save, model_instance, meta)
|
|
87
106
|
end
|
|
88
107
|
|
|
89
108
|
model_instance
|
|
90
109
|
end
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def update(update_params, meta = nil)
|
|
94
|
-
model_instance = nil
|
|
95
|
-
id = update_params[:id]
|
|
96
|
-
update_params = update_params.except(:id)
|
|
97
|
-
|
|
98
|
-
snapshot = attributes_snapshot(:update, update_params)
|
|
99
110
|
|
|
100
|
-
|
|
101
|
-
|
|
111
|
+
model_instance
|
|
112
|
+
end
|
|
102
113
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
call_with_meta(:assign_attributes, model_instance, params, meta)
|
|
106
|
-
model_instance
|
|
107
|
-
end
|
|
114
|
+
def update(update_params, meta = nil, assigned_model: nil)
|
|
115
|
+
model_instance = assigned_model || assign(update_params, meta, :update)
|
|
108
116
|
|
|
117
|
+
run_callbacks :persistence, :update, model_instance, meta do
|
|
109
118
|
run_callbacks :save, :update, model_instance, meta do
|
|
110
119
|
model_instance = call_with_meta(:save, model_instance, meta)
|
|
111
120
|
end
|
|
121
|
+
|
|
122
|
+
model_instance
|
|
112
123
|
end
|
|
113
124
|
|
|
114
125
|
model_instance
|
|
@@ -140,37 +151,6 @@ module Graphiti
|
|
|
140
151
|
|
|
141
152
|
private
|
|
142
153
|
|
|
143
|
-
# In Graphiti 2.0, attributes are assigned to the model before the
|
|
144
|
-
# persistence hooks fire, so hash modifications made by an
|
|
145
|
-
# around_persistence hook before its yield will no longer be applied.
|
|
146
|
-
# Snapshot-and-compare detects exactly those hooks: the comparison
|
|
147
|
-
# happens before any attributes-phase callback has run, so hooks that
|
|
148
|
-
# only wrap their yield never trigger the warning.
|
|
149
|
-
def attributes_snapshot(action, params)
|
|
150
|
-
return unless around_persistence_hooks?(action)
|
|
151
|
-
Util::Hash.deep_dup(params)
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
def around_persistence_hooks?(action)
|
|
155
|
-
hooks = self.class.config[:callbacks][:persistence].try(:[], :around) || []
|
|
156
|
-
hooks.any? { |hook| hook[:only].include?(action) }
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def warn_attributes_mutated_in_around_persistence(action, snapshot, params)
|
|
160
|
-
return if snapshot.nil? || snapshot == params
|
|
161
|
-
|
|
162
|
-
changed_keys = (snapshot.keys | params.keys).reject { |key| snapshot[key] == params[key] }
|
|
163
|
-
Graphiti::DEPRECATOR.warn(<<~MSG)
|
|
164
|
-
#{self.class}'s around_persistence hook modified the attributes hash before yield (changed keys: #{changed_keys.map(&:inspect).join(", ")}). In Graphiti 2.0, attributes are assigned to the model before persistence hooks run, and these modifications will be silently ignored. Move the modification to a hook that runs before assignment:
|
|
165
|
-
|
|
166
|
-
before_attributes do |attributes|
|
|
167
|
-
attributes[#{changed_keys.first.inspect}] = ...
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
or set the value on the model itself in a before_save hook. See UPGRADING.md in the Graphiti 2.0 release.
|
|
171
|
-
MSG
|
|
172
|
-
end
|
|
173
|
-
|
|
174
154
|
def run_callbacks(kind, action, *args)
|
|
175
155
|
fire_around_callbacks(kind, action, *args) do |*yieldargs|
|
|
176
156
|
fire_callbacks(kind, :before, action, *yieldargs)
|
data/lib/graphiti/resource.rb
CHANGED
|
@@ -123,9 +123,18 @@ module Graphiti
|
|
|
123
123
|
adapter.disassociate(parent, child, association_name, type)
|
|
124
124
|
end
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
# TODO: make foreign_key a keyword once the satellite gems are rolled in - they call these positionally
|
|
127
|
+
def assign_with_relationships(meta, attributes, relationships, caller_model = nil, foreign_key = nil, model_instance: nil)
|
|
127
128
|
persistence = Graphiti::Util::Persistence \
|
|
128
|
-
.new(self, meta, attributes, relationships, caller_model, foreign_key
|
|
129
|
+
.new(self, meta, attributes, relationships, caller_model, foreign_key,
|
|
130
|
+
assigned_model: model_instance)
|
|
131
|
+
persistence.assign
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def persist_with_relationships(meta, attributes, relationships, caller_model = nil, foreign_key = nil, assigned_model: nil)
|
|
135
|
+
persistence = Graphiti::Util::Persistence \
|
|
136
|
+
.new(self, meta, attributes, relationships, caller_model, foreign_key,
|
|
137
|
+
assigned_model: assigned_model)
|
|
129
138
|
persistence.run
|
|
130
139
|
end
|
|
131
140
|
|
|
@@ -11,6 +11,7 @@ module Graphiti
|
|
|
11
11
|
payload: nil,
|
|
12
12
|
single: false,
|
|
13
13
|
raise_on_missing: false,
|
|
14
|
+
assign_action: nil,
|
|
14
15
|
cache: nil,
|
|
15
16
|
cache_expires_in: nil,
|
|
16
17
|
cache_tag: nil
|
|
@@ -22,6 +23,7 @@ module Graphiti
|
|
|
22
23
|
@payload = payload
|
|
23
24
|
@single = single
|
|
24
25
|
@raise_on_missing = raise_on_missing
|
|
26
|
+
@assign_action = assign_action
|
|
25
27
|
@cache = cache
|
|
26
28
|
@cache_expires_in = cache_expires_in
|
|
27
29
|
@cache_tag = cache_tag
|
|
@@ -80,14 +82,11 @@ module Graphiti
|
|
|
80
82
|
Renderer.new(self, options).as_graphql
|
|
81
83
|
end
|
|
82
84
|
|
|
83
|
-
# Records supplied directly, no scope resolution
|
|
84
|
-
def data=(models)
|
|
85
|
-
@data = models
|
|
86
|
-
[@data].flatten.compact.each { |record| @resource.decorate_record(record) }
|
|
87
|
-
end
|
|
88
|
-
|
|
89
85
|
def data
|
|
90
|
-
@data
|
|
86
|
+
return @data unless @data.nil?
|
|
87
|
+
return assign_attributes(@payload.params) if @assign_action
|
|
88
|
+
|
|
89
|
+
@data = begin
|
|
91
90
|
records = @scope.resolve
|
|
92
91
|
raise Graphiti::Errors::RecordNotFound if records.empty? && raise_on_missing?
|
|
93
92
|
|
|
@@ -95,6 +94,7 @@ module Graphiti
|
|
|
95
94
|
records
|
|
96
95
|
end
|
|
97
96
|
end
|
|
97
|
+
|
|
98
98
|
alias_method :to_a, :data
|
|
99
99
|
alias_method :resolve_data, :data
|
|
100
100
|
|
|
@@ -137,18 +137,56 @@ module Graphiti
|
|
|
137
137
|
@pagination ||= Delegates::Pagination.new(self)
|
|
138
138
|
end
|
|
139
139
|
|
|
140
|
+
# Apply request params to the underlying model without saving it,
|
|
141
|
+
# Rails-style: the params are always passed explicitly, in the same
|
|
142
|
+
# request-params shape find/build accept. They are validated,
|
|
143
|
+
# deserialized, and become the payload #save will persist.
|
|
144
|
+
#
|
|
145
|
+
# Idempotent per params - calling again with params that normalize to
|
|
146
|
+
# the same payload is a no-op, so the attributes callbacks fire once.
|
|
147
|
+
# Different params re-assign onto the same model instance.
|
|
148
|
+
#
|
|
149
|
+
# Note the attributes callbacks fire here, outside any transaction
|
|
150
|
+
# opened during #save - the persistence hooks wrap only the save phase,
|
|
151
|
+
# receiving this assigned model.
|
|
152
|
+
def assign_attributes(params)
|
|
153
|
+
action = @assign_action || :update
|
|
154
|
+
params = normalized_params_copy(params)
|
|
155
|
+
add_endpoint_filter(params, action)
|
|
156
|
+
validator = ::Graphiti::RequestValidator.new(@resource, params, action)
|
|
157
|
+
validator.validate!
|
|
158
|
+
|
|
159
|
+
if @assigned_model && same_write_payload?(validator.deserialized_payload)
|
|
160
|
+
return @assigned_model
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
@payload = validator.deserialized_payload
|
|
164
|
+
@assigned_model = @data = @resource.assign_with_relationships(
|
|
165
|
+
@payload.meta(action: action),
|
|
166
|
+
@payload.attributes,
|
|
167
|
+
@payload.relationships,
|
|
168
|
+
model_instance: @assigned_model || (data if action == :update)
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
140
172
|
def save(action: :create)
|
|
141
173
|
# TODO: remove this. Only used for persisting many-to-many with AR
|
|
142
174
|
# (see activerecord adapter)
|
|
143
175
|
original = Graphiti.context[:namespace]
|
|
144
176
|
begin
|
|
145
177
|
Graphiti.context[:namespace] = action
|
|
146
|
-
|
|
178
|
+
# An assigned model can only come from #assign_attributes, which
|
|
179
|
+
# validated the payload it stored - re-validating here would run the
|
|
180
|
+
# writable guards (and their guard_model lookups) a redundant time.
|
|
181
|
+
unless @assigned_model
|
|
182
|
+
::Graphiti::RequestValidator.new(@resource, @payload.params, action).validate!
|
|
183
|
+
end
|
|
147
184
|
validator = persist {
|
|
148
185
|
@resource.persist_with_relationships \
|
|
149
186
|
@payload.meta(action: action),
|
|
150
187
|
@payload.attributes,
|
|
151
|
-
@payload.relationships
|
|
188
|
+
@payload.relationships,
|
|
189
|
+
assigned_model: @assigned_model
|
|
152
190
|
}
|
|
153
191
|
ensure
|
|
154
192
|
Graphiti.context[:namespace] = original
|
|
@@ -185,12 +223,16 @@ module Graphiti
|
|
|
185
223
|
success
|
|
186
224
|
end
|
|
187
225
|
|
|
188
|
-
|
|
226
|
+
# Rails-style: pass params to assign and save in one call, or call with
|
|
227
|
+
# no arguments to save a payload assigned earlier (via find or
|
|
228
|
+
# #assign_attributes).
|
|
229
|
+
def update(params = nil)
|
|
230
|
+
assign_attributes(params) if params
|
|
189
231
|
resolve_data
|
|
190
232
|
save(action: :update)
|
|
191
233
|
end
|
|
192
234
|
|
|
193
|
-
|
|
235
|
+
alias_method :update_attributes, :update
|
|
194
236
|
|
|
195
237
|
def include_hash
|
|
196
238
|
@include_hash ||= begin
|
|
@@ -247,6 +289,39 @@ module Graphiti
|
|
|
247
289
|
|
|
248
290
|
private
|
|
249
291
|
|
|
292
|
+
# Validation typecasts values and injects ids into the params it is
|
|
293
|
+
# given - work on a deep copy so the caller's hash stays untouched.
|
|
294
|
+
def normalized_params_copy(params)
|
|
295
|
+
if params.respond_to?(:to_unsafe_h)
|
|
296
|
+
params.to_unsafe_h.deep_symbolize_keys
|
|
297
|
+
else
|
|
298
|
+
::Graphiti::Util::Hash.deep_dup(params)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# UpdateValidator enforces that data.id matches the endpoint's filter id.
|
|
303
|
+
# Params that came through find already carry that filter; params passed
|
|
304
|
+
# directly to #assign_attributes usually don't, so merge in the id this
|
|
305
|
+
# proxy was found with. A payload whose data.id names a different record
|
|
306
|
+
# still fails validation with ConflictRequest. Mutates the copy made by
|
|
307
|
+
# #normalized_params_copy, never caller state.
|
|
308
|
+
def add_endpoint_filter(params, action)
|
|
309
|
+
return unless action == :update
|
|
310
|
+
|
|
311
|
+
endpoint_id = @query.filters[:id]
|
|
312
|
+
return if endpoint_id.nil? || params[:filter].try(:[], :id)
|
|
313
|
+
|
|
314
|
+
params[:filter] ||= {}
|
|
315
|
+
params[:filter][:id] = endpoint_id
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Compare only the write payload (data + included) - a repeat call whose
|
|
319
|
+
# params differ in read-side keys like sort or page is still a no-op.
|
|
320
|
+
def same_write_payload?(deserialized_payload)
|
|
321
|
+
deserialized_payload.params.values_at(:data, :included) ==
|
|
322
|
+
@payload.params.values_at(:data, :included)
|
|
323
|
+
end
|
|
324
|
+
|
|
250
325
|
def persist
|
|
251
326
|
transaction_response = @resource.transaction do
|
|
252
327
|
::Graphiti::Util::TransactionHooksRecorder.record do
|
data/lib/graphiti/runner.rb
CHANGED
|
@@ -3,12 +3,12 @@ module Graphiti
|
|
|
3
3
|
attr_reader :params
|
|
4
4
|
attr_reader :deserialized_payload
|
|
5
5
|
|
|
6
|
+
# TODO: make query and action keywords once the satellite gems are rolled in - they instantiate Runner positionally
|
|
6
7
|
def initialize(resource_class, params, query = nil, action = nil)
|
|
7
8
|
@resource_class = resource_class
|
|
8
9
|
@params = params
|
|
9
10
|
@query = query
|
|
10
11
|
@action = action
|
|
11
|
-
|
|
12
12
|
validator = RequestValidator.new(jsonapi_resource, params, action)
|
|
13
13
|
validator.validate!
|
|
14
14
|
|
|
@@ -77,6 +77,7 @@ module Graphiti
|
|
|
77
77
|
payload: deserialized_payload,
|
|
78
78
|
single: opts[:single],
|
|
79
79
|
raise_on_missing: opts[:raise_on_missing],
|
|
80
|
+
assign_action: opts[:assign_action],
|
|
80
81
|
cache: opts[:cache],
|
|
81
82
|
cache_expires_in: opts[:cache_expires_in],
|
|
82
83
|
cache_tag: opts[:cache_tag]
|
data/lib/graphiti/serializer.rb
CHANGED
data/lib/graphiti/util/hash.rb
CHANGED
|
@@ -55,8 +55,15 @@ module Graphiti
|
|
|
55
55
|
else
|
|
56
56
|
{}.tap do |duped|
|
|
57
57
|
hash.each_pair do |key, value|
|
|
58
|
-
value =
|
|
59
|
-
|
|
58
|
+
value = if value.is_a?(Hash)
|
|
59
|
+
deep_dup(value)
|
|
60
|
+
elsif value.is_a?(Array)
|
|
61
|
+
value.map { |element| element.is_a?(Hash) ? deep_dup(element) : element }
|
|
62
|
+
elsif value&.respond_to?(:dup) && ![Symbol, Integer].include?(value.class)
|
|
63
|
+
value.dup
|
|
64
|
+
else
|
|
65
|
+
value
|
|
66
|
+
end
|
|
60
67
|
duped[key] = value
|
|
61
68
|
end
|
|
62
69
|
end
|
|
@@ -7,8 +7,12 @@ class Graphiti::Util::Persistence
|
|
|
7
7
|
# @param [Hash] relationships see (Deserializer#relationships)
|
|
8
8
|
# @param [Model] caller_model The persisted parent object in the request graph
|
|
9
9
|
# @param [Symbol] foreign_key Attribute assigned by parent object in graph
|
|
10
|
-
|
|
10
|
+
# @param [Model] assigned_model a model already built by #assign, to be
|
|
11
|
+
# assigned onto rather than rebuilt
|
|
12
|
+
# TODO: make foreign_key a keyword once the satellite gems are rolled in
|
|
13
|
+
def initialize(resource, meta, attributes, relationships, caller_model, foreign_key = nil, assigned_model: nil)
|
|
11
14
|
@resource = resource
|
|
15
|
+
@assigned_model = assigned_model
|
|
12
16
|
@meta = meta
|
|
13
17
|
@attributes = attributes
|
|
14
18
|
@relationships = relationships
|
|
@@ -24,6 +28,14 @@ class Graphiti::Util::Persistence
|
|
|
24
28
|
end
|
|
25
29
|
end
|
|
26
30
|
|
|
31
|
+
def assign
|
|
32
|
+
attributes = @adapter.persistence_attributes(self, @attributes)
|
|
33
|
+
assigned = @resource.assign(attributes, metadata, @meta[:method], model_instance: @assigned_model)
|
|
34
|
+
@resource.decorate_record(assigned)
|
|
35
|
+
|
|
36
|
+
assigned
|
|
37
|
+
end
|
|
38
|
+
|
|
27
39
|
# Perform the actual save logic.
|
|
28
40
|
#
|
|
29
41
|
# belongs_to must be processed before/separately from has_many -
|
|
@@ -46,11 +58,13 @@ class Graphiti::Util::Persistence
|
|
|
46
58
|
def run
|
|
47
59
|
attributes = @adapter.persistence_attributes(self, @attributes)
|
|
48
60
|
|
|
61
|
+
payload_attributes = attributes.dup
|
|
49
62
|
parents = @adapter.process_belongs_to(self, attributes)
|
|
63
|
+
apply_derived_attributes(attributes, payload_attributes)
|
|
50
64
|
persisted = persist_object(@meta[:method], attributes)
|
|
51
65
|
@resource.decorate_record(persisted)
|
|
52
|
-
assign_temp_id(persisted, @meta[:temp_id])
|
|
53
66
|
|
|
67
|
+
assign_temp_id(persisted, @meta[:temp_id])
|
|
54
68
|
associate_parents(persisted, parents)
|
|
55
69
|
|
|
56
70
|
children = @adapter.process_has_many(self, persisted)
|
|
@@ -81,6 +95,19 @@ class Graphiti::Util::Persistence
|
|
|
81
95
|
|
|
82
96
|
private
|
|
83
97
|
|
|
98
|
+
# process_belongs_to persists the parents and writes their primary keys into
|
|
99
|
+
# +attributes+. A model that was already assigned (see ResourceProxy#assign_attributes)
|
|
100
|
+
# predates those keys, so apply just them - the payload attributes are already on it,
|
|
101
|
+
# and re-applying them would clobber any changes made since.
|
|
102
|
+
def apply_derived_attributes(attributes, payload_attributes)
|
|
103
|
+
return unless @assigned_model
|
|
104
|
+
|
|
105
|
+
derived = attributes.reject { |key, value| payload_attributes[key] == value }
|
|
106
|
+
return if derived.empty?
|
|
107
|
+
|
|
108
|
+
@resource.assign_attributes(@assigned_model, derived, metadata)
|
|
109
|
+
end
|
|
110
|
+
|
|
84
111
|
def add_hook(prc, lifecycle_event)
|
|
85
112
|
::Graphiti::Util::TransactionHooksRecorder.add(prc, lifecycle_event)
|
|
86
113
|
end
|
|
@@ -163,10 +190,19 @@ class Graphiti::Util::Persistence
|
|
|
163
190
|
}
|
|
164
191
|
end
|
|
165
192
|
|
|
193
|
+
def accepts_assigned_model?(method)
|
|
194
|
+
method.parameters.any? { |type, name| name == :assigned_model && [:key, :keyreq].include?(type) }
|
|
195
|
+
end
|
|
196
|
+
|
|
166
197
|
def call_resource_method(method_name, attributes, caller_model)
|
|
167
198
|
method = @resource.method(method_name)
|
|
168
199
|
|
|
169
|
-
if
|
|
200
|
+
if @assigned_model && [:create, :update].include?(method_name)
|
|
201
|
+
unless accepts_assigned_model?(method)
|
|
202
|
+
raise Graphiti::Errors::AssignedModelNotSupported.new(@resource.class, method_name)
|
|
203
|
+
end
|
|
204
|
+
method.call(attributes, metadata, assigned_model: @assigned_model)
|
|
205
|
+
elsif method.arity == 1
|
|
170
206
|
method.call(attributes)
|
|
171
207
|
else
|
|
172
208
|
method.call(attributes, metadata)
|
data/lib/graphiti/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: graphiti
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 2.0.0.beta.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lee Richmond
|
|
@@ -248,6 +248,7 @@ files:
|
|
|
248
248
|
- LICENSE.txt
|
|
249
249
|
- README.md
|
|
250
250
|
- Rakefile
|
|
251
|
+
- UPGRADING.md
|
|
251
252
|
- bin/appraisal
|
|
252
253
|
- bin/console
|
|
253
254
|
- bin/rspec
|
|
@@ -269,8 +270,6 @@ files:
|
|
|
269
270
|
- deprecated_generators/graphiti/templates/update_request_spec.rb.erb
|
|
270
271
|
- exe/graphiti
|
|
271
272
|
- gemfiles/.bundle/config
|
|
272
|
-
- gemfiles/rails_5_2.gemfile
|
|
273
|
-
- gemfiles/rails_5_2_graphiti_rails.gemfile
|
|
274
273
|
- gemfiles/rails_6.gemfile
|
|
275
274
|
- gemfiles/rails_6_graphiti_rails.gemfile
|
|
276
275
|
- gemfiles/rails_7.gemfile
|
|
@@ -386,12 +385,12 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
386
385
|
requirements:
|
|
387
386
|
- - ">="
|
|
388
387
|
- !ruby/object:Gem::Version
|
|
389
|
-
version: '
|
|
388
|
+
version: '3.0'
|
|
390
389
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
391
390
|
requirements:
|
|
392
|
-
- - "
|
|
391
|
+
- - ">"
|
|
393
392
|
- !ruby/object:Gem::Version
|
|
394
|
-
version:
|
|
393
|
+
version: 1.3.1
|
|
395
394
|
requirements: []
|
|
396
395
|
rubygems_version: 3.3.27
|
|
397
396
|
signing_key:
|
data/gemfiles/rails_5_2.gemfile
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
# This file was generated by Appraisal
|
|
2
|
-
|
|
3
|
-
source "https://rubygems.org"
|
|
4
|
-
|
|
5
|
-
gem "rails", "~> 5.2.0"
|
|
6
|
-
gem "rspec-rails"
|
|
7
|
-
gem "sqlite3", "~> 1.4.0"
|
|
8
|
-
gem "database_cleaner"
|
|
9
|
-
|
|
10
|
-
group :test do
|
|
11
|
-
gem "pry"
|
|
12
|
-
gem "pry-byebug", platform: [:mri]
|
|
13
|
-
gem "appraisal"
|
|
14
|
-
gem "guard"
|
|
15
|
-
gem "guard-rspec"
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
gemspec path: "../"
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
# This file was generated by Appraisal
|
|
2
|
-
|
|
3
|
-
source "https://rubygems.org"
|
|
4
|
-
|
|
5
|
-
gem "rails", "~> 5.2.0"
|
|
6
|
-
gem "rspec-rails"
|
|
7
|
-
gem "sqlite3", "~> 1.4.0"
|
|
8
|
-
gem "database_cleaner"
|
|
9
|
-
gem "graphiti-rails", "~> 0.4.0"
|
|
10
|
-
|
|
11
|
-
group :test do
|
|
12
|
-
gem "pry"
|
|
13
|
-
gem "pry-byebug", platform: [:mri]
|
|
14
|
-
gem "appraisal"
|
|
15
|
-
gem "guard"
|
|
16
|
-
gem "guard-rspec"
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
gemspec path: "../"
|