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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +10 -0
  3. data/LICENSE +21 -0
  4. data/README.md +82 -0
  5. data/SECURITY.md +40 -0
  6. data/docs/active-record-association-support.md +37 -0
  7. data/docs/assets/branding/rails-mmd-social-preview.jpg +0 -0
  8. data/docs/usage.md +126 -0
  9. data/exe/rails-mmd +6 -0
  10. data/fixtures/schemas/diagnostics/valid/catalog.json +39 -0
  11. data/lib/rails_mmd/artifact_ref_sanitizer.rb +59 -0
  12. data/lib/rails_mmd/attribute_types.rb +29 -0
  13. data/lib/rails_mmd/canonical_json.rb +19 -0
  14. data/lib/rails_mmd/cli.rb +86 -0
  15. data/lib/rails_mmd/config.rb +249 -0
  16. data/lib/rails_mmd/constant_resolver.rb +33 -0
  17. data/lib/rails_mmd/diagnostic_factory.rb +122 -0
  18. data/lib/rails_mmd/domain_resolver.rb +97 -0
  19. data/lib/rails_mmd/exit_policy.rb +27 -0
  20. data/lib/rails_mmd/generate_command.rb +116 -0
  21. data/lib/rails_mmd/generation_pipeline.rb +109 -0
  22. data/lib/rails_mmd/ir_builder.rb +122 -0
  23. data/lib/rails_mmd/mermaid_serializer.rb +168 -0
  24. data/lib/rails_mmd/metadata_shapes.rb +55 -0
  25. data/lib/rails_mmd/model_inventory.rb +144 -0
  26. data/lib/rails_mmd/ordering.rb +25 -0
  27. data/lib/rails_mmd/output_directory.rb +192 -0
  28. data/lib/rails_mmd/publisher.rb +166 -0
  29. data/lib/rails_mmd/rails_loader.rb +96 -0
  30. data/lib/rails_mmd/redactor.rb +143 -0
  31. data/lib/rails_mmd/relationship_builder.rb +982 -0
  32. data/lib/rails_mmd/render_plan_builder.rb +192 -0
  33. data/lib/rails_mmd/safe_token_assigner.rb +130 -0
  34. data/lib/rails_mmd/schema_probe.rb +376 -0
  35. data/lib/rails_mmd/schema_validator.rb +136 -0
  36. data/lib/rails_mmd/subject_ids.rb +17 -0
  37. data/lib/rails_mmd/tooling_tasks.rb +47 -0
  38. data/lib/rails_mmd/verification_runner.rb +143 -0
  39. data/lib/rails_mmd/version.rb +5 -0
  40. data/lib/rails_mmd.rb +15 -0
  41. data/schemas/config.schema.json +54 -0
  42. data/schemas/diagnostics.schema.json +418 -0
  43. data/schemas/ir.schema.json +84 -0
  44. data/schemas/render_plan.schema.json +136 -0
  45. 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