rails-mmd 0.1.0
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 +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE +21 -0
- data/README.md +82 -0
- data/SECURITY.md +40 -0
- data/docs/active-record-association-support.md +37 -0
- data/docs/assets/branding/rails-mmd-social-preview.jpg +0 -0
- data/docs/usage.md +126 -0
- data/exe/rails-mmd +6 -0
- data/fixtures/schemas/diagnostics/valid/catalog.json +39 -0
- data/lib/rails_mmd/artifact_ref_sanitizer.rb +59 -0
- data/lib/rails_mmd/attribute_types.rb +29 -0
- data/lib/rails_mmd/canonical_json.rb +19 -0
- data/lib/rails_mmd/cli.rb +86 -0
- data/lib/rails_mmd/config.rb +249 -0
- data/lib/rails_mmd/constant_resolver.rb +33 -0
- data/lib/rails_mmd/diagnostic_factory.rb +122 -0
- data/lib/rails_mmd/domain_resolver.rb +97 -0
- data/lib/rails_mmd/exit_policy.rb +27 -0
- data/lib/rails_mmd/generate_command.rb +116 -0
- data/lib/rails_mmd/generation_pipeline.rb +109 -0
- data/lib/rails_mmd/ir_builder.rb +122 -0
- data/lib/rails_mmd/mermaid_serializer.rb +168 -0
- data/lib/rails_mmd/metadata_shapes.rb +55 -0
- data/lib/rails_mmd/model_inventory.rb +144 -0
- data/lib/rails_mmd/ordering.rb +25 -0
- data/lib/rails_mmd/output_directory.rb +192 -0
- data/lib/rails_mmd/publisher.rb +166 -0
- data/lib/rails_mmd/rails_loader.rb +96 -0
- data/lib/rails_mmd/redactor.rb +143 -0
- data/lib/rails_mmd/relationship_builder.rb +982 -0
- data/lib/rails_mmd/render_plan_builder.rb +192 -0
- data/lib/rails_mmd/safe_token_assigner.rb +130 -0
- data/lib/rails_mmd/schema_probe.rb +376 -0
- data/lib/rails_mmd/schema_validator.rb +136 -0
- data/lib/rails_mmd/subject_ids.rb +17 -0
- data/lib/rails_mmd/tooling_tasks.rb +47 -0
- data/lib/rails_mmd/verification_runner.rb +143 -0
- data/lib/rails_mmd/version.rb +5 -0
- data/lib/rails_mmd.rb +15 -0
- data/schemas/config.schema.json +54 -0
- data/schemas/diagnostics.schema.json +418 -0
- data/schemas/ir.schema.json +84 -0
- data/schemas/render_plan.schema.json +136 -0
- metadata +135 -0
|
@@ -0,0 +1,982 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails_mmd/constant_resolver'
|
|
4
|
+
require 'rails_mmd/diagnostic_factory'
|
|
5
|
+
|
|
6
|
+
module RailsMmd
|
|
7
|
+
# Builds selected-domain direct and through relationship records.
|
|
8
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
9
|
+
class RelationshipBuilder
|
|
10
|
+
DomainResult = Struct.new(:domain_id, :relationships, :diagnostics, keyword_init: true)
|
|
11
|
+
Relationship = Struct.new(
|
|
12
|
+
:relationship_id,
|
|
13
|
+
:owner_entity_id,
|
|
14
|
+
:target_entity_id,
|
|
15
|
+
:association_name,
|
|
16
|
+
:foreign_key_column,
|
|
17
|
+
:foreign_type_column,
|
|
18
|
+
:owner_cardinality,
|
|
19
|
+
:target_cardinality,
|
|
20
|
+
keyword_init: true
|
|
21
|
+
)
|
|
22
|
+
RelationshipCandidate = Struct.new(
|
|
23
|
+
:relationship_id,
|
|
24
|
+
:owner_entity_id,
|
|
25
|
+
:target_entity_id,
|
|
26
|
+
:association_name,
|
|
27
|
+
:association_macro,
|
|
28
|
+
:relationship_kind,
|
|
29
|
+
:join_table_name,
|
|
30
|
+
:declaration_owner_entity_id,
|
|
31
|
+
:through_path,
|
|
32
|
+
:foreign_key_holder_entity_id,
|
|
33
|
+
:foreign_key_column,
|
|
34
|
+
:foreign_type_column,
|
|
35
|
+
:referenced_primary_key_column,
|
|
36
|
+
:physical_key,
|
|
37
|
+
:owner_foreign_key_column,
|
|
38
|
+
:target_primary_key_column,
|
|
39
|
+
:foreign_key_unique,
|
|
40
|
+
:foreign_key_constraint,
|
|
41
|
+
:foreign_key_nullable,
|
|
42
|
+
:owner_cardinality,
|
|
43
|
+
:target_cardinality,
|
|
44
|
+
keyword_init: true
|
|
45
|
+
)
|
|
46
|
+
private_constant :RelationshipCandidate
|
|
47
|
+
Result = Struct.new(:domains, :diagnostics, keyword_init: true)
|
|
48
|
+
|
|
49
|
+
ASSOCIATION_NAME_PATTERN = /\A[a-z][a-z0-9]*(?:_[a-z0-9]+)*[!?=]?\z/
|
|
50
|
+
STRUCTURED_COLUMN_PATTERN = /\A[a-z][a-z0-9]*(?:_[a-z0-9]+)*\z/
|
|
51
|
+
MACRO_PRIORITY = { belongs_to: 0, has_one: 1, has_many: 2 }.freeze
|
|
52
|
+
ReflectionEntry = Struct.new(:owner, :reflection, keyword_init: true)
|
|
53
|
+
DomainContext = Struct.new(:domain, keyword_init: false) do
|
|
54
|
+
def domain_id = domain.domain_id
|
|
55
|
+
|
|
56
|
+
def entities = domain.entities
|
|
57
|
+
|
|
58
|
+
def join_table_by_name
|
|
59
|
+
@join_table_by_name ||= Array(domain.join_tables).to_h { |table| [table.table_name, table] }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def entity_by_constant
|
|
63
|
+
@entity_by_constant ||= entities.to_h { |entity| [entity.ruby_constant, entity] }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def initialize(constant_resolver:, diagnostics: DiagnosticFactory.new)
|
|
68
|
+
@constant_resolver = ConstantResolver.wrap(constant_resolver)
|
|
69
|
+
@diagnostic_factory = diagnostics
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def build(domains:)
|
|
73
|
+
domain_results = domains.map { |domain| build_domain(domain) }
|
|
74
|
+
|
|
75
|
+
Result.new(domains: domain_results, diagnostics: domain_results.flat_map(&:diagnostics))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
attr_reader :constant_resolver, :diagnostic_factory
|
|
81
|
+
|
|
82
|
+
def build_domain(domain)
|
|
83
|
+
context = DomainContext.new(domain)
|
|
84
|
+
relationships = []
|
|
85
|
+
output_diagnostics = []
|
|
86
|
+
|
|
87
|
+
entries = reflection_entries(context)
|
|
88
|
+
polymorphic_entries, ordinary_entries = entries.partition do |entry|
|
|
89
|
+
polymorphic_inventory?(entry.reflection)
|
|
90
|
+
end
|
|
91
|
+
ordinary_entries.each do |entry|
|
|
92
|
+
relationship, diagnostic = classify_reflection(context, entry.owner, entry.reflection)
|
|
93
|
+
relationships << relationship if relationship
|
|
94
|
+
output_diagnostics << diagnostic if diagnostic
|
|
95
|
+
end
|
|
96
|
+
polymorphic_relationships, polymorphic_diagnostics = build_polymorphic_relationships(
|
|
97
|
+
context, polymorphic_entries
|
|
98
|
+
)
|
|
99
|
+
relationships.concat(polymorphic_relationships)
|
|
100
|
+
output_diagnostics.concat(polymorphic_diagnostics)
|
|
101
|
+
|
|
102
|
+
DomainResult.new(
|
|
103
|
+
domain_id: domain.domain_id,
|
|
104
|
+
relationships: deduplicate_relationships(relationships).sort_by(&:relationship_id),
|
|
105
|
+
diagnostics: output_diagnostics
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def reflection_entries(context)
|
|
110
|
+
context.entities.flat_map do |owner|
|
|
111
|
+
owner_model = resolve_model(owner.ruby_constant)
|
|
112
|
+
next [] unless owner_model
|
|
113
|
+
|
|
114
|
+
association_reflections(owner_model).map do |reflection|
|
|
115
|
+
ReflectionEntry.new(owner: owner, reflection: reflection)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def association_reflections(model)
|
|
121
|
+
if model.respond_to?(:reflect_on_all_associations)
|
|
122
|
+
Array(model.reflect_on_all_associations)
|
|
123
|
+
elsif model.respond_to?(:reflections)
|
|
124
|
+
model.reflections.values
|
|
125
|
+
else
|
|
126
|
+
[]
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def classify_reflection(context, owner, reflection)
|
|
131
|
+
return build_belongs_to_reflection(context, owner, reflection) if reflection_macro(reflection) == :belongs_to
|
|
132
|
+
return build_habtm_reflection(context, owner, reflection) if habtm?(reflection)
|
|
133
|
+
return build_through_reflection(context, owner, reflection) if through?(reflection)
|
|
134
|
+
return build_direct_reflection(context, owner, reflection) if direct_has?(reflection)
|
|
135
|
+
|
|
136
|
+
[nil, omitted_macro(context, owner, reflection)]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def habtm?(reflection)
|
|
140
|
+
reflection_macro(reflection) == :has_and_belongs_to_many
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def build_habtm_reflection(context, owner, reflection)
|
|
144
|
+
association_name = reflection_name(reflection)
|
|
145
|
+
sanitized_name = sanitize_association_name(association_name)
|
|
146
|
+
return omitted(context, owner, association_name, 'ASSOCIATION_SCOPED_OMITTED') if scoped?(reflection)
|
|
147
|
+
return omitted(context, owner, association_name, 'ASSOCIATION_NAME_UNSUPPORTED_OMITTED') unless sanitized_name
|
|
148
|
+
|
|
149
|
+
target_model, target_constant = resolve_target(reflection)
|
|
150
|
+
unless target_model
|
|
151
|
+
return omitted(context, owner, sanitized_name, 'ASSOCIATION_TARGET_UNRESOLVED',
|
|
152
|
+
target_constant)
|
|
153
|
+
end
|
|
154
|
+
unless renderable_model?(target_model)
|
|
155
|
+
return omitted(context, owner, sanitized_name,
|
|
156
|
+
'ASSOCIATION_TARGET_NOT_RENDERABLE_OMITTED', target_constant)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
target = context.entity_by_constant[target_constant]
|
|
160
|
+
return omitted(context, owner, sanitized_name, 'DOMAIN_RELATIONSHIP_OMITTED', target_constant) unless target
|
|
161
|
+
|
|
162
|
+
habtm_key_metadata = habtm_keys(reflection)
|
|
163
|
+
unless habtm_key_metadata
|
|
164
|
+
return omitted(context, owner, sanitized_name, 'ASSOCIATION_COMPOSITE_KEY_OMITTED', target_constant)
|
|
165
|
+
end
|
|
166
|
+
unless habtm_key_metadata.fetch(:owner_primary_key) == owner.primary_key &&
|
|
167
|
+
habtm_key_metadata.fetch(:target_primary_key) == target.primary_key
|
|
168
|
+
return omitted(context, owner, sanitized_name, 'ASSOCIATION_NON_PRIMARY_KEY_OMITTED', target_constant)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
join_table = context.join_table_by_name[habtm_key_metadata.fetch(:join_table)]
|
|
172
|
+
invalid_reason = habtm_join_table_invalid_reason(join_table, habtm_key_metadata)
|
|
173
|
+
if invalid_reason
|
|
174
|
+
return habtm_join_table_invalid(
|
|
175
|
+
context, owner,
|
|
176
|
+
habtm_key_metadata.merge(association_name: sanitized_name, target_constant: target_constant),
|
|
177
|
+
invalid_reason
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
[habtm_relationship_for(owner, target, sanitized_name, habtm_key_metadata), nil]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def habtm_keys(reflection)
|
|
185
|
+
values = {
|
|
186
|
+
join_table: safe_reflection_value(reflection, :join_table),
|
|
187
|
+
owner_column: safe_reflection_value(reflection, :foreign_key),
|
|
188
|
+
target_column: safe_reflection_value(reflection, :association_foreign_key)
|
|
189
|
+
}
|
|
190
|
+
return unless values.values.all? { |value| value.is_a?(String) && value.match?(STRUCTURED_COLUMN_PATTERN) }
|
|
191
|
+
|
|
192
|
+
owner_primary_key = scalar_key(safe_reflection_value(reflection, :active_record_primary_key))
|
|
193
|
+
target_primary_key = scalar_key(safe_reflection_value(reflection, :association_primary_key))
|
|
194
|
+
return unless owner_primary_key && target_primary_key
|
|
195
|
+
|
|
196
|
+
values.merge(owner_primary_key: owner_primary_key, target_primary_key: target_primary_key)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def habtm_join_table_invalid_reason(join_table, habtm_key_metadata)
|
|
200
|
+
return 'ambiguous_columns' if habtm_key_metadata.fetch(:owner_column) == habtm_key_metadata.fetch(:target_column)
|
|
201
|
+
return 'unresolved' unless join_table
|
|
202
|
+
return 'primary_key_present' unless join_table.primary_key.nil?
|
|
203
|
+
|
|
204
|
+
actual = join_table.columns.map(&:name)
|
|
205
|
+
required = [habtm_key_metadata.fetch(:owner_column), habtm_key_metadata.fetch(:target_column)]
|
|
206
|
+
return 'join_column_missing' unless (required - actual).empty?
|
|
207
|
+
|
|
208
|
+
'extra_columns' unless actual.sort == required.sort
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def habtm_join_table_invalid(context, owner, details, reason)
|
|
212
|
+
association_name = details.fetch(:association_name)
|
|
213
|
+
metadata = association_metadata(context.domain_id, owner, association_name, details.fetch(:target_constant))
|
|
214
|
+
.merge(join_table: details.fetch(:join_table), reason: reason)
|
|
215
|
+
[
|
|
216
|
+
nil,
|
|
217
|
+
diagnostic_factory.build(
|
|
218
|
+
code: 'ASSOCIATION_JOIN_TABLE_INVALID',
|
|
219
|
+
message: omission_message(owner, association_name, 'ASSOCIATION_JOIN_TABLE_INVALID'),
|
|
220
|
+
subject_id: "#{owner.table_name}.#{safe_subject_association(association_name)}",
|
|
221
|
+
metadata: metadata
|
|
222
|
+
)
|
|
223
|
+
]
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def habtm_relationship_for(owner, target, association_name, habtm_key_metadata)
|
|
227
|
+
left, right = [
|
|
228
|
+
[entity_id(owner), owner.table_name, habtm_key_metadata.fetch(:owner_column)],
|
|
229
|
+
[entity_id(target), target.table_name, habtm_key_metadata.fetch(:target_column)]
|
|
230
|
+
].sort_by { |entity, _table, column| [entity, column] }
|
|
231
|
+
join_table = habtm_key_metadata.fetch(:join_table)
|
|
232
|
+
relationship_id = [
|
|
233
|
+
'relationships', left[1], 'habtm', join_table, left[2], right[1], right[2]
|
|
234
|
+
].join('/')
|
|
235
|
+
RelationshipCandidate.new(
|
|
236
|
+
relationship_id: relationship_id,
|
|
237
|
+
owner_entity_id: left[0],
|
|
238
|
+
target_entity_id: right[0],
|
|
239
|
+
association_name: association_name,
|
|
240
|
+
association_macro: :has_and_belongs_to_many,
|
|
241
|
+
relationship_kind: :habtm,
|
|
242
|
+
join_table_name: join_table,
|
|
243
|
+
declaration_owner_entity_id: entity_id(owner),
|
|
244
|
+
physical_key: "habtm|#{join_table}|#{left[0]}|#{left[2]}|#{right[0]}|#{right[2]}",
|
|
245
|
+
owner_cardinality: '0..many',
|
|
246
|
+
target_cardinality: '0..many'
|
|
247
|
+
)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def direct_has?(reflection)
|
|
251
|
+
%i[has_many has_one].include?(reflection_macro(reflection))
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def polymorphic_inventory?(reflection)
|
|
255
|
+
polymorphic_root?(reflection) || polymorphic_inverse?(reflection)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def polymorphic_root?(reflection)
|
|
259
|
+
reflection_macro(reflection) == :belongs_to && polymorphic?(reflection)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def polymorphic_inverse?(reflection)
|
|
263
|
+
direct_has?(reflection) && !through?(reflection) && inverse_polymorphic?(reflection)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def build_polymorphic_relationships(context, entries)
|
|
267
|
+
roots = entries.select { |entry| polymorphic_root?(entry.reflection) }
|
|
268
|
+
inverses = entries.select { |entry| polymorphic_inverse?(entry.reflection) }
|
|
269
|
+
relationships = []
|
|
270
|
+
output_diagnostics = []
|
|
271
|
+
handled_inverses = {}.compare_by_identity
|
|
272
|
+
|
|
273
|
+
roots.each do |root|
|
|
274
|
+
root_relationships, root_diagnostics, handled = build_polymorphic_root(context, root, inverses)
|
|
275
|
+
relationships.concat(root_relationships)
|
|
276
|
+
output_diagnostics.concat(root_diagnostics)
|
|
277
|
+
handled.each { |reflection| handled_inverses[reflection] = true }
|
|
278
|
+
end
|
|
279
|
+
inverses.each do |entry|
|
|
280
|
+
next if handled_inverses.key?(entry.reflection)
|
|
281
|
+
|
|
282
|
+
code = scoped?(entry.reflection) ? 'ASSOCIATION_SCOPED_OMITTED' : 'ASSOCIATION_POLYMORPHIC_OMITTED'
|
|
283
|
+
output_diagnostics << omitted(context, entry.owner, reflection_name(entry.reflection),
|
|
284
|
+
code).last
|
|
285
|
+
end
|
|
286
|
+
[relationships, output_diagnostics]
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def build_polymorphic_root(context, root, inverses)
|
|
290
|
+
owner = root.owner
|
|
291
|
+
reflection = root.reflection
|
|
292
|
+
association_name = reflection_name(reflection)
|
|
293
|
+
sanitized_name = sanitize_association_name(association_name)
|
|
294
|
+
return polymorphic_failure(context, owner, association_name, 'ASSOCIATION_SCOPED_OMITTED') if scoped?(reflection)
|
|
295
|
+
unless sanitized_name
|
|
296
|
+
return polymorphic_failure(context, owner, association_name, 'ASSOCIATION_NAME_UNSUPPORTED_OMITTED')
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
polymorphic_keys = polymorphic_key_columns(reflection)
|
|
300
|
+
unless polymorphic_keys
|
|
301
|
+
return polymorphic_failure(context, owner, sanitized_name,
|
|
302
|
+
'ASSOCIATION_COMPOSITE_KEY_OMITTED')
|
|
303
|
+
end
|
|
304
|
+
unless default_polymorphic_keys?(sanitized_name, polymorphic_keys)
|
|
305
|
+
return polymorphic_failure(context, owner, sanitized_name, 'ASSOCIATION_POLYMORPHIC_OMITTED')
|
|
306
|
+
end
|
|
307
|
+
unless polymorphic_keys.values.all? { |column| column_names(owner).include?(column) }
|
|
308
|
+
return polymorphic_failure(context, owner, sanitized_name, 'ASSOCIATION_KEY_COLUMN_MISSING')
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
candidates, candidate_diagnostics, handled = polymorphic_candidates(
|
|
312
|
+
context, root, inverses, sanitized_name, polymorphic_keys
|
|
313
|
+
)
|
|
314
|
+
if candidates.empty?
|
|
315
|
+
candidate_diagnostics << omitted(
|
|
316
|
+
context, owner, sanitized_name, 'ASSOCIATION_POLYMORPHIC_TARGETS_UNRESOLVED'
|
|
317
|
+
).last
|
|
318
|
+
end
|
|
319
|
+
relationships = candidates.map do |candidate|
|
|
320
|
+
polymorphic_relationship_for(owner, candidate.owner, sanitized_name, polymorphic_keys, candidate.reflection)
|
|
321
|
+
end
|
|
322
|
+
[relationships, candidate_diagnostics, handled]
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def polymorphic_failure(context, owner, association_name, code)
|
|
326
|
+
[[], [omitted(context, owner, association_name, code).last], []]
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def polymorphic_key_columns(reflection)
|
|
330
|
+
foreign_key = scalar_key(safe_reflection_value(reflection, :foreign_key))
|
|
331
|
+
foreign_type = scalar_key(safe_reflection_value(reflection, :foreign_type))
|
|
332
|
+
return unless foreign_key && foreign_type
|
|
333
|
+
|
|
334
|
+
{ foreign_key: foreign_key, foreign_type: foreign_type }
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def default_polymorphic_keys?(association_name, polymorphic_keys)
|
|
338
|
+
polymorphic_keys == {
|
|
339
|
+
foreign_key: "#{association_name}_id",
|
|
340
|
+
foreign_type: "#{association_name}_type"
|
|
341
|
+
}
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def polymorphic_candidates(context, root, inverses, association_name, polymorphic_keys)
|
|
345
|
+
valid = []
|
|
346
|
+
output_diagnostics = []
|
|
347
|
+
handled = []
|
|
348
|
+
inverses.each do |candidate|
|
|
349
|
+
next unless inverse_interface(candidate.reflection) == association_name
|
|
350
|
+
|
|
351
|
+
target_model, target_constant = resolve_target(candidate.reflection)
|
|
352
|
+
next unless target_model && target_constant == root.owner.ruby_constant
|
|
353
|
+
|
|
354
|
+
handled << candidate.reflection
|
|
355
|
+
diagnostic = polymorphic_candidate_diagnostic(context, candidate, polymorphic_keys, target_model)
|
|
356
|
+
if diagnostic
|
|
357
|
+
output_diagnostics << diagnostic
|
|
358
|
+
else
|
|
359
|
+
valid << candidate
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
canonical = valid.group_by { |candidate| entity_id(candidate.owner) }.values.map do |duplicates|
|
|
363
|
+
duplicates.min_by do |candidate|
|
|
364
|
+
[MACRO_PRIORITY.fetch(reflection_macro(candidate.reflection)), reflection_name(candidate.reflection)]
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
[canonical, output_diagnostics, handled]
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def polymorphic_candidate_diagnostic(context, candidate, root_keys, target_model)
|
|
371
|
+
reflection = candidate.reflection
|
|
372
|
+
association_name = reflection_name(reflection)
|
|
373
|
+
if scoped?(reflection)
|
|
374
|
+
return omitted(context, candidate.owner, association_name,
|
|
375
|
+
'ASSOCIATION_SCOPED_OMITTED').last
|
|
376
|
+
end
|
|
377
|
+
unless renderable_model?(target_model)
|
|
378
|
+
return omitted(context, candidate.owner, association_name,
|
|
379
|
+
'ASSOCIATION_TARGET_NOT_RENDERABLE_OMITTED', target_model.name).last
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
candidate_keys = polymorphic_inverse_key_columns(reflection)
|
|
383
|
+
unless candidate_keys == root_keys
|
|
384
|
+
return omitted(context, candidate.owner, association_name, 'ASSOCIATION_POLYMORPHIC_OMITTED').last
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
primary_key = scalar_key(safe_reflection_value(reflection, :active_record_primary_key))
|
|
388
|
+
unless primary_key && primary_key == candidate.owner.primary_key
|
|
389
|
+
return omitted(context, candidate.owner, association_name, 'ASSOCIATION_NON_PRIMARY_KEY_OMITTED').last
|
|
390
|
+
end
|
|
391
|
+
return if column_names(candidate.owner).include?(primary_key)
|
|
392
|
+
|
|
393
|
+
omitted(context, candidate.owner, association_name, 'ASSOCIATION_KEY_COLUMN_MISSING').last
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def polymorphic_inverse_key_columns(reflection)
|
|
397
|
+
foreign_key = scalar_key(safe_reflection_value(reflection, :foreign_key))
|
|
398
|
+
foreign_type = scalar_key(safe_reflection_value(reflection, :type))
|
|
399
|
+
return unless foreign_key && foreign_type
|
|
400
|
+
|
|
401
|
+
{ foreign_key: foreign_key, foreign_type: foreign_type }
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def inverse_interface(reflection)
|
|
405
|
+
reflection_options(reflection)[:as].to_s
|
|
406
|
+
rescue LoadError, SyntaxError, StandardError
|
|
407
|
+
''
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def build_direct_reflection(context, owner, reflection)
|
|
411
|
+
association_name = reflection_name(reflection)
|
|
412
|
+
if inverse_polymorphic?(reflection)
|
|
413
|
+
return omitted(context, owner, association_name,
|
|
414
|
+
'ASSOCIATION_POLYMORPHIC_OMITTED')
|
|
415
|
+
end
|
|
416
|
+
return omitted(context, owner, association_name, 'ASSOCIATION_SCOPED_OMITTED') if scoped?(reflection)
|
|
417
|
+
|
|
418
|
+
sanitized_name = sanitize_association_name(association_name)
|
|
419
|
+
return omitted(context, owner, association_name, 'ASSOCIATION_NAME_UNSUPPORTED_OMITTED') unless sanitized_name
|
|
420
|
+
|
|
421
|
+
target_model, target_constant = resolve_target(reflection)
|
|
422
|
+
unless target_model
|
|
423
|
+
return omitted(context, owner, sanitized_name, 'ASSOCIATION_TARGET_UNRESOLVED', target_constant)
|
|
424
|
+
end
|
|
425
|
+
unless renderable_model?(target_model)
|
|
426
|
+
return omitted(context, owner, sanitized_name, 'ASSOCIATION_TARGET_NOT_RENDERABLE_OMITTED', target_constant)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
target = context.entity_by_constant[target_constant]
|
|
430
|
+
return omitted(context, owner, sanitized_name, 'DOMAIN_RELATIONSHIP_OMITTED', target_constant) unless target
|
|
431
|
+
|
|
432
|
+
inverse_keys = inverse_key_columns(reflection, owner)
|
|
433
|
+
unless inverse_keys
|
|
434
|
+
return omitted(context, owner, sanitized_name, 'ASSOCIATION_COMPOSITE_KEY_OMITTED',
|
|
435
|
+
target_constant)
|
|
436
|
+
end
|
|
437
|
+
unless inverse_keys.fetch(:referenced_primary_key) == owner.primary_key
|
|
438
|
+
return omitted(context, owner, sanitized_name, 'ASSOCIATION_NON_PRIMARY_KEY_OMITTED', target_constant)
|
|
439
|
+
end
|
|
440
|
+
unless column_names(target).include?(inverse_keys.fetch(:foreign_key)) &&
|
|
441
|
+
column_names(owner).include?(inverse_keys.fetch(:referenced_primary_key))
|
|
442
|
+
return omitted(context, owner, sanitized_name, 'ASSOCIATION_KEY_COLUMN_MISSING', target_constant)
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
[direct_relationship_for(owner, target, sanitized_name, reflection_macro(reflection), inverse_keys), nil]
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def build_through_reflection(context, owner, reflection)
|
|
449
|
+
association_name = reflection_name(reflection)
|
|
450
|
+
return [nil, omitted_macro(context, owner, reflection)] if explicit_through_source?(reflection)
|
|
451
|
+
if through_source_type?(reflection)
|
|
452
|
+
return omitted(context, owner, association_name, 'ASSOCIATION_POLYMORPHIC_OMITTED')
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
sanitized_name = sanitize_association_name(association_name)
|
|
456
|
+
return omitted(context, owner, association_name, 'ASSOCIATION_NAME_UNSUPPORTED_OMITTED') unless sanitized_name
|
|
457
|
+
|
|
458
|
+
through_reflection = safe_reflection_value(reflection, :through_reflection)
|
|
459
|
+
return omitted(context, owner, sanitized_name, 'ASSOCIATION_THROUGH_UNRESOLVED') unless through_reflection
|
|
460
|
+
|
|
461
|
+
source_reflection = safe_reflection_value(reflection, :source_reflection)
|
|
462
|
+
return omitted(context, owner, sanitized_name, 'ASSOCIATION_SOURCE_UNRESOLVED') unless source_reflection
|
|
463
|
+
|
|
464
|
+
source_lineage = through_source_lineage(source_reflection)
|
|
465
|
+
return omitted(context, owner, sanitized_name, 'ASSOCIATION_SOURCE_UNRESOLVED') unless source_lineage
|
|
466
|
+
|
|
467
|
+
chain = through_chain(reflection)
|
|
468
|
+
return omitted(context, owner, sanitized_name, 'ASSOCIATION_SOURCE_UNRESOLVED') unless chain
|
|
469
|
+
|
|
470
|
+
chain_omission = through_chain_omission_code(chain + source_lineage + [through_reflection])
|
|
471
|
+
return [nil, omitted_macro(context, owner, reflection)] if chain_omission == 'ASSOCIATION_MACRO_OMITTED'
|
|
472
|
+
return omitted(context, owner, sanitized_name, chain_omission) if chain_omission
|
|
473
|
+
|
|
474
|
+
path_names = chain.map { |hop| sanitize_association_name(reflection_name(hop)) }
|
|
475
|
+
path_names[-1] = sanitize_association_name(reflection_name(source_lineage.last))
|
|
476
|
+
if path_names.any?(&:nil?)
|
|
477
|
+
return omitted(context, owner, association_name,
|
|
478
|
+
'ASSOCIATION_NAME_UNSUPPORTED_OMITTED')
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
entities, diagnostic = through_entities(context, owner, sanitized_name, chain)
|
|
482
|
+
return [nil, diagnostic] if diagnostic
|
|
483
|
+
|
|
484
|
+
[through_relationship_for(owner, entities.last, sanitized_name, reflection_macro(reflection), path_names), nil]
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def omitted_macro(context, owner, reflection)
|
|
488
|
+
association_name = reflection_name(reflection)
|
|
489
|
+
diagnostic_factory.build(
|
|
490
|
+
code: 'ASSOCIATION_MACRO_OMITTED',
|
|
491
|
+
message: omission_message(owner, association_name, 'ASSOCIATION_MACRO_OMITTED'),
|
|
492
|
+
subject_id: "#{owner.table_name}.#{safe_subject_association(association_name)}",
|
|
493
|
+
metadata: association_metadata(context.domain_id, owner, association_name, nil).merge(
|
|
494
|
+
association_macro: reflection_macro(reflection).to_s
|
|
495
|
+
)
|
|
496
|
+
)
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def build_belongs_to_reflection(context, owner, reflection)
|
|
500
|
+
association_name = reflection_name(reflection)
|
|
501
|
+
sanitized_name = sanitize_association_name(association_name)
|
|
502
|
+
return omitted(context, owner, association_name, 'ASSOCIATION_POLYMORPHIC_OMITTED') if polymorphic?(reflection)
|
|
503
|
+
return omitted(context, owner, association_name, 'ASSOCIATION_SCOPED_OMITTED') if scoped?(reflection)
|
|
504
|
+
|
|
505
|
+
return omitted(context, owner, association_name, 'ASSOCIATION_NAME_UNSUPPORTED_OMITTED') unless sanitized_name
|
|
506
|
+
|
|
507
|
+
target_model, target_constant = resolve_target(reflection)
|
|
508
|
+
unless target_model
|
|
509
|
+
return omitted(context, owner, sanitized_name, 'ASSOCIATION_TARGET_UNRESOLVED',
|
|
510
|
+
target_constant)
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
unless renderable_model?(target_model)
|
|
514
|
+
return omitted(context, owner, sanitized_name, 'ASSOCIATION_TARGET_NOT_RENDERABLE_OMITTED', target_constant)
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
target = context.entity_by_constant[target_constant]
|
|
518
|
+
return omitted(context, owner, sanitized_name, 'DOMAIN_RELATIONSHIP_OMITTED', target_constant) unless target
|
|
519
|
+
|
|
520
|
+
belongs_to_keys = belongs_to_key_columns(reflection, target)
|
|
521
|
+
if belongs_to_keys.nil?
|
|
522
|
+
return omitted(context, owner, sanitized_name, 'ASSOCIATION_COMPOSITE_KEY_OMITTED', target_constant)
|
|
523
|
+
end
|
|
524
|
+
unless belongs_to_keys.fetch(:association_primary_key) == target.primary_key
|
|
525
|
+
return omitted(context, owner, sanitized_name, 'ASSOCIATION_NON_PRIMARY_KEY_OMITTED', target_constant)
|
|
526
|
+
end
|
|
527
|
+
unless column_names(owner).include?(belongs_to_keys.fetch(:owner_foreign_key)) &&
|
|
528
|
+
column_names(target).include?(belongs_to_keys.fetch(:target_primary_key))
|
|
529
|
+
return omitted(context, owner, sanitized_name, 'ASSOCIATION_KEY_COLUMN_MISSING', target_constant)
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
[belongs_to_relationship_for(owner, target, sanitized_name, belongs_to_keys), nil]
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def belongs_to_relationship_for(owner, target, association_name, belongs_to_keys)
|
|
536
|
+
owner_foreign_key_column = belongs_to_keys.fetch(:owner_foreign_key)
|
|
537
|
+
target_primary_key_column = belongs_to_keys.fetch(:target_primary_key)
|
|
538
|
+
foreign_key_unique = unique_foreign_key_column?(owner, owner_foreign_key_column)
|
|
539
|
+
foreign_key_constraint = foreign_key_constraint?(owner, target, owner_foreign_key_column,
|
|
540
|
+
target_primary_key_column)
|
|
541
|
+
foreign_key_nullable = column_nullable?(owner, owner_foreign_key_column)
|
|
542
|
+
|
|
543
|
+
RelationshipCandidate.new(
|
|
544
|
+
relationship_id: "relationships/#{owner.table_name}/#{association_name}",
|
|
545
|
+
owner_entity_id: entity_id(owner),
|
|
546
|
+
target_entity_id: entity_id(target),
|
|
547
|
+
association_name: association_name,
|
|
548
|
+
association_macro: :belongs_to,
|
|
549
|
+
relationship_kind: :direct,
|
|
550
|
+
foreign_key_holder_entity_id: entity_id(owner),
|
|
551
|
+
foreign_key_column: owner_foreign_key_column,
|
|
552
|
+
referenced_primary_key_column: target_primary_key_column,
|
|
553
|
+
physical_key: physical_key(entity_id(owner), owner_foreign_key_column, entity_id(target),
|
|
554
|
+
target_primary_key_column),
|
|
555
|
+
owner_foreign_key_column: owner_foreign_key_column,
|
|
556
|
+
target_primary_key_column: target_primary_key_column,
|
|
557
|
+
foreign_key_unique: foreign_key_unique,
|
|
558
|
+
foreign_key_constraint: foreign_key_constraint,
|
|
559
|
+
foreign_key_nullable: foreign_key_nullable,
|
|
560
|
+
owner_cardinality: foreign_key_unique ? '0..1' : '0..many',
|
|
561
|
+
target_cardinality: foreign_key_constraint && foreign_key_nullable == false ? '1..1' : '0..1'
|
|
562
|
+
)
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def direct_relationship_for(owner, target, association_name, macro, inverse_keys)
|
|
566
|
+
foreign_key = inverse_keys.fetch(:foreign_key)
|
|
567
|
+
referenced_primary_key = inverse_keys.fetch(:referenced_primary_key)
|
|
568
|
+
foreign_key_constraint = foreign_key_constraint?(target, owner, foreign_key, referenced_primary_key)
|
|
569
|
+
foreign_key_nullable = column_nullable?(target, foreign_key)
|
|
570
|
+
|
|
571
|
+
RelationshipCandidate.new(
|
|
572
|
+
relationship_id: "relationships/#{owner.table_name}/#{association_name}",
|
|
573
|
+
owner_entity_id: entity_id(owner),
|
|
574
|
+
target_entity_id: entity_id(target),
|
|
575
|
+
association_name: association_name,
|
|
576
|
+
association_macro: macro,
|
|
577
|
+
relationship_kind: :direct,
|
|
578
|
+
foreign_key_holder_entity_id: entity_id(target),
|
|
579
|
+
foreign_key_column: foreign_key,
|
|
580
|
+
referenced_primary_key_column: referenced_primary_key,
|
|
581
|
+
physical_key: physical_key(entity_id(target), foreign_key, entity_id(owner), referenced_primary_key),
|
|
582
|
+
owner_foreign_key_column: foreign_key,
|
|
583
|
+
target_primary_key_column: referenced_primary_key,
|
|
584
|
+
foreign_key_unique: unique_foreign_key_column?(target, foreign_key),
|
|
585
|
+
foreign_key_constraint: foreign_key_constraint,
|
|
586
|
+
foreign_key_nullable: foreign_key_nullable,
|
|
587
|
+
owner_cardinality: foreign_key_constraint && foreign_key_nullable == false ? '1..1' : '0..1',
|
|
588
|
+
target_cardinality: macro == :has_one ? '0..1' : '0..many'
|
|
589
|
+
)
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def polymorphic_relationship_for(owner, target, association_name, polymorphic_keys, inverse_reflection)
|
|
593
|
+
foreign_key = polymorphic_keys.fetch(:foreign_key)
|
|
594
|
+
foreign_type = polymorphic_keys.fetch(:foreign_type)
|
|
595
|
+
relationship_id = [
|
|
596
|
+
'relationships', owner.table_name, 'polymorphic', association_name,
|
|
597
|
+
foreign_key, foreign_type, target.table_name
|
|
598
|
+
].join('/')
|
|
599
|
+
singular = reflection_macro(inverse_reflection) == :has_one ||
|
|
600
|
+
unique_foreign_key_columns?(owner, [foreign_type, foreign_key])
|
|
601
|
+
RelationshipCandidate.new(
|
|
602
|
+
relationship_id: relationship_id,
|
|
603
|
+
owner_entity_id: entity_id(owner),
|
|
604
|
+
target_entity_id: entity_id(target),
|
|
605
|
+
association_name: association_name,
|
|
606
|
+
association_macro: :belongs_to,
|
|
607
|
+
relationship_kind: :polymorphic,
|
|
608
|
+
foreign_key_holder_entity_id: entity_id(owner),
|
|
609
|
+
foreign_key_column: foreign_key,
|
|
610
|
+
foreign_type_column: foreign_type,
|
|
611
|
+
referenced_primary_key_column: target.primary_key,
|
|
612
|
+
physical_key: [
|
|
613
|
+
'polymorphic', entity_id(owner), association_name,
|
|
614
|
+
foreign_key, foreign_type, entity_id(target)
|
|
615
|
+
].join('|'),
|
|
616
|
+
owner_foreign_key_column: foreign_key,
|
|
617
|
+
target_primary_key_column: target.primary_key,
|
|
618
|
+
foreign_key_unique: singular,
|
|
619
|
+
foreign_key_constraint: false,
|
|
620
|
+
foreign_key_nullable: column_nullable?(owner, foreign_key) || column_nullable?(owner, foreign_type),
|
|
621
|
+
owner_cardinality: singular ? '0..1' : '0..many',
|
|
622
|
+
target_cardinality: '0..1'
|
|
623
|
+
)
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def through_relationship_for(owner, target, association_name, macro, path_names)
|
|
627
|
+
relationship_id = [
|
|
628
|
+
'relationships', owner.table_name, 'through', *path_names, target.table_name
|
|
629
|
+
].join('/')
|
|
630
|
+
RelationshipCandidate.new(
|
|
631
|
+
relationship_id: relationship_id,
|
|
632
|
+
owner_entity_id: entity_id(owner),
|
|
633
|
+
target_entity_id: entity_id(target),
|
|
634
|
+
association_name: association_name,
|
|
635
|
+
association_macro: macro,
|
|
636
|
+
relationship_kind: :through,
|
|
637
|
+
through_path: path_names,
|
|
638
|
+
physical_key: "through|#{entity_id(owner)}|#{path_names.join('|')}|#{entity_id(target)}",
|
|
639
|
+
owner_cardinality: '0..many',
|
|
640
|
+
target_cardinality: macro == :has_one ? '0..1' : '0..many'
|
|
641
|
+
)
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
def deduplicate_relationships(candidates)
|
|
645
|
+
candidates.group_by(&:physical_key).map do |_key, duplicates|
|
|
646
|
+
relationship_from(canonical_relationship(duplicates))
|
|
647
|
+
end
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def relationship_from(candidate)
|
|
651
|
+
Relationship.new(
|
|
652
|
+
relationship_id: candidate.relationship_id,
|
|
653
|
+
owner_entity_id: candidate.owner_entity_id,
|
|
654
|
+
target_entity_id: candidate.target_entity_id,
|
|
655
|
+
association_name: candidate.association_name,
|
|
656
|
+
foreign_key_column: candidate.foreign_key_column,
|
|
657
|
+
foreign_type_column: candidate.foreign_type_column,
|
|
658
|
+
owner_cardinality: candidate.owner_cardinality,
|
|
659
|
+
target_cardinality: candidate.target_cardinality
|
|
660
|
+
)
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def canonical_relationship(candidates)
|
|
664
|
+
return canonical_through_relationship(candidates) if candidates.first.relationship_kind == :through
|
|
665
|
+
return candidates.min_by(&:relationship_id) if candidates.first.relationship_kind == :polymorphic
|
|
666
|
+
return canonical_habtm_relationship(candidates) if candidates.first.relationship_kind == :habtm
|
|
667
|
+
|
|
668
|
+
winner = candidates.min_by do |candidate|
|
|
669
|
+
[MACRO_PRIORITY.fetch(candidate.association_macro), candidate.relationship_id]
|
|
670
|
+
end
|
|
671
|
+
holder_id = winner.foreign_key_holder_entity_id
|
|
672
|
+
referenced_id = referenced_entity_id(winner)
|
|
673
|
+
canonical = winner.dup
|
|
674
|
+
canonical.relationship_id = canonical_relationship_id(winner, holder_id, referenced_id)
|
|
675
|
+
canonical.owner_entity_id = holder_id
|
|
676
|
+
canonical.target_entity_id = referenced_id
|
|
677
|
+
canonical.owner_cardinality = canonical_holder_cardinality(candidates)
|
|
678
|
+
canonical.target_cardinality = canonical_referenced_cardinality(candidates)
|
|
679
|
+
canonical
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def canonical_habtm_relationship(candidates)
|
|
683
|
+
candidates.min_by do |candidate|
|
|
684
|
+
owner_priority = candidate.declaration_owner_entity_id == candidate.owner_entity_id ? 0 : 1
|
|
685
|
+
[owner_priority, candidate.association_name, candidate.relationship_id]
|
|
686
|
+
end
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
def canonical_through_relationship(candidates)
|
|
690
|
+
candidates.min_by do |candidate|
|
|
691
|
+
[MACRO_PRIORITY.fetch(candidate.association_macro), candidate.association_name, candidate.relationship_id]
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
def referenced_entity_id(relationship)
|
|
696
|
+
return relationship.target_entity_id if relationship.foreign_key_holder_entity_id == relationship.owner_entity_id
|
|
697
|
+
|
|
698
|
+
relationship.owner_entity_id
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
def canonical_relationship_id(relationship, holder_id, referenced_id)
|
|
702
|
+
[
|
|
703
|
+
'relationships', entity_table(holder_id), relationship.foreign_key_column,
|
|
704
|
+
entity_table(referenced_id), relationship.referenced_primary_key_column
|
|
705
|
+
].join('/')
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def entity_table(entity_id)
|
|
709
|
+
entity_id.delete_prefix('entities/')
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
def canonical_holder_cardinality(candidates)
|
|
713
|
+
singular = candidates.any? { |candidate| candidate.association_macro == :has_one || candidate.foreign_key_unique }
|
|
714
|
+
singular ? '0..1' : '0..many'
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
def canonical_referenced_cardinality(candidates)
|
|
718
|
+
required = candidates.any? do |candidate|
|
|
719
|
+
candidate.foreign_key_constraint && candidate.foreign_key_nullable == false
|
|
720
|
+
end
|
|
721
|
+
required ? '1..1' : '0..1'
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
def physical_key(holder_id, foreign_key, referenced_id, primary_key)
|
|
725
|
+
[holder_id, foreign_key, referenced_id, primary_key].join('|')
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
def omitted(context, owner, association_name, code, target_constant = nil)
|
|
729
|
+
[
|
|
730
|
+
nil,
|
|
731
|
+
diagnostic_factory.build(
|
|
732
|
+
code: code,
|
|
733
|
+
message: omission_message(owner, association_name, code),
|
|
734
|
+
subject_id: "#{owner.table_name}.#{safe_subject_association(association_name)}",
|
|
735
|
+
metadata: association_metadata(context.domain_id, owner, association_name, target_constant)
|
|
736
|
+
)
|
|
737
|
+
]
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
def omission_message(owner, association_name, code)
|
|
741
|
+
"#{owner.ruby_constant}.#{association_name} omitted: #{code.downcase}"
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
def association_metadata(domain_id, owner, association_name, target_constant)
|
|
745
|
+
metadata = {
|
|
746
|
+
domain_id: domain_id,
|
|
747
|
+
owner_constant: owner.ruby_constant,
|
|
748
|
+
association_name: association_name.to_s
|
|
749
|
+
}
|
|
750
|
+
metadata[:target_constant] = target_constant if target_constant
|
|
751
|
+
metadata
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
def resolve_target(reflection)
|
|
755
|
+
target_model = target_model_for(reflection)
|
|
756
|
+
[target_model, target_model.respond_to?(:name) ? target_model.name : reflection_class_name(reflection)]
|
|
757
|
+
rescue LoadError, SyntaxError, StandardError
|
|
758
|
+
[nil, reflection_class_name(reflection)]
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
def target_model_for(reflection)
|
|
762
|
+
return constant_resolver.resolve(reflection_class_name(reflection)) unless reflection.respond_to?(:klass)
|
|
763
|
+
|
|
764
|
+
reflection.klass
|
|
765
|
+
rescue NameError
|
|
766
|
+
constant_resolver.resolve(reflection_class_name(reflection))
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
def resolve_model(ruby_constant)
|
|
770
|
+
constant_resolver.resolve(ruby_constant)
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
def renderable_model?(model)
|
|
774
|
+
return false if model.respond_to?(:abstract_class?) && model.abstract_class?
|
|
775
|
+
return false if model.respond_to?(:base_class) && model.base_class != model
|
|
776
|
+
|
|
777
|
+
model.respond_to?(:table_name) && model.table_name.to_s != ''
|
|
778
|
+
rescue LoadError, SyntaxError, StandardError
|
|
779
|
+
false
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
def belongs_to_key_columns(reflection, target)
|
|
783
|
+
owner_foreign_key = scalar_key(reflection_value(reflection, :foreign_key))
|
|
784
|
+
association_primary_key = scalar_key(reflection_value(reflection, :association_primary_key))
|
|
785
|
+
target_primary_key = scalar_key(target.primary_key)
|
|
786
|
+
return unless owner_foreign_key && association_primary_key && target_primary_key
|
|
787
|
+
|
|
788
|
+
{
|
|
789
|
+
owner_foreign_key: owner_foreign_key,
|
|
790
|
+
association_primary_key: association_primary_key,
|
|
791
|
+
target_primary_key: target_primary_key
|
|
792
|
+
}
|
|
793
|
+
end
|
|
794
|
+
|
|
795
|
+
def inverse_key_columns(reflection, owner)
|
|
796
|
+
foreign_key = scalar_key(reflection_value(reflection, :foreign_key))
|
|
797
|
+
referenced_primary_key = scalar_key(reflection_value(reflection, :active_record_primary_key))
|
|
798
|
+
referenced_primary_key ||= scalar_key(owner.primary_key)
|
|
799
|
+
return unless foreign_key && referenced_primary_key
|
|
800
|
+
|
|
801
|
+
{ foreign_key: foreign_key, referenced_primary_key: referenced_primary_key }
|
|
802
|
+
rescue LoadError, SyntaxError, StandardError
|
|
803
|
+
nil
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
def unique_foreign_key_column?(entity, foreign_key_column)
|
|
807
|
+
entity.indexes.any? do |index|
|
|
808
|
+
index.unique == true &&
|
|
809
|
+
Array(index.columns) == [foreign_key_column] &&
|
|
810
|
+
total_plain_index?(index)
|
|
811
|
+
end
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
def unique_foreign_key_columns?(entity, foreign_key_columns)
|
|
815
|
+
entity.indexes.any? do |index|
|
|
816
|
+
index.unique == true &&
|
|
817
|
+
Array(index.columns).sort == foreign_key_columns.sort &&
|
|
818
|
+
total_plain_index?(index)
|
|
819
|
+
end
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
def total_plain_index?(index)
|
|
823
|
+
index.where.nil? && index.expression.nil? && [nil, 'btree', :btree].include?(index.using)
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
def foreign_key_constraint?(holder_entity, referenced_entity, foreign_key_column,
|
|
827
|
+
referenced_primary_key_column)
|
|
828
|
+
holder_entity.foreign_keys.any? do |foreign_key|
|
|
829
|
+
foreign_key.from_table == holder_entity.table_name &&
|
|
830
|
+
foreign_key.column == foreign_key_column &&
|
|
831
|
+
foreign_key.to_table == referenced_entity.table_name &&
|
|
832
|
+
foreign_key.primary_key == referenced_primary_key_column
|
|
833
|
+
end
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
def column_nullable?(entity, column_name)
|
|
837
|
+
entity.columns.find { |column| column.name == column_name }&.nullable
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
def column_names(entity)
|
|
841
|
+
entity.columns.map(&:name)
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
def reflection_value(reflection, method_name)
|
|
845
|
+
return unless reflection.respond_to?(method_name)
|
|
846
|
+
|
|
847
|
+
reflection.public_send(method_name)
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
def safe_reflection_value(reflection, method_name)
|
|
851
|
+
reflection_value(reflection, method_name)
|
|
852
|
+
rescue LoadError, SyntaxError, StandardError
|
|
853
|
+
nil
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
def reflection_options(reflection)
|
|
857
|
+
reflection.respond_to?(:options) ? reflection.options : {}
|
|
858
|
+
end
|
|
859
|
+
|
|
860
|
+
def explicit_through_source?(reflection)
|
|
861
|
+
reflection_options(reflection).key?(:source)
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
def through_source_type?(reflection)
|
|
865
|
+
!reflection_options(reflection)[:source_type].nil?
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
def through_scoped?(reflection)
|
|
869
|
+
return !!reflection.has_scope? if reflection.respond_to?(:has_scope?)
|
|
870
|
+
|
|
871
|
+
scoped?(reflection)
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
def through_chain(reflection)
|
|
875
|
+
chain = Array(reflection.collect_join_chain).reverse
|
|
876
|
+
chain unless chain.empty?
|
|
877
|
+
rescue LoadError, SyntaxError, StandardError
|
|
878
|
+
nil
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
def through_source_lineage(source_reflection)
|
|
882
|
+
lineage = []
|
|
883
|
+
seen = {}.compare_by_identity
|
|
884
|
+
current = source_reflection
|
|
885
|
+
loop do
|
|
886
|
+
return if seen.key?(current)
|
|
887
|
+
|
|
888
|
+
seen[current] = true
|
|
889
|
+
lineage << current
|
|
890
|
+
break if explicit_through_source?(current) || through_source_type?(current) || polymorphic?(current)
|
|
891
|
+
break unless through?(current)
|
|
892
|
+
|
|
893
|
+
current = safe_reflection_value(current, :source_reflection)
|
|
894
|
+
return unless current
|
|
895
|
+
end
|
|
896
|
+
lineage
|
|
897
|
+
rescue LoadError, SyntaxError, StandardError
|
|
898
|
+
nil
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
def through_chain_omission_code(chain)
|
|
902
|
+
return 'ASSOCIATION_MACRO_OMITTED' if chain.any? { |hop| explicit_through_source?(hop) }
|
|
903
|
+
return 'ASSOCIATION_POLYMORPHIC_OMITTED' if chain.any? { |hop| through_source_type?(hop) || polymorphic?(hop) }
|
|
904
|
+
return 'ASSOCIATION_SCOPED_OMITTED' if chain.any? { |hop| through_scoped?(hop) || scoped?(hop) }
|
|
905
|
+
|
|
906
|
+
nil
|
|
907
|
+
rescue LoadError, SyntaxError, StandardError
|
|
908
|
+
'ASSOCIATION_SOURCE_UNRESOLVED'
|
|
909
|
+
end
|
|
910
|
+
|
|
911
|
+
def through_entities(context, owner, association_name, chain)
|
|
912
|
+
entities = []
|
|
913
|
+
chain.each_with_index do |hop, index|
|
|
914
|
+
model, constant = resolve_target(hop)
|
|
915
|
+
code = index.zero? ? 'ASSOCIATION_THROUGH_UNRESOLVED' : 'ASSOCIATION_SOURCE_UNRESOLVED'
|
|
916
|
+
return [nil, omitted(context, owner, association_name, code, constant).last] unless model
|
|
917
|
+
unless renderable_model?(model)
|
|
918
|
+
return [nil, omitted(context, owner, association_name,
|
|
919
|
+
'ASSOCIATION_TARGET_NOT_RENDERABLE_OMITTED', constant).last]
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
entity = context.entity_by_constant[constant]
|
|
923
|
+
unless entity
|
|
924
|
+
return [nil,
|
|
925
|
+
omitted(context, owner, association_name, 'DOMAIN_RELATIONSHIP_OMITTED', constant).last]
|
|
926
|
+
end
|
|
927
|
+
|
|
928
|
+
entities << entity
|
|
929
|
+
end
|
|
930
|
+
[entities, nil]
|
|
931
|
+
end
|
|
932
|
+
|
|
933
|
+
def scalar_key(value)
|
|
934
|
+
value if value.is_a?(String) && !value.empty?
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
def reflection_name(reflection)
|
|
938
|
+
reflection.respond_to?(:name) ? reflection.name.to_s : ''
|
|
939
|
+
end
|
|
940
|
+
|
|
941
|
+
def reflection_class_name(reflection)
|
|
942
|
+
return reflection.class_name if reflection.respond_to?(:class_name)
|
|
943
|
+
|
|
944
|
+
reflection_name(reflection).split('_').map(&:capitalize).join
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
def reflection_macro(reflection)
|
|
948
|
+
reflection.respond_to?(:macro) ? reflection.macro : nil
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
def polymorphic?(reflection)
|
|
952
|
+
reflection.respond_to?(:polymorphic?) && reflection.polymorphic?
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
def scoped?(reflection)
|
|
956
|
+
reflection.respond_to?(:scope) && !!reflection.scope
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
def through?(reflection)
|
|
960
|
+
reflection.respond_to?(:through_reflection?) && reflection.through_reflection?
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
def inverse_polymorphic?(reflection)
|
|
964
|
+
reflection.respond_to?(:type) && !reflection.type.nil?
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
def sanitize_association_name(name)
|
|
968
|
+
return unless name.match?(ASSOCIATION_NAME_PATTERN)
|
|
969
|
+
|
|
970
|
+
name.delete_suffix('?').delete_suffix('!').delete_suffix('=')
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
def safe_subject_association(name)
|
|
974
|
+
sanitized = sanitize_association_name(name.to_s)
|
|
975
|
+
sanitized || 'unsupported'
|
|
976
|
+
end
|
|
977
|
+
|
|
978
|
+
def entity_id(entity)
|
|
979
|
+
"entities/#{entity.table_name}"
|
|
980
|
+
end
|
|
981
|
+
end
|
|
982
|
+
end
|