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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE.txt +21 -0
- data/README.md +161 -0
- data/exe/rails2qdbd +6 -0
- data/lib/rails2qdbd/catalog.rb +68 -0
- data/lib/rails2qdbd/cli.rb +95 -0
- data/lib/rails2qdbd/config.rb +61 -0
- data/lib/rails2qdbd/converter.rb +96 -0
- data/lib/rails2qdbd/grouping.rb +23 -0
- data/lib/rails2qdbd/inflector.rb +65 -0
- data/lib/rails2qdbd/literal.rb +80 -0
- data/lib/rails2qdbd/model_scanner.rb +254 -0
- data/lib/rails2qdbd/output.rb +59 -0
- data/lib/rails2qdbd/resolver.rb +266 -0
- data/lib/rails2qdbd/schema_parser.rb +240 -0
- data/lib/rails2qdbd/version.rb +5 -0
- data/lib/rails2qdbd/writer.rb +115 -0
- data/lib/rails2qdbd.rb +26 -0
- metadata +105 -0
|
@@ -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
|