rails2qdbd 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.
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rails2qdbd
6
+ # Turns Prism nodes into plain Ruby values without evaluating anything: project code is never run.
7
+ module Literal
8
+ # Returned for anything that is not a literal (method calls, variables, constants, ...).
9
+ UNKNOWN = Object.new.tap { |o| def o.inspect = "Literal::UNKNOWN" }.freeze
10
+
11
+ module_function
12
+
13
+ def value(node)
14
+ case node
15
+ when Prism::StringNode, Prism::SymbolNode then node.unescaped
16
+ when Prism::IntegerNode, Prism::FloatNode then node.value
17
+ when Prism::TrueNode then true
18
+ when Prism::FalseNode then false
19
+ when Prism::NilNode then nil
20
+ when Prism::ArrayNode then all_known(node.elements.map { |element| value(element) })
21
+ when Prism::HashNode, Prism::KeywordHashNode then hash_value(node)
22
+ when Prism::LambdaNode then lambda_value(node)
23
+ when Prism::RegularExpressionNode then regexp(node)
24
+ else UNKNOWN
25
+ end
26
+ end
27
+
28
+ def known?(value) = !value.equal?(UNKNOWN)
29
+
30
+ # [positional nodes, { key_symbol => value node }] of a call's arguments.
31
+ def arguments(call)
32
+ nodes = call.arguments&.arguments || []
33
+ keywords = nodes.last if nodes.last.is_a?(Prism::KeywordHashNode)
34
+ nodes = nodes[0...-1] if keywords
35
+ options = {}
36
+ keywords&.elements&.each do |element|
37
+ next unless element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode)
38
+
39
+ options[element.key.unescaped.to_sym] = element.value
40
+ end
41
+ [nodes, options]
42
+ end
43
+
44
+ # Full constant name of a ConstantReadNode / ConstantPathNode ("ActiveRecord::Base"), else nil.
45
+ def constant_name(node)
46
+ case node
47
+ when Prism::ConstantReadNode then node.name.to_s
48
+ when Prism::ConstantPathNode
49
+ parent = node.parent ? constant_name(node.parent) : ""
50
+ parent && "#{parent}::#{node.name}".delete_prefix("::")
51
+ end
52
+ end
53
+
54
+ def hash_value(node)
55
+ node.elements.each_with_object({}) do |element, result|
56
+ return UNKNOWN unless element.is_a?(Prism::AssocNode)
57
+
58
+ key = value(element.key)
59
+ val = value(element.value)
60
+ return UNKNOWN unless known?(key) && known?(val)
61
+
62
+ result[key.is_a?(String) ? key.to_sym : key] = val
63
+ end
64
+ end
65
+
66
+ def regexp(node)
67
+ flags = (node.ignore_case? ? Regexp::IGNORECASE : 0) | (node.extended? ? Regexp::EXTENDED : 0) |
68
+ (node.multi_line? ? Regexp::MULTILINE : 0)
69
+ Regexp.new(node.unescaped, flags)
70
+ end
71
+
72
+ def all_known(values) = values.all? { |v| known?(v) } ? values : UNKNOWN
73
+
74
+ # Rails dumps SQL defaults as `-> { "now()" }`; the string is the expression.
75
+ def lambda_value(node)
76
+ statements = node.body&.body || []
77
+ statements.size == 1 && statements.first.is_a?(Prism::StringNode) ? statements.first.unescaped : UNKNOWN
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rails2qdbd
6
+ # Reads model files statically into Catalog::Models: associations,
7
+ # enums, attachments, table overrides, abstract classes, STI parents and concerns.
8
+ class ModelScanner
9
+ include Catalog
10
+
11
+ ASSOCIATIONS = %i[belongs_to has_one has_many has_and_belongs_to_many].freeze
12
+ ASSOCIATION_OPTIONS = %i[class_name foreign_key primary_key polymorphic as through join_table
13
+ association_foreign_key foreign_type].freeze
14
+ ATTACHMENTS = { has_one_attached: :one, has_many_attached: :many }.freeze
15
+ ENUM_OPTIONS = %i[prefix suffix scopes default instance_methods validate].freeze
16
+ ROOTS = %w[ActiveRecord::Base].freeze
17
+ # Assumed abstract when the project has no file for it.
18
+ IMPLICIT_ABSTRACT = %w[ApplicationRecord].freeze
19
+
20
+ # One class or concern as written, before deciding whether it is a model.
21
+ Unit = Struct.new(:model, :scopes, :warnings, :concern, keyword_init: true)
22
+
23
+ attr_reader :diagnostics
24
+
25
+ def initialize
26
+ @diagnostics = []
27
+ end
28
+
29
+ # sources: { relative_path => source }. Returns { "Admin::User" => Model } for models only.
30
+ def scan(sources:)
31
+ @units = {}
32
+ sources.sort.each { |path, source| scan_file(path, source) }
33
+ models = @units.values.reject(&:concern).select { |unit| model?(unit.model.name) }
34
+ models.sort_by { |unit| unit.model.name }.to_h do |unit|
35
+ apply_concerns(unit)
36
+ # A concern's warnings arrive with every class that includes it; report them once.
37
+ @diagnostics.concat(unit.warnings.uniq - @diagnostics)
38
+ [unit.model.name, unit.model]
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def scan_file(path, source)
45
+ @path = path
46
+ result = Prism.parse(source)
47
+ return @diagnostics << Diagnostic.new(message: "syntax error, file skipped", path: path) if result.failure?
48
+
49
+ visit_scope(result.value.statements.body, [])
50
+ end
51
+
52
+ # scopes: full names of the enclosing modules/classes, outermost first.
53
+ def visit_scope(statements, scopes)
54
+ statements.each do |node|
55
+ case node
56
+ when Prism::ModuleNode then visit_module(node, scopes)
57
+ when Prism::ClassNode then visit_class(node, scopes)
58
+ end
59
+ end
60
+ end
61
+
62
+ def visit_module(node, scopes)
63
+ name = qualified(Literal.constant_name(node.constant_path), scopes)
64
+ body = node.body&.body || []
65
+ included = body.find { |n| n.is_a?(Prism::CallNode) && n.name == :included && n.receiver.nil? && n.block }
66
+ if included || body.any? { |n| concern_extension?(n) }
67
+ unit = unit_for(name, scopes + [name], concern: true)
68
+ collect(included.block.body&.body || [], unit) if included
69
+ body.each { |n| add_include(n, unit) }
70
+ end
71
+ visit_scope(body, scopes + [name])
72
+ end
73
+
74
+ def visit_class(node, scopes)
75
+ name = qualified(Literal.constant_name(node.constant_path), scopes)
76
+ unit = unit_for(name, scopes + [name], concern: false)
77
+ unit.model.superclass ||= Literal.constant_name(node.superclass)
78
+ unit.model.path ||= @path
79
+ unit.model.line ||= node.location.start_line
80
+ collect(node.body&.body || [], unit)
81
+ visit_scope(node.body&.body || [], scopes + [name])
82
+ end
83
+
84
+ # `extend ActiveSupport::Concern`: the module's includes reach the class, even without `included do`.
85
+ def concern_extension?(node)
86
+ node.is_a?(Prism::CallNode) && node.name == :extend && node.receiver.nil? &&
87
+ node.arguments&.arguments&.any? { |arg| Literal.constant_name(arg) == "ActiveSupport::Concern" }
88
+ end
89
+
90
+ def unit_for(name, scopes, concern:)
91
+ @units[name] ||= Unit.new(model: Model.new(name: name), scopes: scopes, warnings: [], concern: concern)
92
+ end
93
+
94
+ def qualified(name, scopes) = scopes.empty? ? name : "#{scopes.last}::#{name}"
95
+
96
+ # Class-body declarations; `inherited` lets with_options pass its options down.
97
+ def collect(statements, unit, inherited = {})
98
+ statements.each do |node|
99
+ next unless node.is_a?(Prism::CallNode)
100
+
101
+ if node.receiver.is_a?(Prism::SelfNode) then self_assignment(node, unit.model)
102
+ elsif node.receiver.nil? || node.receiver.is_a?(Prism::LocalVariableReadNode)
103
+ declaration(node, unit, inherited)
104
+ end
105
+ end
106
+ end
107
+
108
+ def self_assignment(node, model)
109
+ value = Literal.value(node.arguments&.arguments&.first)
110
+ return unless Literal.known?(value)
111
+
112
+ case node.name
113
+ when :table_name= then model.table_name = value.to_s
114
+ when :primary_key= then model.primary_key = value.is_a?(Array) ? value.map(&:to_s) : value.to_s
115
+ when :abstract_class= then model.abstract = value == true
116
+ end
117
+ end
118
+
119
+ def declaration(node, unit, inherited)
120
+ case node.name
121
+ when *ASSOCIATIONS then association(node, unit, inherited)
122
+ when *ATTACHMENTS.keys then attachment(node, unit.model)
123
+ when :enum then enum(node, unit)
124
+ when :include then add_include(node, unit)
125
+ when :primary_abstract_class then unit.model.abstract = true
126
+ when :with_options then with_options(node, unit, inherited)
127
+ end
128
+ end
129
+
130
+ def association(node, unit, inherited)
131
+ args, options = Literal.arguments(node)
132
+ name = Literal.value(args.first)
133
+ return unless name.is_a?(Symbol) || name.is_a?(String)
134
+
135
+ values = inherited.dup
136
+ options.slice(*ASSOCIATION_OPTIONS).each do |key, value_node|
137
+ value = Literal.value(value_node)
138
+ unless Literal.known?(value)
139
+ return skip(unit, node, "#{unit.model.name} #{node.name} :#{name}: non-literal #{key}, skipped")
140
+ end
141
+
142
+ values[key] = value
143
+ end
144
+ values = values.transform_values { |v| v.is_a?(Symbol) ? v.to_s : v }
145
+ unit.model.associations << Association.new(macro: node.name, name: name.to_s, options: values,
146
+ path: @path, line: node.location.start_line)
147
+ end
148
+
149
+ def with_options(node, unit, inherited)
150
+ return unless node.block
151
+
152
+ _, options = Literal.arguments(node)
153
+ values = options.slice(*ASSOCIATION_OPTIONS).transform_values { |v| Literal.value(v) }
154
+ return skip(unit, node, "#{unit.model.name} with_options: non-literal options, skipped") unless values.values.all? { |v| Literal.known?(v) }
155
+
156
+ collect(node.block.body&.body || [], unit, inherited.merge(values))
157
+ end
158
+
159
+ def attachment(node, model)
160
+ name = Literal.value(node.arguments&.arguments&.first)
161
+ model.attachments << [name.to_s, ATTACHMENTS.fetch(node.name)] if name.is_a?(Symbol) || name.is_a?(String)
162
+ end
163
+
164
+ # enum :status, {...} | enum :status, [...] | enum :status, a: 0, b: 1 | enum status: {...}, _prefix: true
165
+ def enum(node, unit)
166
+ args, options = Literal.arguments(node)
167
+ values = options.reject { |key, _| key.start_with?("_") || ENUM_OPTIONS.include?(key) }
168
+ pairs = if args.any?
169
+ [[Literal.value(args[0]), args[1] ? Literal.value(args[1]) : keyword_values(values)]]
170
+ else
171
+ values.map { |key, value| [key, Literal.value(value)] }
172
+ end
173
+ pairs.each do |name, values|
174
+ next skip(unit, node, "#{unit.model.name} enum :#{name}: non-literal values, skipped") unless values.is_a?(Hash) || values.is_a?(Array)
175
+
176
+ unit.model.enums[name.to_s] = enum_labels(values)
177
+ end
178
+ end
179
+
180
+ # The values of `enum :status, a: 0, b: 1`, or UNKNOWN when there are none or one is not a literal.
181
+ def keyword_values(nodes)
182
+ values = nodes.transform_values { |node| Literal.value(node) }
183
+ values.any? && values.values.all? { |value| Literal.known?(value) } ? values : Literal::UNKNOWN
184
+ end
185
+
186
+ def enum_labels(values)
187
+ return values.map(&:to_s) if values.is_a?(Array)
188
+
189
+ values.map { |key, value| key.to_s == value.to_s ? key.to_s : "#{key} (#{value})" }
190
+ end
191
+
192
+ def add_include(node, unit)
193
+ return unless node.is_a?(Prism::CallNode) && node.name == :include && node.receiver.nil?
194
+
195
+ (node.arguments&.arguments || []).each do |arg|
196
+ name = Literal.constant_name(arg)
197
+ unit.model.includes << name if name
198
+ end
199
+ end
200
+
201
+ def skip(unit, node, message)
202
+ unit.warnings << Diagnostic.new(message: message, path: @path, line: node.location.start_line)
203
+ nil
204
+ end
205
+
206
+ # Constant lookup like Ruby's lexical scope: innermost scope first, then top level.
207
+ def resolve(name, scopes)
208
+ return nil unless name
209
+
210
+ name = name.delete_prefix("::")
211
+ scopes.reverse.map { |scope| "#{scope}::#{name}" }.find { |candidate| @units.key?(candidate) } ||
212
+ (@units.key?(name) ? name : nil)
213
+ end
214
+
215
+ def model?(name, seen = [])
216
+ unit = @units[name]
217
+ return false if unit.nil? || unit.concern || seen.include?(name)
218
+
219
+ superclass = unit.model.superclass
220
+ return false unless superclass
221
+
222
+ resolved = resolve(superclass, unit.scopes[0...-1])
223
+ unit.model.superclass = resolved || superclass
224
+ return model?(resolved, seen + [name]) if resolved
225
+
226
+ ROOTS.include?(superclass.delete_prefix("::")) || IMPLICIT_ABSTRACT.include?(superclass)
227
+ end
228
+
229
+ # A concern's `included do` declarations (and the concerns it includes) land on the class.
230
+ def apply_concerns(unit, seen = [])
231
+ model = unit.model
232
+ unit.model.includes.each do |name|
233
+ concern = @units[resolve(name, unit.scopes)]
234
+ next unless concern&.concern && !seen.include?(concern.model.name)
235
+
236
+ # Expanded on a copy, so the next class including this concern starts from it as written.
237
+ concern = copy(concern)
238
+ apply_concerns(concern, seen + [concern.model.name])
239
+ model.associations.unshift(*concern.model.associations)
240
+ model.attachments.unshift(*concern.model.attachments)
241
+ model.enums.merge!(concern.model.enums) { |_key, own, _theirs| own }
242
+ unit.warnings.concat(concern.warnings)
243
+ end
244
+ end
245
+
246
+ def copy(unit)
247
+ model = unit.model.dup
248
+ model.associations = model.associations.dup
249
+ model.attachments = model.attachments.dup
250
+ model.enums = model.enums.dup
251
+ unit.dup.tap { |u| u.model = model; u.warnings = u.warnings.dup }
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Rails2qdbd
6
+ # Writes converter results to disk, or reports how the disk differs (for --check).
7
+ # Only files starting with the generated header are ever replaced or removed.
8
+ module Output
9
+ module_function
10
+
11
+ # path: a .txt file (single result file) or a directory. Returns the paths actually written.
12
+ # Raises Error, writing nothing, when a destination exists without the generated header.
13
+ def write(result, path)
14
+ foreign = planned(result, path).keys.select { |file| File.exist?(file) && !generated?(file) }
15
+ if foreign.any?
16
+ raise Error, "not generated by rails2qdbd, left untouched (move or delete them to regenerate): #{foreign.join(', ')}"
17
+ end
18
+
19
+ planned(result, path).filter_map do |file, text|
20
+ next if File.file?(file) && File.read(file) == text
21
+
22
+ FileUtils.mkdir_p(File.dirname(file))
23
+ File.write(file, text)
24
+ file
25
+ end.tap { leftovers(result, path).each { |file| File.delete(file) } }
26
+ end
27
+
28
+ # Human-readable list of differences; empty means up to date.
29
+ def stale(result, path)
30
+ problems = planned(result, path).filter_map do |file, text|
31
+ if !File.file?(file) then "#{file} (missing)"
32
+ elsif !generated?(file) then "#{file} (not generated by rails2qdbd)"
33
+ elsif File.read(file) != text then "#{file} (out of date)"
34
+ end
35
+ end
36
+ problems + leftovers(result, path).map { |file| "#{file} (no longer generated)" }
37
+ end
38
+
39
+ def planned(result, path)
40
+ return { path => result.files.values.first } if single_file?(path)
41
+
42
+ result.files.sort.to_h { |name, text| [File.join(path, name), text] }
43
+ end
44
+
45
+ # Generated files in the output directory that this result no longer produces.
46
+ def leftovers(result, path)
47
+ return [] if single_file?(path) || !File.directory?(path)
48
+
49
+ Dir.glob("*.txt", base: path).sort.filter_map do |name|
50
+ file = File.join(path, name)
51
+ file if !result.files.key?(name) && generated?(file)
52
+ end
53
+ end
54
+
55
+ def generated?(file) = File.file?(file) && File.foreach(file).first.to_s.start_with?(Writer::HEADER)
56
+
57
+ def single_file?(path) = path.end_with?(".txt")
58
+ end
59
+ end
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails2qdbd
4
+ # Derives relationships and documentation notes from the schema and the models.
5
+ # Database foreign keys come first and win every conflict.
6
+ class Resolver
7
+ include Catalog
8
+
9
+ MODEL_NOTE = "relationship from model association (no database constraint)"
10
+
11
+ # relationships: { [from_table, from_column] => Relationship }
12
+ # table_notes: { table => [String] }; column_notes: { table => { column => [String] } }
13
+ Resolution = Struct.new(:relationships, :table_notes, :column_notes, :diagnostics, keyword_init: true)
14
+
15
+ # excluded: tables left out of the output; relationships into them become notes.
16
+ def initialize(schema, models, inflector:, excluded: ->(_table) { false })
17
+ @schema = schema
18
+ @models = models
19
+ @inflector = inflector
20
+ @excluded = excluded
21
+ end
22
+
23
+ def resolve
24
+ @relationships = {}
25
+ @hints = Hash.new { |hash, key| hash[key] = [] } # [table, column] => [:has_one | :has_many]
26
+ @polymorphic = {} # [table, column] => { type:, targets: [] }
27
+ @table_notes = Hash.new { |hash, key| hash[key] = [] }
28
+ @column_notes = Hash.new { |hash, table| hash[table] = Hash.new { |h, column| h[column] = [] } }
29
+ @attachments = Hash.new { |hash, key| hash[key] = [] } # table => [[name, :one | :many]]
30
+ @diagnostics = []
31
+ # The model owning each table (first by name), for association targets.
32
+ @table_models = @models.values.reject { |model| model.abstract || sti_child?(model) }
33
+ .each_with_object({}) { |model, index| index[table_for(model)] ||= model }
34
+
35
+ @schema.foreign_keys.each { |fk| add_database_fk(fk) }
36
+ @models.each_value { |model| resolve_model(model) unless model.abstract }
37
+ finish
38
+ end
39
+
40
+ # The table a model reads: STI subclasses use their base class's table.
41
+ def table_for(model)
42
+ return nil if model.abstract
43
+
44
+ return table_for(@models[model.superclass]) if sti_child?(model)
45
+
46
+ model.table_name || @inflector.tableize(model.name)
47
+ end
48
+
49
+ private
50
+
51
+ # A subclass of a concrete model shares its table (single-table inheritance).
52
+ def sti_child?(model) = !!@models[model.superclass]&.then { |parent| !parent.abstract }
53
+
54
+ def add_database_fk(fk)
55
+ label = "foreign key #{fk.from_table}.#{fk.from_column} -> #{fk.to_table}.#{fk.to_column}"
56
+ problem = missing(fk.from_table, fk.from_column) || missing(fk.to_table, fk.to_column)
57
+ return @diagnostics << Diagnostic.new(message: "#{label}: #{problem}, skipped") if problem
58
+
59
+ @relationships[[fk.from_table, fk.from_column]] ||= Relationship.new(
60
+ from_table: fk.from_table, from_column: fk.from_column, to_table: fk.to_table, to_column: fk.to_column, source: :database,
61
+ )
62
+ end
63
+
64
+ # Declarations of abstract parents apply to each subclass, resolved as the subclass's own.
65
+ def resolve_model(model)
66
+ table = table_for(model)
67
+ declared = [*abstract_ancestors(model), model]
68
+ declared.flat_map(&:associations).each { |association| resolve_association(model, table, association) }
69
+ return unless @schema.tables.key?(table)
70
+
71
+ declared.map(&:enums).reduce(:merge).each do |column, values|
72
+ next if (schema_column = @schema.tables[table].column(column)).nil? || schema_column.enum_values
73
+
74
+ @column_notes[table][column] << "enum: #{values.join(', ')}"
75
+ end
76
+ attachments = declared.flat_map(&:attachments)
77
+ @attachments[table].concat(attachments)
78
+ active_storage_target(model) if attachments.any?
79
+ end
80
+
81
+ # Abstract superclasses, outermost first. An STI child stops at its concrete parent, whose own
82
+ # declarations are resolved with it.
83
+ def abstract_ancestors(model)
84
+ chain = []
85
+ while (parent = @models[model.superclass]) && parent.abstract && !chain.include?(parent)
86
+ chain.unshift(parent)
87
+ model = parent
88
+ end
89
+ chain
90
+ end
91
+
92
+ # Active Storage's own models live in Rails, so its polymorphic record is described from
93
+ # the app side: every model with has_one_attached / has_many_attached is a target.
94
+ def active_storage_target(model)
95
+ key = %w[active_storage_attachments record_id]
96
+ return unless @schema.tables[key.first]&.column(key.last)
97
+
98
+ entry = (@polymorphic[key] ||= { type: "record_type", targets: [] })
99
+ entry[:targets] |= [model.name]
100
+ end
101
+
102
+ def resolve_association(model, table, association)
103
+ context = [model, association]
104
+ return warn(context, "table #{table} not found") unless @schema.tables.key?(table)
105
+
106
+ case association.macro
107
+ when :belongs_to then belongs_to(context, table)
108
+ when :has_many, :has_one then has_many(context, table)
109
+ when :has_and_belongs_to_many then habtm(context, table)
110
+ end
111
+ end
112
+
113
+ def belongs_to(context, table)
114
+ _, association = context
115
+ options = association.options
116
+ column = options[:foreign_key] || "#{association.name}_id"
117
+ return polymorphic_belongs_to(table, column, options[:foreign_type] || "#{association.name}_type") if options[:polymorphic]
118
+
119
+ target = target_table(context, options[:class_name] || @inflector.camelize(association.name)) or return
120
+ add_model_edge(context, table, column, target, options[:primary_key] || primary_key(target, model_for_table(target)))
121
+ end
122
+
123
+ def has_many(context, table)
124
+ model, association = context
125
+ options = association.options
126
+ return if options[:through]
127
+ return polymorphic_target(context) if options[:as]
128
+
129
+ default_class = association.macro == :has_many ? @inflector.singularize(association.name) : association.name
130
+ target = target_table(context, options[:class_name] || @inflector.camelize(default_class)) or return
131
+ column = options[:foreign_key] || @inflector.foreign_key(model.name)
132
+ @hints[[target, column]] << association.macro if add_model_edge(context, target, column, table,
133
+ options[:primary_key] || primary_key(table, model))
134
+ end
135
+
136
+ def habtm(context, table)
137
+ model, association = context
138
+ options = association.options
139
+ target = target_table(context, options[:class_name] || @inflector.camelize(@inflector.singularize(association.name))) or return
140
+ join = options[:join_table] || [table, target].sort.join("\0").gsub(/^(.*_)(.+)\0\1(.+)/, '\1\2_\3').tr("\0", "_")
141
+ return warn(context, "join table #{join} not found") unless @schema.tables.key?(join)
142
+
143
+ add_model_edge(context, join, options[:foreign_key] || @inflector.foreign_key(model.name), table, primary_key(table, model))
144
+ target_class = options[:class_name] || @inflector.camelize(@inflector.singularize(association.name))
145
+ add_model_edge(context, join, options[:association_foreign_key] || @inflector.foreign_key(target_class),
146
+ target, primary_key(target, model_for_table(target)))
147
+ end
148
+
149
+ def polymorphic_belongs_to(table, column, type_column)
150
+ return unless @schema.tables[table].column(column)
151
+
152
+ @polymorphic[[table, column]] ||= { type: type_column, targets: [] }
153
+ end
154
+
155
+ # has_many :comments, as: :commentable -> Model is one of comments.commentable_id's targets.
156
+ def polymorphic_target(context)
157
+ model, association = context
158
+ options = association.options
159
+ target = target_table(context, options[:class_name] || @inflector.camelize(@inflector.singularize(association.name))) or return
160
+ key = [target, options[:foreign_key] || "#{options[:as]}_id"]
161
+ problem = missing(*key)
162
+ return warn(context, problem) if problem
163
+
164
+ entry = (@polymorphic[key] ||= { type: options[:foreign_type] || "#{options[:as]}_type", targets: [] })
165
+ entry[:targets] |= [model.name]
166
+ end
167
+
168
+ # Rails-like class lookup (namespace first), else a table named after the class.
169
+ def target_table(context, class_name)
170
+ model, = context
171
+ scopes = model.name.split("::")[0...-1]
172
+ candidates = scopes.size.downto(0).map { |n| [*scopes.first(n), class_name.delete_prefix("::")].join("::") }
173
+ found = candidates.filter_map { |name| @models[name] }.first
174
+ table = found ? table_for(found) : @inflector.tableize(class_name)
175
+ return table if @schema.tables.key?(table)
176
+
177
+ warn(context, found ? "table #{table} not found" : "class #{class_name} not found")
178
+ end
179
+
180
+ def model_for_table(table) = @table_models[table]
181
+
182
+ # A composite key cannot be an association target, so only a single-column key counts.
183
+ def primary_key(table, model)
184
+ return model.primary_key if model&.primary_key.is_a?(String)
185
+
186
+ @schema.tables[table].primary_key.then { |pk| pk.size == 1 ? pk.first : "id" }
187
+ end
188
+
189
+ # true when the edge exists (new or already known with the same target).
190
+ def add_model_edge(context, from_table, from_column, to_table, to_column)
191
+ problem = missing(from_table, from_column) || missing(to_table, to_column)
192
+ return warn(context, problem) && false if problem
193
+
194
+ key = [from_table, from_column]
195
+ existing = @relationships[key]
196
+ if existing && [existing.to_table, existing.to_column] != [to_table, to_column]
197
+ source = existing.source == :database ? "the database foreign key" : "another association"
198
+ winner = existing.source == :database ? "the database" : "the first"
199
+ return warn(context, "points to #{to_table}.#{to_column} but #{source} points to " \
200
+ "#{existing.to_table}.#{existing.to_column}; using #{winner}", skipped: false) && false
201
+ end
202
+
203
+ @relationships[key] ||= Relationship.new(from_table: from_table, from_column: from_column, to_table: to_table,
204
+ to_column: to_column, source: :model)
205
+ true
206
+ end
207
+
208
+ def missing(table, column)
209
+ return "table #{table} not found" unless @schema.tables.key?(table)
210
+
211
+ "column #{table}.#{column} not found" unless @schema.tables[table].column(column)
212
+ end
213
+
214
+ def warn(context, problem, skipped: true)
215
+ model, association = context
216
+ @diagnostics << Diagnostic.new(message: "#{model.name} #{association.macro} :#{association.name}: #{problem}#{', skipped' if skipped}",
217
+ path: association.path, line: association.line)
218
+ nil
219
+ end
220
+
221
+ def finish
222
+ drop_excluded
223
+ @relationships.each do |key, relationship|
224
+ relationship.cardinality = :one_to_one if one_to_one?(key)
225
+ @column_notes[key.first][key.last] << MODEL_NOTE if relationship.source == :model
226
+ end
227
+ @polymorphic.sort.each do |(table, column), entry|
228
+ targets = entry[:targets].sort
229
+ @column_notes[table][column] << "polymorphic (type in #{entry[:type]})#{": #{targets.join(', ')}" if targets.any?}"
230
+ end
231
+ sti_notes
232
+ @attachments.each do |table, attachments|
233
+ next if attachments.empty?
234
+
235
+ @table_notes[table] << "attachments: #{attachments.uniq.map { |name, kind| "#{name} (#{kind})" }.join(', ')}"
236
+ end
237
+ Resolution.new(relationships: @relationships, table_notes: plain(@table_notes),
238
+ column_notes: plain(@column_notes).transform_values { |columns| plain(columns) }, diagnostics: @diagnostics)
239
+ end
240
+
241
+ def drop_excluded
242
+ @relationships.reject! do |(table, column), relationship|
243
+ next false unless @excluded.call(table) || @excluded.call(relationship.to_table)
244
+
245
+ @column_notes[table][column] << "references #{relationship.to_table} (excluded)" unless @excluded.call(table)
246
+ true
247
+ end
248
+ end
249
+
250
+ def one_to_one?((table, column))
251
+ unique = @schema.tables[table].indexes.any? { |index| index.single_column? && index.unique && index.columns == [column] }
252
+ hints = @hints[[table, column]]
253
+ unique || (hints.include?(:has_one) && !hints.include?(:has_many))
254
+ end
255
+
256
+ def sti_notes
257
+ subclasses = @models.values.select { |model| sti_child?(model) }
258
+ subclasses.group_by { |model| table_for(model) }.sort.each do |table, models|
259
+ @table_notes[table].unshift("STI: #{models.map(&:name).sort.join(', ')}") if @schema.tables.key?(table)
260
+ end
261
+ end
262
+
263
+ # Drops the default procs so callers get ordinary hashes.
264
+ def plain(hash) = {}.merge(hash.reject { |_, value| value.empty? })
265
+ end
266
+ end