graphiti 2.0.0.beta.4 → 2.0.0.beta.6
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 +5 -0
- data/.github/workflows/docs.yml +4 -7
- data/CHANGELOG.md +29 -0
- data/docs/concepts/links.md +3 -3
- data/docs/concepts/relationships.md +109 -9
- data/docs/topics/debugging.md +26 -0
- data/docs/upgrading.md +18 -17
- data/graphiti.gemspec +1 -19
- data/lib/generators/graphiti/resource_generator.rb +25 -1
- data/lib/generators/graphiti/templates/controller.rb.erb +1 -1
- data/lib/graphiti/audit/report.rb +226 -0
- data/lib/graphiti/audit.rb +231 -0
- data/lib/graphiti/errors.rb +47 -0
- data/lib/graphiti/rails/rake_helpers.rb +42 -0
- data/lib/graphiti/request_validators/validator.rb +10 -1
- data/lib/graphiti/resource/configuration.rb +16 -1
- data/lib/graphiti/resource/polymorphism.rb +10 -0
- data/lib/graphiti/schema.rb +1 -1
- data/lib/graphiti/sideload/belongs_to.rb +22 -29
- data/lib/graphiti/sideload.rb +48 -13
- data/lib/graphiti/spec_helpers/matchers.rb +196 -0
- data/lib/graphiti/spec_helpers/rspec.rb +1 -0
- data/lib/graphiti/spec_helpers.rb +1 -0
- data/lib/graphiti/util/serializer_relationships.rb +27 -4
- data/lib/graphiti/version.rb +1 -1
- data/lib/graphiti.rb +2 -0
- data/lib/tasks/graphiti.rake +16 -32
- data/website/docusaurus.config.js +67 -11
- data/website/src/css/custom.css +29 -2
- data/website/static/1.13/assets/img/fancy-cushion.png +0 -0
- data/website/static/1.13/assets/img/home-bg.jpg +0 -0
- data/website/static/1.13/assets/img/sunrise.png +0 -0
- data/website/static/1.13/assets/main.css +4 -4
- data/website/static/CNAME +1 -0
- metadata +9 -9
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
module Graphiti
|
|
2
|
+
class Audit
|
|
3
|
+
class Report
|
|
4
|
+
ANSI = {red: 31, green: 32, yellow: 33, blue: 34, magenta: 35, cyan: 36, bold: 1, dim: 2}.freeze
|
|
5
|
+
|
|
6
|
+
ISSUE_HEADINGS = {
|
|
7
|
+
broken_relationship: "raised while being inspected",
|
|
8
|
+
missing_association_method: "will raise when the relationship is included: the model has no association method",
|
|
9
|
+
missing_guard_method: "will raise whenever the resource renders: the readable guard is not defined",
|
|
10
|
+
missing_sideload_filter: "will raise when the relationship is included: the related resource is missing the filter"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
CHECKLIST = {
|
|
14
|
+
broken_relationship: ["all relationships inspectable", "relationship", "raised while being inspected"],
|
|
15
|
+
missing_association_method: ["all association methods defined", "association method", "missing"],
|
|
16
|
+
missing_guard_method: ["all readable guards defined", "readable guard", "missing"],
|
|
17
|
+
missing_sideload_filter: ["all sideload filters declared", "sideload filter", "missing"],
|
|
18
|
+
loads_on_every_render: ["all id-rendering loads preloaded", "relationship", "loading ids without preloading"]
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
Cell = Struct.new(:raw, :painted) do
|
|
22
|
+
def pad_to(width)
|
|
23
|
+
painted + " " * (width - raw.length)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(rows, color: $stdout.tty?)
|
|
28
|
+
@rows = rows
|
|
29
|
+
@color = color
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_s
|
|
33
|
+
return "graphiti: no relationships found.\n" if @rows.empty?
|
|
34
|
+
|
|
35
|
+
(body + [summary]).join("\n")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def body
|
|
41
|
+
[""] + issue_sections + loading_section + would_load_section + checks_section
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def issue_sections
|
|
45
|
+
row_finding_pairs = @rows.flat_map do |row|
|
|
46
|
+
row.findings.map { |finding| [row, finding] }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
row_finding_pairs
|
|
50
|
+
.group_by { |_, finding| finding.check }
|
|
51
|
+
.sort_by { |check, _| ISSUE_HEADINGS.keys.index(check) || ISSUE_HEADINGS.size }
|
|
52
|
+
.flat_map { |check, group| issue_section(check, group) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def issue_section(check, group)
|
|
56
|
+
findings = group.map(&:last)
|
|
57
|
+
label = findings.first.error? ? "ERROR" : "WARNING"
|
|
58
|
+
color = findings.first.error? ? :red : :yellow
|
|
59
|
+
fixes = findings.filter_map(&:remedy).uniq
|
|
60
|
+
|
|
61
|
+
section_heading(label, color, ISSUE_HEADINGS.fetch(check, check.to_s.tr("_", " "))) +
|
|
62
|
+
lines_grouped_by_resource(group.map { |row, finding| [row, finding.message] }) +
|
|
63
|
+
(fixes.size == 1 ? [fix_line(fixes.first), ""] : [])
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def loading_section
|
|
67
|
+
rows = @rows.select { |row| loads_without_preload?(row) }
|
|
68
|
+
return [] if rows.empty?
|
|
69
|
+
|
|
70
|
+
section_heading("WARNING", :yellow, "rendering resource ids by loading an association the base_scope does not preload") +
|
|
71
|
+
lines_grouped_by_resource(rows.map { |row| [row, nil] }) +
|
|
72
|
+
[" #{paint("That is a query per record rendered.", :dim)}",
|
|
73
|
+
fix_line("preload the association in base_scope, or drop `resource_ids`"),
|
|
74
|
+
" #{paint("See www.graphiti.dev/concepts/relationships#customizing-relationships", :dim)}",
|
|
75
|
+
""]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def loads_without_preload?(row)
|
|
79
|
+
row.resource_ids_source == :load && row.preloaded == false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def would_load_section
|
|
83
|
+
rows = @rows.select(&:would_start_loading?)
|
|
84
|
+
return [] if rows.empty?
|
|
85
|
+
|
|
86
|
+
verb = rows.size == 1 ? "renders" : "render"
|
|
87
|
+
|
|
88
|
+
section_heading("FYI", :blue, "#{count(rows.size, "belongs_to relationship")} #{verb} no resource ids") +
|
|
89
|
+
lines_grouped_by_resource(rows.map { |row| [row, nil] }) +
|
|
90
|
+
[" #{paint("Nothing to fix. A request that includes the relationship still gets its ids.", :dim)}",
|
|
91
|
+
"",
|
|
92
|
+
" #{paint("To render ids on every response, opt in:", :dim)}",
|
|
93
|
+
""] +
|
|
94
|
+
opt_in_lines +
|
|
95
|
+
["",
|
|
96
|
+
" #{paint("Either would load the association on every render. That potential performance cost is why ids are opt-in.", :dim)}",
|
|
97
|
+
" #{paint("Preloading the association in base_scope keeps that load cheap.", :dim)}",
|
|
98
|
+
" #{paint("See www.graphiti.dev/concepts/relationships#belongs-to-resource-ids", :dim)}",
|
|
99
|
+
""]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def checks_section
|
|
103
|
+
failure_counts = @rows.flat_map(&:findings).map(&:check).tally
|
|
104
|
+
loading = @rows.count { |row| loads_without_preload?(row) }
|
|
105
|
+
failure_counts[:loads_on_every_render] = loading if loading > 0
|
|
106
|
+
|
|
107
|
+
no_loads = @rows.none? { |row| row.resource_ids_source == :load }
|
|
108
|
+
|
|
109
|
+
lines = CHECKLIST.filter_map do |check, (passed, failure_noun, failure_suffix)|
|
|
110
|
+
if (failures = failure_counts[check])
|
|
111
|
+
" #{paint("✗", :red)} #{count(failures, failure_noun)} #{failure_suffix}"
|
|
112
|
+
elsif check == :loads_on_every_render && no_loads
|
|
113
|
+
nil
|
|
114
|
+
else
|
|
115
|
+
" #{paint("✓", :green)} #{passed}"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
[paint("checks", :bold), ""] + lines + [""]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def section_heading(label, color, heading)
|
|
123
|
+
["#{paint(label, color, :bold)} #{paint(heading, :bold)}", ""]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def fix_line(text)
|
|
127
|
+
" #{paint("fix:", :green)} #{text}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def lines_grouped_by_resource(pairs)
|
|
131
|
+
width = pairs.map { |row, _| declaration_cell(row).raw.length }.max
|
|
132
|
+
|
|
133
|
+
pairs.group_by { |row, _| row.resource }.sort_by { |resource, _| resource }.flat_map do |resource, group|
|
|
134
|
+
[" #{paint(resource, :bold)}"] +
|
|
135
|
+
group.sort_by { |row, _| row.relationship.to_s }.map { |row, note|
|
|
136
|
+
declaration = declaration_cell(row)
|
|
137
|
+
note ? " #{declaration.pad_to(width)} #{note}" : " #{declaration.painted}"
|
|
138
|
+
} +
|
|
139
|
+
[""]
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def opt_in_lines
|
|
144
|
+
options = [
|
|
145
|
+
[[["resource_ids: ", :cyan], ["true", :yellow]], "on one relationship"],
|
|
146
|
+
[[["self.belongs_to_resource_ids_by_default = ", nil], [":always", :cyan]], "across the whole API"]
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
rows = options.map do |parts, scope|
|
|
150
|
+
[cell(parts), cell([[scope, :dim]])]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
aligned(rows, " ")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def declaration_cell(row)
|
|
157
|
+
cell(declaration_parts(row))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def declaration_parts(row)
|
|
161
|
+
parts = [[row.type.to_s, :blue], [" ", nil], [":#{row.relationship}", :cyan]]
|
|
162
|
+
|
|
163
|
+
row.options.each do |key, value|
|
|
164
|
+
parts << [", ", nil] << ["#{key}: ", :cyan] << option_part(value)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
parts
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def option_part(value)
|
|
171
|
+
case value
|
|
172
|
+
when "..." then ["{ ... }", :dim]
|
|
173
|
+
when true, false then [value.inspect, :yellow]
|
|
174
|
+
when Symbol then [value.inspect, :cyan]
|
|
175
|
+
when String then [value.inspect, :green]
|
|
176
|
+
else [value.inspect, nil]
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def cell(parts)
|
|
181
|
+
Cell.new(
|
|
182
|
+
parts.map { |text, _| text }.join,
|
|
183
|
+
parts.map { |text, style| paint(text, style) }.join
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def aligned(rows, indent)
|
|
188
|
+
widths = rows.transpose.map { |column| column.map { |cell| cell.raw.length }.max }
|
|
189
|
+
|
|
190
|
+
rows.map do |cells|
|
|
191
|
+
line = cells.each_with_index.map { |cell, index|
|
|
192
|
+
index == cells.size - 1 ? cell.painted : cell.pad_to(widths[index])
|
|
193
|
+
}.join(" ")
|
|
194
|
+
indent + line
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def summary
|
|
199
|
+
errors = @rows.count(&:error?)
|
|
200
|
+
loading = @rows.count { |row| loads_without_preload?(row) }
|
|
201
|
+
no_ids = @rows.count(&:would_start_loading?)
|
|
202
|
+
|
|
203
|
+
counts = [
|
|
204
|
+
count(@rows.group_by(&:resource).size, "resource"),
|
|
205
|
+
count(@rows.size, "relationship"),
|
|
206
|
+
paint(count(errors, "error"), errors > 0 ? :red : nil)
|
|
207
|
+
]
|
|
208
|
+
counts << paint("#{loading} loading ids without preloading", :yellow) if loading > 0
|
|
209
|
+
counts << "#{no_ids} without resource ids" if no_ids > 0
|
|
210
|
+
|
|
211
|
+
"graphiti: #{counts.join(", ")}."
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def count(number, noun)
|
|
215
|
+
"#{number} #{noun}#{"s" unless number == 1}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def paint(text, *styles)
|
|
219
|
+
styles = styles.compact
|
|
220
|
+
return text unless @color && styles.any?
|
|
221
|
+
|
|
222
|
+
"\e[#{styles.map { |style| ANSI.fetch(style) }.join(";")}m#{text}\e[0m"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
module Graphiti
|
|
2
|
+
# Resources register themselves as they load, so eager load before auditing.
|
|
3
|
+
class Audit
|
|
4
|
+
Finding = Struct.new(:severity, :check, :message, :remedy, keyword_init: true) do
|
|
5
|
+
def error?
|
|
6
|
+
severity == :error
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
Row = Struct.new(
|
|
11
|
+
:resource,
|
|
12
|
+
:relationship,
|
|
13
|
+
:type,
|
|
14
|
+
:target,
|
|
15
|
+
:resource_ids_source,
|
|
16
|
+
:resource_ids_source_if_always,
|
|
17
|
+
:resource_ids_default,
|
|
18
|
+
:resource_ids_blocker,
|
|
19
|
+
:preloaded,
|
|
20
|
+
:options,
|
|
21
|
+
:findings,
|
|
22
|
+
keyword_init: true
|
|
23
|
+
) do
|
|
24
|
+
def would_start_loading?
|
|
25
|
+
return false if resource_ids_default == :never
|
|
26
|
+
|
|
27
|
+
resource_ids_source != :load && resource_ids_source_if_always == :load
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def severity
|
|
31
|
+
return :error if findings.any?(&:error?)
|
|
32
|
+
findings.empty? ? :ok : :warning
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def error?
|
|
36
|
+
severity == :error
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.run(resources = nil)
|
|
41
|
+
new(resources).run
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.findings(resources = nil)
|
|
45
|
+
run(resources).flat_map(&:findings)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(resources = nil)
|
|
49
|
+
@resources = resources || Graphiti.resources.reject(&:abstract_class?)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def run
|
|
53
|
+
@resources.sort_by { |resource| resource.name.to_s }.flat_map { |resource| rows_for(resource) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def rows_for(resource_class)
|
|
59
|
+
model = inferred_model(resource_class)
|
|
60
|
+
|
|
61
|
+
resource_class.sideloads.map do |name, sideload|
|
|
62
|
+
row_for(resource_class, name, sideload, model)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def row_for(resource_class, name, sideload, model)
|
|
67
|
+
source = resource_ids_source(sideload)
|
|
68
|
+
|
|
69
|
+
Row.new(
|
|
70
|
+
resource: resource_class.name,
|
|
71
|
+
relationship: name,
|
|
72
|
+
type: sideload.type,
|
|
73
|
+
target: target_name(sideload),
|
|
74
|
+
resource_ids_source: source,
|
|
75
|
+
resource_ids_source_if_always: resource_ids_source_if_always(resource_class, sideload),
|
|
76
|
+
resource_ids_default: resource_class.belongs_to_resource_ids_by_default,
|
|
77
|
+
resource_ids_blocker: sideload.resource_ids_blocker,
|
|
78
|
+
preloaded: source == :load ? association_preloaded?(resource_class, sideload) : nil,
|
|
79
|
+
options: declaration_options(sideload),
|
|
80
|
+
findings: [
|
|
81
|
+
missing_association_method(sideload, model),
|
|
82
|
+
missing_guard_method(resource_class, sideload),
|
|
83
|
+
missing_sideload_filter(sideload)
|
|
84
|
+
].compact
|
|
85
|
+
)
|
|
86
|
+
rescue => error
|
|
87
|
+
Row.new(
|
|
88
|
+
resource: resource_class.name,
|
|
89
|
+
relationship: name,
|
|
90
|
+
type: sideload.type,
|
|
91
|
+
resource_ids_source: :none,
|
|
92
|
+
options: {},
|
|
93
|
+
findings: [
|
|
94
|
+
Finding.new(
|
|
95
|
+
severity: :error,
|
|
96
|
+
check: :broken_relationship,
|
|
97
|
+
message: "#{error.class}: #{error.message.strip.lines.first.to_s.strip}"
|
|
98
|
+
)
|
|
99
|
+
]
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def declaration_options(sideload)
|
|
104
|
+
options = sideload.non_default_options
|
|
105
|
+
options[:base_scope] = "..." if sideload.customized_base_scope?
|
|
106
|
+
options[:scope] = "..." if sideload.class.scope_proc
|
|
107
|
+
options[:params] = "..." if sideload.class.params_proc
|
|
108
|
+
options
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def resource_ids_source(sideload)
|
|
112
|
+
return :none unless sideload.render_resource_ids?
|
|
113
|
+
|
|
114
|
+
sideload.resource_ids_blocker.nil? ? :key : :load
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def resource_ids_source_if_always(resource_class, sideload)
|
|
118
|
+
original = resource_class.belongs_to_resource_ids_by_default
|
|
119
|
+
resource_class.belongs_to_resource_ids_by_default = :always
|
|
120
|
+
resource_ids_source(sideload)
|
|
121
|
+
ensure
|
|
122
|
+
resource_class.belongs_to_resource_ids_by_default = original
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def target_name(sideload)
|
|
126
|
+
if sideload.type == :polymorphic_belongs_to
|
|
127
|
+
children = sideload.children.values.filter_map { |child| short_name(child) }
|
|
128
|
+
return children.empty? ? nil : children.join(", ")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
sideload.resource.class.name
|
|
132
|
+
rescue
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def short_name(sideload)
|
|
137
|
+
sideload.resource.class.name&.split("::")&.last
|
|
138
|
+
rescue
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def inferred_model(resource_class)
|
|
143
|
+
model = resource_class.model
|
|
144
|
+
model.is_a?(Class) ? model : nil
|
|
145
|
+
rescue Errors::ModelNotFound
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def missing_association_method(sideload, model)
|
|
150
|
+
return unless model
|
|
151
|
+
return if sideload.type == :polymorphic_belongs_to
|
|
152
|
+
return if model.method_defined?(sideload.association_name)
|
|
153
|
+
return if model.private_method_defined?(sideload.association_name)
|
|
154
|
+
|
|
155
|
+
Finding.new(
|
|
156
|
+
severity: :error,
|
|
157
|
+
check: :missing_association_method,
|
|
158
|
+
message: "#{model.name} has no ##{sideload.association_name} method",
|
|
159
|
+
remedy: "define it, point the relationship at the real association with `as:`, or remove the relationship"
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def missing_guard_method(resource_class, sideload)
|
|
164
|
+
guard = sideload.readable_guard_name
|
|
165
|
+
return unless guard
|
|
166
|
+
return if defines?(resource_class, guard)
|
|
167
|
+
return if defines?(sideload.resource.class, guard)
|
|
168
|
+
|
|
169
|
+
Finding.new(
|
|
170
|
+
severity: :error,
|
|
171
|
+
check: :missing_guard_method,
|
|
172
|
+
message: "##{guard} is defined on neither #{resource_class.name} nor its related resource",
|
|
173
|
+
remedy: "define the guard on either resource"
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def defines?(resource_class, method_name)
|
|
178
|
+
resource_class.method_defined?(method_name) ||
|
|
179
|
+
resource_class.private_method_defined?(method_name)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def missing_sideload_filter(sideload)
|
|
183
|
+
return if builds_its_own_query?(sideload)
|
|
184
|
+
|
|
185
|
+
key = sideload_filter_key(sideload)
|
|
186
|
+
return unless key
|
|
187
|
+
|
|
188
|
+
related = sideload.resource.class
|
|
189
|
+
return if related.filters.key?(key)
|
|
190
|
+
|
|
191
|
+
Finding.new(
|
|
192
|
+
severity: :error,
|
|
193
|
+
check: :missing_sideload_filter,
|
|
194
|
+
message: "#{related.name} is missing `filter #{key.inspect}`",
|
|
195
|
+
remedy: "declare the filter on the related resource"
|
|
196
|
+
)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def builds_its_own_query?(sideload)
|
|
200
|
+
sideload.class.params_proc || sideload.class.scope_proc
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def sideload_filter_key(sideload)
|
|
204
|
+
case sideload.type
|
|
205
|
+
when :has_many, :has_one then sideload.foreign_key
|
|
206
|
+
when :many_to_many then sideload.true_foreign_key
|
|
207
|
+
when :belongs_to then sideload.primary_key
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def association_preloaded?(resource_class, sideload)
|
|
212
|
+
scope = resource_class.new.base_scope
|
|
213
|
+
return nil unless scope.respond_to?(:includes_values)
|
|
214
|
+
|
|
215
|
+
preloads = [scope.includes_values, scope.preload_values, scope.eager_load_values]
|
|
216
|
+
association_names(preloads).include?(sideload.association_name.to_sym)
|
|
217
|
+
rescue
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def association_names(preload_values)
|
|
222
|
+
Array(preload_values).flat_map do |value|
|
|
223
|
+
case value
|
|
224
|
+
when Hash then value.keys.map(&:to_sym)
|
|
225
|
+
when Array then association_names(value)
|
|
226
|
+
else [value.to_sym]
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
data/lib/graphiti/errors.rb
CHANGED
|
@@ -689,6 +689,53 @@ module Graphiti
|
|
|
689
689
|
end
|
|
690
690
|
end
|
|
691
691
|
|
|
692
|
+
class InvalidBelongsToResourceIds < Base
|
|
693
|
+
def initialize(resource_class, value)
|
|
694
|
+
@resource_class = resource_class
|
|
695
|
+
@value = value
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
def message
|
|
699
|
+
<<~MSG
|
|
700
|
+
#{@resource_class.name}: belongs_to_resource_ids_by_default must be one of :foreign_key, :always, or :never. Got #{@value.inspect}.
|
|
701
|
+
|
|
702
|
+
:foreign_key - render resource ids wherever the foreign key already holds the related id (default)
|
|
703
|
+
:always - render them for every belongs_to, loading the association when the foreign key cannot answer
|
|
704
|
+
:never - render none
|
|
705
|
+
|
|
706
|
+
To render resource ids for a collection, ask for it one relationship at a time with `resource_ids: true`.
|
|
707
|
+
MSG
|
|
708
|
+
end
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
class MissingRelationshipMethod < Base
|
|
712
|
+
def initialize(resource_class, sideload, model)
|
|
713
|
+
@resource_class = resource_class
|
|
714
|
+
@sideload = sideload
|
|
715
|
+
@model = model
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
def message
|
|
719
|
+
<<~MSG
|
|
720
|
+
#{@resource_class.name}: relationship #{@sideload.name.inspect} is declared, but #{@model.class.name} has no ##{@sideload.association_name} method.
|
|
721
|
+
|
|
722
|
+
Rendering the relationship reads the association off the model. Define ##{@sideload.association_name} on #{@model.class.name}, point the relationship at the real association with `as:`, or remove the relationship.
|
|
723
|
+
#{resource_ids_note}
|
|
724
|
+
MSG
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
private
|
|
728
|
+
|
|
729
|
+
def resource_ids_note
|
|
730
|
+
return "" unless @sideload.render_resource_ids?
|
|
731
|
+
|
|
732
|
+
<<~MSG
|
|
733
|
+
|
|
734
|
+
resource_ids is set on this relationship, so every render reads the association, not just requests that include it. See www.graphiti.dev/concepts/relationships#customizing-relationships.
|
|
735
|
+
MSG
|
|
736
|
+
end
|
|
737
|
+
end
|
|
738
|
+
|
|
692
739
|
class MissingDependentFilter < Base
|
|
693
740
|
def initialize(resource, filters)
|
|
694
741
|
@resource = resource
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
require "graphiti/rails/test_helpers"
|
|
2
|
+
|
|
3
|
+
module Graphiti
|
|
4
|
+
module Rails
|
|
5
|
+
# Rake's `namespace` takes a block, and a block does not open a new definee,
|
|
6
|
+
# so helpers defined inside one are defined on Object and turn up in every
|
|
7
|
+
# request spec in the host app. They live here to stay off that namespace.
|
|
8
|
+
module RakeHelpers
|
|
9
|
+
extend TestHelpers
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# ::Rails throughout, because bare Rails resolves to Graphiti::Rails here.
|
|
14
|
+
def session
|
|
15
|
+
@session ||= ActionDispatch::Integration::Session.new(::Rails.application)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def setup_rails!
|
|
19
|
+
::Rails.application.eager_load!
|
|
20
|
+
::Rails.application.config.cache_classes = true
|
|
21
|
+
::Rails.application.config.action_controller.perform_caching = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def make_request(path, debug = false)
|
|
25
|
+
if path.split("/").length == 2
|
|
26
|
+
path = "#{ApplicationResource.endpoint_namespace}#{path}"
|
|
27
|
+
end
|
|
28
|
+
path << if path.include?("?")
|
|
29
|
+
"&cache=bust"
|
|
30
|
+
else
|
|
31
|
+
"?cache=bust"
|
|
32
|
+
end
|
|
33
|
+
path = "#{path}&debug=true" if debug
|
|
34
|
+
handle_request_exceptions do
|
|
35
|
+
headers = {Authorization: ENV["AUTHORIZATION_HEADER"]}.compact
|
|
36
|
+
session.get(path.to_s, headers: headers)
|
|
37
|
+
end
|
|
38
|
+
JSON.parse(session.response.body)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -11,10 +11,12 @@ module Graphiti
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def validate
|
|
14
|
+
validate_page
|
|
15
|
+
|
|
14
16
|
# Right now, all requests - even reads - go through the validator
|
|
15
17
|
# In the future these should have their own validation logic, but
|
|
16
18
|
# for now we can just bypass
|
|
17
|
-
return
|
|
19
|
+
return errors.blank? unless @params.has_key?(:data)
|
|
18
20
|
|
|
19
21
|
resource = @root_resource
|
|
20
22
|
if @params[:data].has_key?(:type)
|
|
@@ -47,6 +49,13 @@ module Graphiti
|
|
|
47
49
|
|
|
48
50
|
private
|
|
49
51
|
|
|
52
|
+
def validate_page
|
|
53
|
+
page = @params[:page]
|
|
54
|
+
return if page.nil? || page.respond_to?(:each_pair)
|
|
55
|
+
|
|
56
|
+
@errors.add(:page, :invalid, message: "must be an object")
|
|
57
|
+
end
|
|
58
|
+
|
|
50
59
|
def process_relationships(resource, relationships, payload_path)
|
|
51
60
|
relationships.each_key do |name|
|
|
52
61
|
unless resource.class.sideload(name.to_sym)
|
|
@@ -6,6 +6,16 @@ module Graphiti
|
|
|
6
6
|
DEFAULT_MAX_PAGE_SIZE = 1_000
|
|
7
7
|
|
|
8
8
|
module Overrides
|
|
9
|
+
BELONGS_TO_RESOURCE_IDS_VALUES = [:foreign_key, :always, :never].freeze
|
|
10
|
+
|
|
11
|
+
def belongs_to_resource_ids_by_default=(val)
|
|
12
|
+
unless BELONGS_TO_RESOURCE_IDS_VALUES.include?(val)
|
|
13
|
+
raise Errors::InvalidBelongsToResourceIds.new(self, val)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
|
|
9
19
|
def serializer=(val)
|
|
10
20
|
if val
|
|
11
21
|
if super(Class.new(val))
|
|
@@ -21,6 +31,10 @@ module Graphiti
|
|
|
21
31
|
send(:prepend, Polymorphism)
|
|
22
32
|
end
|
|
23
33
|
|
|
34
|
+
def polymorphic?
|
|
35
|
+
polymorphic.present?
|
|
36
|
+
end
|
|
37
|
+
|
|
24
38
|
def type=(val)
|
|
25
39
|
val = val&.to_sym
|
|
26
40
|
if (val = super)
|
|
@@ -90,7 +104,7 @@ module Graphiti
|
|
|
90
104
|
:attributes_schema_by_default,
|
|
91
105
|
:relationships_readable_by_default,
|
|
92
106
|
:relationships_writable_by_default,
|
|
93
|
-
:
|
|
107
|
+
:belongs_to_resource_ids_by_default,
|
|
94
108
|
:filters_accept_nil_by_default,
|
|
95
109
|
:filters_deny_empty_by_default,
|
|
96
110
|
:graphql_entrypoint,
|
|
@@ -116,6 +130,7 @@ module Graphiti
|
|
|
116
130
|
default(klass, :attributes_schema_by_default, true)
|
|
117
131
|
default(klass, :relationships_readable_by_default, true)
|
|
118
132
|
default(klass, :relationships_writable_by_default, true)
|
|
133
|
+
default(klass, :belongs_to_resource_ids_by_default, :foreign_key)
|
|
119
134
|
default(klass, :filters_accept_nil_by_default, false)
|
|
120
135
|
default(klass, :filters_deny_empty_by_default, false)
|
|
121
136
|
|
|
@@ -8,6 +8,8 @@ module Graphiti
|
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
def serializer_for(model)
|
|
11
|
+
return super unless self.class.polymorphic?
|
|
12
|
+
|
|
11
13
|
if polymorphic_child?
|
|
12
14
|
serializer
|
|
13
15
|
else
|
|
@@ -17,10 +19,14 @@ module Graphiti
|
|
|
17
19
|
end
|
|
18
20
|
|
|
19
21
|
def associate_all(*args)
|
|
22
|
+
return super unless self.class.polymorphic?
|
|
23
|
+
|
|
20
24
|
_associate(:associate_all, *args)
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
def associate(*args)
|
|
28
|
+
return super unless self.class.polymorphic?
|
|
29
|
+
|
|
24
30
|
_associate(:associate, *args)
|
|
25
31
|
end
|
|
26
32
|
|
|
@@ -34,6 +40,8 @@ module Graphiti
|
|
|
34
40
|
|
|
35
41
|
module ClassMethods
|
|
36
42
|
def inherited(klass)
|
|
43
|
+
return super unless polymorphic?
|
|
44
|
+
|
|
37
45
|
klass.type = nil
|
|
38
46
|
klass.model = klass.infer_model
|
|
39
47
|
klass.endpoint = klass.infer_endpoint
|
|
@@ -42,6 +50,8 @@ module Graphiti
|
|
|
42
50
|
end
|
|
43
51
|
|
|
44
52
|
def sideload(name)
|
|
53
|
+
return super unless polymorphic?
|
|
54
|
+
|
|
45
55
|
if (split_on = name.to_s.split(/^on__/)).length > 1
|
|
46
56
|
on_type, name = split_on[1].split("--").map(&:to_sym)
|
|
47
57
|
end
|