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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE.txt +21 -0
- data/README.md +109 -0
- data/bin/pgdd +10 -0
- data/bundler_audit_output.txt +10 -0
- data/fasterer_output.txt +1 -0
- data/flay_output.txt +1 -0
- data/flog_output.txt +29 -0
- data/lib/pgdd/cli.rb +118 -0
- data/lib/pgdd/collector.rb +109 -0
- data/lib/pgdd/database_inspector.rb +242 -0
- data/lib/pgdd/erd_writer.rb +205 -0
- data/lib/pgdd/migration_scanner.rb +147 -0
- data/lib/pgdd/model_inspector.rb +162 -0
- data/lib/pgdd/railtie.rb +10 -0
- data/lib/pgdd/readme_template.md +61 -0
- data/lib/pgdd/report.rb +478 -0
- data/lib/pgdd/types.rb +72 -0
- data/lib/pgdd/version.rb +5 -0
- data/lib/pgdd.rb +29 -0
- data/lib/tasks/pgdd.rake +11 -0
- data/reek_output.txt +2 -0
- data/rubocop_output.txt +4 -0
- data/scrap_output.txt +28 -0
- data/test_output.txt +9 -0
- data/typos_output.txt +0 -0
- metadata +74 -0
|
@@ -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('&', '&').gsub('<', '<').gsub('>', '>').gsub('"', '"')
|
|
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
|