pgdd 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,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgdd
4
+ # Reads everything it can about one database through an ActiveRecord connection
5
+ class DatabaseInspector
6
+ INTERNAL_TABLES = %w[schema_migrations ar_internal_metadata].freeze
7
+
8
+ def initialize(connection, name:, root: Dir.pwd, count_rows: false, include_internal: false)
9
+ @connection = connection
10
+ @name = name
11
+ @root = root
12
+ @count_rows = count_rows
13
+ @include_internal = include_internal
14
+ end
15
+
16
+ def call
17
+ Database.new(
18
+ name: @name,
19
+ adapter: @connection.adapter_name,
20
+ location: location,
21
+ server_version: server_version,
22
+ schema_version: schema_version,
23
+ pending_migrations: pending_migrations,
24
+ migrations: migrations,
25
+ extensions: extensions,
26
+ enums: enums,
27
+ tables: data_sources.map { |name, kind| table(name, kind) }
28
+ )
29
+ end
30
+
31
+ def postgres? = @connection.adapter_name.match?(/postg/i)
32
+ def sqlite? = @connection.adapter_name.match?(/sqlite/i)
33
+
34
+ # [[name, kind], ...] for every table and view
35
+ def data_sources
36
+ sources = @connection.tables.map { |name| [name, 'table'] } +
37
+ @connection.views.map { |name| [name, materialized_views.include?(name) ? 'materialized view' : 'view'] }
38
+ sources.reject! { |name, _| INTERNAL_TABLES.include?(name) } unless @include_internal
39
+ sources.sort
40
+ end
41
+
42
+ def table(name, kind)
43
+ Table.new(
44
+ name: name,
45
+ kind: kind,
46
+ comment: table_comment(name),
47
+ primary_key: @connection.primary_key(name),
48
+ columns: columns(name),
49
+ indexes: indexes(name),
50
+ foreign_keys: foreign_keys(name),
51
+ check_constraints: check_constraints(name),
52
+ triggers: triggers.fetch(name) { [] },
53
+ **row_stats(name, kind)
54
+ )
55
+ end
56
+
57
+ def columns(table_name)
58
+ @connection.columns(table_name).map do |c|
59
+ sql_type = c.sql_type.to_s
60
+ sql_type += '[]' if c.respond_to?(:array?) && c.array?
61
+ Column.new(
62
+ name: c.name,
63
+ sql_type: sql_type,
64
+ null: c.null,
65
+ default: c.default_function || c.default,
66
+ comment: c.respond_to?(:comment) ? c.comment : nil
67
+ )
68
+ end
69
+ end
70
+
71
+ def indexes(table_name)
72
+ @connection.indexes(table_name).map do |i|
73
+ Index.new(name: i.name, columns: Array(i.columns), unique: i.unique, where: i.where, using: i.using)
74
+ end
75
+ end
76
+
77
+ def foreign_keys(table_name)
78
+ return [] unless @connection.supports_foreign_keys?
79
+
80
+ @connection.foreign_keys(table_name).map do |fk|
81
+ ForeignKey.new(name: fk.name, column: fk.column, to_table: fk.to_table,
82
+ primary_key: fk.primary_key, on_delete: fk.on_delete, on_update: fk.on_update)
83
+ end
84
+ end
85
+
86
+ def check_constraints(table_name)
87
+ return [] unless @connection.respond_to?(:supports_check_constraints?) && @connection.supports_check_constraints?
88
+
89
+ @connection.check_constraints(table_name).map { |c| Constraint.new(name: c.name, expression: c.expression) }
90
+ end
91
+
92
+ def table_comment(table_name)
93
+ @connection.table_comment(table_name) if @connection.supports_comments?
94
+ end
95
+
96
+ def row_stats(table_name, kind)
97
+ stats = relation_stats.fetch(table_name) { {} }
98
+ rows = stats[:rows]
99
+
100
+ if @count_rows && kind != 'view'
101
+ rows = @connection.select_value("SELECT COUNT(*) FROM #{@connection.quote_table_name(table_name)}").to_i
102
+ return { rows: rows, rows_estimated: false, bytes: stats[:bytes] }
103
+ end
104
+
105
+ { rows: rows, rows_estimated: !rows.nil?, bytes: stats[:bytes] }
106
+ end
107
+
108
+ # { table_name => { rows:, bytes: } }; rows are planner estimates
109
+ def relation_stats
110
+ @relation_stats ||=
111
+ if postgres? then postgres_relation_stats
112
+ elsif sqlite? then sqlite_relation_stats
113
+ else {}
114
+ end
115
+ end
116
+
117
+ def postgres_relation_stats
118
+ sql = <<~SQL
119
+ SELECT c.relname AS name, c.reltuples::bigint AS rows, pg_total_relation_size(c.oid) AS bytes
120
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
121
+ WHERE c.relkind IN ('r', 'p', 'm') AND n.nspname = ANY (current_schemas(false))
122
+ SQL
123
+ @connection.select_all(sql).to_h do |row|
124
+ rows = row['rows'].to_i
125
+ [row['name'], { rows: rows.negative? ? nil : rows, bytes: row['bytes'].to_i }]
126
+ end
127
+ end
128
+
129
+ # Needs SQLite compiled with the dbstat virtual table; sizes include indexes
130
+ def sqlite_relation_stats
131
+ sql = <<~SQL
132
+ SELECT m.tbl_name AS name, SUM(d.pgsize) AS bytes
133
+ FROM dbstat d JOIN sqlite_master m ON m.name = d.name
134
+ GROUP BY m.tbl_name
135
+ SQL
136
+ @connection.select_all(sql).to_h { |row| [row['name'], { bytes: row['bytes'].to_i }] }
137
+ rescue ActiveRecord::StatementInvalid
138
+ {}
139
+ end
140
+
141
+ # { table_name => [Trigger, ...] }
142
+ def triggers
143
+ @triggers ||=
144
+ if postgres? then postgres_triggers
145
+ elsif sqlite? then sqlite_triggers
146
+ else {}
147
+ end
148
+ end
149
+
150
+ def postgres_triggers
151
+ sql = <<~SQL
152
+ SELECT event_object_table AS table_name, trigger_name, action_timing,
153
+ string_agg(event_manipulation, ' OR ' ORDER BY event_manipulation) AS events,
154
+ action_statement
155
+ FROM information_schema.triggers
156
+ WHERE trigger_schema = ANY (current_schemas(false))
157
+ GROUP BY event_object_table, trigger_name, action_timing, action_statement
158
+ ORDER BY 1, 2
159
+ SQL
160
+ @connection.select_all(sql).group_by { |r| r['table_name'] }.transform_values do |rows|
161
+ rows.map { |r| Trigger.new(name: r['trigger_name'], timing: r['action_timing'], events: r['events'], action: r['action_statement']) }
162
+ end
163
+ end
164
+
165
+ def sqlite_triggers
166
+ sql = "SELECT tbl_name, name, sql FROM sqlite_master WHERE type = 'trigger' ORDER BY tbl_name, name"
167
+ @connection.select_all(sql).group_by { |r| r['tbl_name'] }.transform_values do |rows|
168
+ rows.map { |r| Trigger.new(name: r['name'], timing: nil, events: nil, action: r['sql']) }
169
+ end
170
+ end
171
+
172
+ def materialized_views
173
+ @materialized_views ||=
174
+ postgres? ? @connection.select_values('SELECT matviewname FROM pg_matviews WHERE schemaname = ANY (current_schemas(false))') : []
175
+ end
176
+
177
+ # { enum_type_name => [values] } for PostgreSQL enum types
178
+ def enums
179
+ return {} unless postgres?
180
+
181
+ sql = <<~SQL
182
+ SELECT t.typname AS name, string_agg(e.enumlabel, ',' ORDER BY e.enumsortorder) AS labels
183
+ FROM pg_type t
184
+ JOIN pg_enum e ON e.enumtypid = t.oid
185
+ JOIN pg_namespace n ON n.oid = t.typnamespace
186
+ WHERE n.nspname = ANY (current_schemas(false))
187
+ GROUP BY t.typname ORDER BY 1
188
+ SQL
189
+ @connection.select_all(sql).to_h { |r| [r['name'], r['labels'].split(',')] }
190
+ end
191
+
192
+ def extensions
193
+ @connection.respond_to?(:extensions) ? @connection.extensions.sort : []
194
+ end
195
+
196
+ def location
197
+ @connection.pool.db_config.database
198
+ rescue StandardError
199
+ nil
200
+ end
201
+
202
+ def server_version
203
+ sql = if postgres? then 'SHOW server_version'
204
+ elsif sqlite? then 'SELECT sqlite_version()'
205
+ else 'SELECT version()'
206
+ end
207
+ @connection.select_value(sql)
208
+ rescue StandardError
209
+ nil
210
+ end
211
+
212
+ def schema_version
213
+ migration_context&.current_version
214
+ rescue StandardError
215
+ nil
216
+ end
217
+
218
+ def pending_migrations
219
+ migration_context&.needs_migration?
220
+ rescue StandardError
221
+ nil
222
+ end
223
+
224
+ # Every migration file on this database's migrations paths, oldest first
225
+ def migrations
226
+ MigrationScanner.new(migrations_paths, root: @root).call
227
+ end
228
+
229
+ def migrations_paths
230
+ Array(migration_context&.migrations_paths)
231
+ rescue StandardError
232
+ []
233
+ end
234
+
235
+ def migration_context
236
+ pool = @connection.pool
237
+ return pool.migration_context if pool.respond_to?(:migration_context)
238
+
239
+ @connection.migration_context if @connection.respond_to?(:migration_context)
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgdd
4
+ # Builds Graphviz ERDs from a Database: one per table (the table in full,
5
+ # its parents and children by key columns) plus one for the whole schema.
6
+ # Dark theme on a transparent background; boxes and edges link to the
7
+ # table pages when name_for is given.
8
+ class ErdWriter
9
+ FORMATS = %w[svg dot png pdf].freeze
10
+
11
+ GRAPH_ATTRS = 'rankdir=LR bgcolor="transparent" fontname="Arial" nodesep=0.3 ranksep=1.0'
12
+ NODE_ATTRS = 'shape=plaintext fontname="Arial" fontsize=12 fontcolor="#e6e6e6"'
13
+ EDGE_ATTRS = 'fontname="Arial" fontsize=9 color="#7aa2f7" fontcolor="#9aa4b2"'
14
+ BORDER = '#c8d1dc'
15
+ TITLE_BG = '#2b3a55'
16
+ TYPE_COLOR = '#9aa4b2'
17
+
18
+ class Error < Pgdd::Error; end
19
+
20
+ # A link the models declare but the database cannot enforce: from_table.column
21
+ # points at to_table's id whenever type_column names model. macro is the
22
+ # inverse side's has_one or has_many; name is the polymorphic association.
23
+ PolymorphicKey = Data.define(:name, :column, :type_column, :to_table, :primary_key, :model, :macro)
24
+
25
+ class << self
26
+ # For text inside HTML-like labels and their attributes
27
+ def escape(value)
28
+ value.to_s.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;').gsub('"', '&quot;')
29
+ end
30
+
31
+ # A double-quoted DOT attribute value
32
+ def quote(value)
33
+ %("#{value.to_s.gsub('\\', '\\\\\\\\').gsub('"', '\\"').gsub(/\s*\n\s*/, ' ')}")
34
+ end
35
+
36
+ # HTML-like label with a title row and one row (and port) per column,
37
+ # or per column named in only. A url makes the whole box a link; comments
38
+ # become hover tooltips, which Graphviz only emits for cells that carry a
39
+ # link of their own.
40
+ def node(table, url: nil, only: nil)
41
+ cell_link = url ? %( href="#{escape(url)}") : ''
42
+ columns = only ? table.columns.select { |c| only.include?(c.name) } : table.columns
43
+ rows = [title_row(table, cell_link)] + columns.map { |c| column_row(c, cell_link) }
44
+ label = %(<<table border="0" cellborder="1" cellspacing="0" cellpadding="6" color="#{BORDER}">#{rows.join}</table>>)
45
+
46
+ attrs = ["label=#{label}"]
47
+ attrs << "URL=#{quote(url)}" if url
48
+ attrs << "tooltip=#{quote(table.comment)}" if table.comment
49
+ %("#{table.name}" [#{attrs.join(' ')}];)
50
+ end
51
+
52
+ def title_row(table, cell_link)
53
+ %(<tr><td bgcolor="#{TITLE_BG}"#{cell_link}><font point-size="16" color="#ffffff"><b>#{escape(table.name)}</b></font></td></tr>)
54
+ end
55
+
56
+ # One row per column, with a port for edges and the comment as a tooltip
57
+ def column_row(column, cell_link)
58
+ tooltip = column.comment ? %( title="#{escape(column.comment)}") : ''
59
+ %(<tr><td port="#{escape(column.name)}" align="left"#{cell_link}#{tooltip}>#{escape(column.name)} ) +
60
+ %(<font color="#{TYPE_COLOR}">[#{escape(column.sql_type)}]</font></td></tr>)
61
+ end
62
+
63
+ # Crow's foot on the child column, bar and ring on the parent key; a
64
+ # PolymorphicKey is dashed, with a single tee when its inverse is has_one
65
+ def edge(from_table, fk, url: nil)
66
+ column = Array(fk.column).first
67
+ key = Array(fk.primary_key).first || 'id'
68
+ attrs = ['arrowhead=teeodot', 'dir=both', "label=#{quote(column)}"]
69
+ attrs.concat(edge_attrs(fk, "#{from_table}.#{column} references #{fk.to_table}.#{key}"))
70
+ attrs << "URL=#{quote(url)}" if url
71
+ %("#{from_table}":"#{column}":e -> "#{fk.to_table}":"#{key}":w [#{attrs.join(' ')}];)
72
+ end
73
+
74
+ # Tail, style and tooltip: a solid crow's foot for a foreign key; dashed,
75
+ # with a single tee when the inverse is has_one, for a polymorphic key
76
+ def edge_attrs(fk, reference)
77
+ return ['arrowtail=crow', "tooltip=#{quote(reference)}"] unless fk.is_a?(PolymorphicKey)
78
+
79
+ [fk.macro == :has_one ? 'arrowtail=tee' : 'arrowtail=crow', 'style=dashed',
80
+ "tooltip=#{quote("#{reference} when #{fk.type_column} is #{fk.model} (polymorphic #{fk.name})")}"]
81
+ end
82
+
83
+ # nodes and edges are DOT statements from .node and .edge
84
+ def graph(name, nodes, edges)
85
+ lines = ["digraph \"#{name}\" {",
86
+ " graph [#{GRAPH_ATTRS}];",
87
+ " node [#{NODE_ATTRS}];",
88
+ " edge [#{EDGE_ATTRS}];"]
89
+ lines.concat(nodes.map { |n| " #{n}" })
90
+ lines.concat(edges.map { |e| " #{e}" })
91
+ (lines << '}').join("\n") + "\n"
92
+ end
93
+
94
+ # Renders DOT source to path with Graphviz
95
+ def render(source, path, format)
96
+ IO.popen(['dot', "-T#{format}", '-o', path], 'w') { |dot| dot.write(source) }
97
+ raise Error, "dot exited with #{Process.last_status.exitstatus} rendering #{path}" unless Process.last_status.success?
98
+ end
99
+ end
100
+
101
+ # models are the Models backed by this database; their has_one/has_many
102
+ # ... as: declarations become dashed PolymorphicKey edges. name_for maps a
103
+ # table name to the file stem its page and diagram share; when given, boxes
104
+ # and edges link to "<stem>.md"
105
+ def initialize(db, models: [], name_for: nil)
106
+ @db = db
107
+ @tables = db.tables.to_h { |t| [t.name, t] }
108
+ @name_for = name_for
109
+ @polymorphic = polymorphic_keys(models)
110
+ end
111
+
112
+ # [[from_table, PolymorphicKey], ...] for every `has_one/has_many ..., as:`
113
+ # whose owner and target tables are both in this database. The association's
114
+ # class_name is the model holding the foreign key; the declaring model is
115
+ # the target the type column names.
116
+ def polymorphic_keys(models)
117
+ by_name = models.to_h { |m| [m.name, m] }
118
+ keys = models.flat_map do |target|
119
+ target.associations.filter_map { |a| polymorphic_key(target, a, by_name[a.class_name]) }
120
+ end
121
+ keys.uniq { |from, pk| [from, pk.column, pk.to_table] }
122
+ end
123
+
124
+ # [owner_table, PolymorphicKey] for one `has_one/has_many ..., as:` that
125
+ # target declares and owner holds the foreign key for; nil otherwise
126
+ def polymorphic_key(target, association, owner)
127
+ as = association.options[:as]
128
+ return nil unless as && association.foreign_key && %i[has_one has_many].include?(association.macro)
129
+ return nil unless owner && @tables[owner.table_name.to_s] && @tables[target.table_name.to_s]
130
+
131
+ [owner.table_name.to_s,
132
+ PolymorphicKey.new(name: as.to_s, column: association.foreign_key.to_s, type_column: "#{as}_type",
133
+ to_table: target.table_name.to_s, primary_key: 'id',
134
+ model: target.name, macro: association.macro)]
135
+ end
136
+
137
+ def stem(name)
138
+ @name_for ? @name_for.call(name) : name
139
+ end
140
+
141
+ def page_url(table_name)
142
+ "#{@name_for.call(table_name)}.md" if @name_for
143
+ end
144
+
145
+ def node(table, only: nil) = self.class.node(table, url: page_url(table.name), only: only)
146
+ def edge(from_table, fk) = self.class.edge(from_table, fk, url: page_url(from_table))
147
+
148
+ # The primary key plus every column that the given [[table_name, ForeignKey], ...]
149
+ # relations use on this table: enough to draw its edges without the full box
150
+ def key_columns(table, relations)
151
+ keys = Array(table.primary_key).map(&:to_s)
152
+ keys = ['id'] if keys.empty?
153
+ relations.each do |from, fk|
154
+ if from == table.name
155
+ keys.concat(Array(fk.column).map(&:to_s))
156
+ keys << fk.type_column if fk.is_a?(PolymorphicKey)
157
+ end
158
+ keys.concat(Array(fk.primary_key).map(&:to_s)) if fk.to_table == table.name
159
+ end
160
+ keys.uniq
161
+ end
162
+
163
+ # [[table_name, ForeignKey or PolymorphicKey], ...] for keys from and to this table
164
+ def relations(table)
165
+ parents = table.foreign_keys.map { |fk| [table.name, fk] }
166
+ children = @db.tables.flat_map do |other|
167
+ other.foreign_keys.select { |fk| fk.to_table == table.name }.map { |fk| [other.name, fk] }
168
+ end
169
+ poly = @polymorphic.select { |from, pk| from == table.name || pk.to_table == table.name }
170
+ (parents + children + poly).uniq
171
+ end
172
+
173
+ # The table in full; its parents and children collapsed to their key columns
174
+ def table_dot(table)
175
+ edges = relations(table)
176
+ names = ([table.name] + edges.flat_map { |from, fk| [from, fk.to_table] }).uniq
177
+ nodes = names.filter_map { |n| @tables[n] }.map do |t|
178
+ t == table ? node(t) : node(t, only: key_columns(t, edges))
179
+ end
180
+ self.class.graph(table.name, nodes, edges.map { |from, fk| edge(from, fk) })
181
+ end
182
+
183
+ # Every table collapsed to its key columns
184
+ def schema_dot
185
+ edges = @db.tables.flat_map { |t| t.foreign_keys.map { |fk| [t.name, fk] } } + @polymorphic
186
+ nodes = @db.tables.map { |t| node(t, only: key_columns(t, edges)) }
187
+ self.class.graph('schema', nodes, edges.map { |from, fk| edge(from, fk) })
188
+ end
189
+
190
+ # { file stem => dot source } for every diagram
191
+ def diagrams
192
+ @db.tables.to_h { |t| [stem(t.name), table_dot(t)] }.merge(stem('schema') => schema_dot)
193
+ end
194
+
195
+ # Writes STEM.FORMAT for each diagram, through Graphviz unless FORMAT is dot
196
+ def write(dir, format: 'svg')
197
+ FileUtils.mkdir_p(dir)
198
+ diagrams.map do |name, source|
199
+ path = File.join(dir, "#{name}.#{format}")
200
+ format == 'dot' ? File.write(path, source) : self.class.render(source, path, format)
201
+ path
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgdd
4
+ # Reads migration files and works out which tables each one changes, from
5
+ # the schema DSL calls and from the SQL in execute strings and heredocs.
6
+ # Only the forward direction counts: `def down` and `dir.down` are skipped.
7
+ class MigrationScanner
8
+ TABLE_OPS = %w[create_table drop_table change_table add_column remove_column change_column
9
+ change_column_null change_column_default change_column_comment rename_column
10
+ add_index remove_index add_reference remove_reference add_belongs_to
11
+ remove_belongs_to add_check_constraint remove_check_constraint
12
+ add_unique_constraint remove_unique_constraint add_exclusion_constraint
13
+ remove_exclusion_constraint add_timestamps remove_timestamps
14
+ change_table_comment validate_foreign_key validate_check_constraint
15
+ validate_constraint].freeze
16
+
17
+ # op :table[, :column | %i[a b] | [:a, :b] | "string"]
18
+ ARGUMENT = /(?:\s*,\s*(?::(\w+)|%i\[([^\]]*)\]|\[([^\]]*)\]|"([^"]*)"|'([^']*)'))?/
19
+ # longest names first so change_column_null is not read as change_column
20
+ OPS = TABLE_OPS.sort_by { |op| -op.size }.join('|')
21
+ DSL_CALL = /\b(#{OPS})\b\s*\(?\s*:(\w+)#{ARGUMENT}/
22
+ # The same call with a variable for the table, as in `TABLES.each { |t| add_column t, ... }`;
23
+ # the change is attributed to every word in the file's %i[...] and %w[...] literals
24
+ DSL_VAR = /\b(#{OPS})\b\s*\(?\s*([a-z_]\w*)#{ARGUMENT}/
25
+ WORD_LISTS = /%[iw]\[([^\]]*)\]/m
26
+ RENAME = /\brename_table\s*\(?\s*:(\w+)\s*,\s*:(\w+)/
27
+ JOIN_TABLE = /\bcreate_join_table\s*\(?\s*:(\w+)\s*,\s*:(\w+)/
28
+ FOREIGN_KEY = /\b(add|remove)_foreign_key\s*\(?\s*:(\w+)\s*,\s*:(\w+)/
29
+ MODEL_TABLE = /self\.table_name\s*=\s*['"](\w+)['"]/
30
+ SQL = /\b(?<verb>ALTER\s+TABLE|CREATE\s+TABLE|DROP\s+TABLE|TRUNCATE\s+TABLE|CREATE\s+(?:UNIQUE\s+)?INDEX\s+\S+\s+ON|UPDATE|INSERT\s+INTO|DELETE\s+FROM)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?(?:ONLY\s+)?"?(?<table>\w+)"?/i
31
+ HEREDOC = /<<[~-]?(['"]?)([A-Z_]+)\1[^\n]*\n(.*?)^\s*\2\s*$/m
32
+ EXEC_STRING = /\bexecute\s*\(?\s*(['"])(.*?)\1/m
33
+ FILENAME = /\A(\d+)_(.+)\.rb\z/
34
+
35
+ class << self
36
+ # The source with comment lines, `def down ... end`, `dir.down do ... end`
37
+ # and one-line `dir.down { ... }` removed. A block ends at the first line
38
+ # whose indentation matches the line that opened it.
39
+ def forward_source(source)
40
+ skip = nil
41
+ source.lines.filter_map do |line|
42
+ if skip
43
+ skip = nil if line.match?(/\A#{skip}end\b/)
44
+ next
45
+ end
46
+ next if line.match?(/\A\s*#/) || line.match?(/\bdir\.down\s*\{/)
47
+
48
+ if (m = line.match(/\A(\s*)(?:def down\b|dir\.down\s+do\b)/))
49
+ skip = m[1]
50
+ next
51
+ end
52
+ line
53
+ end.join
54
+ end
55
+
56
+ # SQL fragments in execute strings and heredocs
57
+ def sql_blocks(source)
58
+ source.scan(HEREDOC).map(&:last) + source.scan(EXEC_STRING).map(&:last)
59
+ end
60
+
61
+ # The ARGUMENT captures rendered as "col" or "[a, b]": one of a :symbol,
62
+ # a %i[...] list, a [...] list, a "string" or a 'string'
63
+ def argument(symbol, words, list, double_quoted, single_quoted)
64
+ return symbol if symbol
65
+ return "[#{words.split.join(', ')}]" if words
66
+ return "[#{list.delete(':').split(/\s*,\s*/).join(', ')}]" if list
67
+
68
+ double_quoted || single_quoted
69
+ end
70
+
71
+ def change(op, *argument_captures)
72
+ [op, argument(*argument_captures)].compact.join(' ')
73
+ end
74
+
75
+ # { table => [change, ...] } in file order, from the forward source
76
+ def tables(source)
77
+ src = forward_source(source)
78
+ tables = Hash.new { |h, k| h[k] = [] }
79
+ dsl_changes(src, tables)
80
+ looped_changes(src, tables)
81
+ structural_changes(src, tables)
82
+ sql_changes(src, tables)
83
+ tables.transform_values(&:uniq)
84
+ end
85
+
86
+ # `op :table, ...` calls
87
+ def dsl_changes(src, tables)
88
+ src.scan(DSL_CALL) { |op, table, *args| tables[table] << change(op, *args) }
89
+ end
90
+
91
+ # `op variable, ...` calls, attributed to every word list in the file
92
+ def looped_changes(src, tables)
93
+ looped = src.scan(WORD_LISTS).flatten.flat_map(&:split).uniq
94
+ src.scan(DSL_VAR) do |op, _variable, *args|
95
+ described = change(op, *args)
96
+ looped.each { |table| tables[table] << described }
97
+ end
98
+ end
99
+
100
+ # Renames, join tables, foreign keys and data changes through a model
101
+ def structural_changes(src, tables)
102
+ src.scan(RENAME) do |old_name, new_name|
103
+ tables[old_name] << "rename_table to #{new_name}"
104
+ tables[new_name] << "rename_table from #{old_name}"
105
+ end
106
+ src.scan(JOIN_TABLE) { |left, right| tables[[left, right].sort.join('_')] << 'create_join_table' }
107
+ src.scan(FOREIGN_KEY) do |verb, from, to|
108
+ tables[from] << "#{verb}_foreign_key to #{to}"
109
+ tables[to] << "#{verb}_foreign_key from #{from}"
110
+ end
111
+ src.scan(MODEL_TABLE) { |(table)| tables[table] << 'data change through a model' }
112
+ end
113
+
114
+ # Statements inside execute strings and heredocs
115
+ def sql_changes(src, tables)
116
+ sql_blocks(src).each do |sql|
117
+ sql.scan(SQL) { |verb, table| tables[table] << "#{sql_verb(verb)} (SQL)" }
118
+ end
119
+ end
120
+
121
+ def sql_verb(verb)
122
+ verb.upcase.gsub(/\s+/, ' ').sub(/ (UNIQUE )?INDEX .*/, ' INDEX')
123
+ end
124
+
125
+ def renames(source)
126
+ forward_source(source).scan(RENAME)
127
+ end
128
+
129
+ def migration(path)
130
+ version, name = File.basename(path).match(FILENAME)&.captures
131
+ source = File.read(path)
132
+ Migration.new(version: version || '0', name: name || File.basename(path, '.rb'), path: path,
133
+ tables: tables(source), renames: renames(source))
134
+ end
135
+ end
136
+
137
+ # paths are migration directories, absolute or relative to root
138
+ def initialize(paths, root: Dir.pwd)
139
+ @dirs = Array(paths).map { |path| File.expand_path(path.to_s, root) }
140
+ end
141
+
142
+ # [Migration, ...] oldest first; missing directories are skipped
143
+ def call
144
+ @dirs.flat_map { |dir| Dir[File.join(dir, '*.rb')] }.sort.map { |path| self.class.migration(path) }
145
+ end
146
+ end
147
+ end