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
data/lib/pgdd/report.rb
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgdd
|
|
4
|
+
# Builds the Markdown database documentation from an App: the index page and one
|
|
5
|
+
# page per table. Needs nothing from Rails; everything comes from the App.
|
|
6
|
+
class Report
|
|
7
|
+
class << self
|
|
8
|
+
def cell(value)
|
|
9
|
+
value.to_s.gsub('|', '\|').gsub(/\s*\n\s*/, ' ')
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Columns are padded so the pipes line up (markdownlint MD060 "aligned")
|
|
13
|
+
def markdown_table(headers, rows)
|
|
14
|
+
cells = ([headers] + rows).map { |row| row.map { |v| cell(v) } }
|
|
15
|
+
widths = cells.transpose.map { |column| [column.map(&:length).max, 3].max }
|
|
16
|
+
lines = cells.map { |row| row.zip(widths).map { |v, width| v.ljust(width) } }
|
|
17
|
+
lines.insert(1, widths.map { |width| '-' * width })
|
|
18
|
+
lines.map { |row| "| #{row.join(' | ')} |" }.join("\n")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def format_version(version)
|
|
22
|
+
digits = version.to_s.delete('_')
|
|
23
|
+
return 'unknown' if digits.empty? || digits == '0'
|
|
24
|
+
|
|
25
|
+
m = digits.match(/\A(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)\z/)
|
|
26
|
+
m ? "#{m[1]}-#{m[2]}-#{m[3]} #{m[4]}:#{m[5]}:#{m[6]} (#{digits})" : digits
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def format_value(value)
|
|
30
|
+
case value
|
|
31
|
+
when Proc then '(proc)'
|
|
32
|
+
when Array then value.map { |v| format_value(v) }.join(', ')
|
|
33
|
+
when Hash then "{#{format_options(value)}}"
|
|
34
|
+
when String then value
|
|
35
|
+
else value.inspect
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def format_options(options)
|
|
40
|
+
options.map { |key, value| "#{key}: #{format_value(value)}" }.join(', ')
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def enum_values(mapping)
|
|
44
|
+
mapping.map { |key, value| key.to_s == value.to_s ? key.to_s : "#{key} (#{value})" }.join(', ')
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def number(value)
|
|
48
|
+
value.to_s.reverse.scan(/\d{1,3}/).join(',').reverse.then { |s| value.negative? ? "-#{s}" : s }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def human_size(bytes)
|
|
52
|
+
return '' if bytes.nil?
|
|
53
|
+
|
|
54
|
+
units = %w[B KB MB GB TB]
|
|
55
|
+
size = bytes.to_f
|
|
56
|
+
unit = 0
|
|
57
|
+
while size >= 1024 && unit < units.size - 1
|
|
58
|
+
size /= 1024
|
|
59
|
+
unit += 1
|
|
60
|
+
end
|
|
61
|
+
unit.zero? ? "#{bytes} B" : format('%<size>.1f %<unit>s', size: size, unit: units[unit])
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def rows_label(table)
|
|
65
|
+
return '' if table.rows.nil?
|
|
66
|
+
|
|
67
|
+
table.rows_estimated ? "~#{number(table.rows)}" : number(table.rows)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
INDEX = 'INDEX.md'
|
|
72
|
+
README = 'README.md'
|
|
73
|
+
TEMPLATE = File.expand_path('readme_template.md', __dir__)
|
|
74
|
+
|
|
75
|
+
# erd_format names the diagram files the table pages embed; nil for none.
|
|
76
|
+
# output_dir is where the pages will be written; migration links are relative to it.
|
|
77
|
+
# command is the command line that produced the report, quoted in the README
|
|
78
|
+
def initialize(app, date: Date.today, erd_format: nil, output_dir: nil, command: 'pgdd')
|
|
79
|
+
@app = app
|
|
80
|
+
@date = date
|
|
81
|
+
@erd_format = erd_format
|
|
82
|
+
@output_dir = output_dir
|
|
83
|
+
@command = command
|
|
84
|
+
@multi = app.databases.size > 1
|
|
85
|
+
@models = app.models.group_by { |m| [m.database, m.table_name] }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# { filename => markdown } for the README, the index page and one page per table
|
|
89
|
+
def pages
|
|
90
|
+
pages = { README => readme, INDEX => index_markdown }
|
|
91
|
+
@app.databases.reject(&:error).each do |db|
|
|
92
|
+
db.tables.each { |t| pages["#{basename(db.name, t.name)}.md"] = table_page(db, t) }
|
|
93
|
+
end
|
|
94
|
+
pages
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The directory's README from readme_template.md: what the files are, how to
|
|
98
|
+
# read the diagrams, and how this copy was generated
|
|
99
|
+
def readme
|
|
100
|
+
image = %w[svg png].include?(@erd_format)
|
|
101
|
+
template = ERB.new(File.read(TEMPLATE), trim_mode: '-')
|
|
102
|
+
template.result_with_hash(
|
|
103
|
+
app: @app, date: @date, index: INDEX, command: @command, multi: @multi,
|
|
104
|
+
erd_format: @erd_format, image: image, files_table: readme_files_table(image),
|
|
105
|
+
schema_stem: @multi ? '<database>.schema' : 'schema'
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The README's table of what each file in the directory is
|
|
110
|
+
def readme_files_table(image)
|
|
111
|
+
pattern = @multi ? '<database>.<table>' : '<table>'
|
|
112
|
+
rows = [
|
|
113
|
+
["`#{INDEX}`", 'The index. Databases, tables, enum types, models without a table, and the data element index.'],
|
|
114
|
+
["`#{pattern}.md`", 'One page per table: columns, indexes, foreign keys, the tables that reference it, ' \
|
|
115
|
+
'check constraints, triggers, the migrations that changed it, and the models backed by the table.']
|
|
116
|
+
]
|
|
117
|
+
if @erd_format
|
|
118
|
+
rows << ["`#{pattern}.#{@erd_format}`", "The entity relationship diagram for that table#{', embedded in its page' if image}."]
|
|
119
|
+
rows << ["`#{'<database>.' if @multi}schema.#{@erd_format}`", 'One diagram of every table in the database.']
|
|
120
|
+
end
|
|
121
|
+
self.class.markdown_table(%w[File Content], rows)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def index_markdown
|
|
125
|
+
sections = [header, database_summary]
|
|
126
|
+
@app.databases.each { |db| sections << database_section(db) }
|
|
127
|
+
sections << orphan_models
|
|
128
|
+
sections << data_element_index
|
|
129
|
+
sections.compact.join("\n\n") + "\n"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# File stem shared by a table's page and its diagram; prefixed when there are several databases
|
|
133
|
+
def basename(db_name, table_name)
|
|
134
|
+
@multi ? "#{db_name}.#{table_name}" : table_name
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def page_link(db_name, table_name)
|
|
138
|
+
"[#{table_name}](#{basename(db_name, table_name)}.md)"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def header
|
|
142
|
+
<<~HEADER.chomp
|
|
143
|
+
# #{@app.name} Database Documentation
|
|
144
|
+
|
|
145
|
+
This report was generated on #{@date} by booting the application and
|
|
146
|
+
reading its live database#{'s' if @multi} and its ActiveRecord models.
|
|
147
|
+
|
|
148
|
+
- **Application:** #{@app.name} (`#{File.basename(@app.root)}`)
|
|
149
|
+
- **Environment:** #{@app.environment}
|
|
150
|
+
- **Rails:** #{@app.rails_version}
|
|
151
|
+
- **Ruby:** #{@app.ruby_version}
|
|
152
|
+
- **Models:** #{@app.models.size}
|
|
153
|
+
HEADER
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def database_summary
|
|
157
|
+
headers = ['Database', 'Adapter', 'Server Version', 'Location', 'Schema Version', 'Tables', 'Views', 'Models']
|
|
158
|
+
rows = @app.databases.map do |db|
|
|
159
|
+
tables, views = db.tables.partition { |t| t.kind == 'table' }
|
|
160
|
+
model_count = @app.models.count { |m| m.database == db.name }
|
|
161
|
+
[db.name, db.adapter, db.server_version, db.location, self.class.format_version(db.schema_version),
|
|
162
|
+
tables.size, views.size, model_count]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
warnings = @app.databases.filter_map do |db|
|
|
166
|
+
if db.error
|
|
167
|
+
"> **#{db.name}:** could not be read: #{db.error}"
|
|
168
|
+
elsif db.pending_migrations
|
|
169
|
+
"> **#{db.name}:** has pending migrations; the database may not match the migration files."
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# One blockquote; separate quotes with blank lines between them trip MD028
|
|
174
|
+
parts = ['## Databases', self.class.markdown_table(headers, rows)]
|
|
175
|
+
parts << warnings.join("\n>\n") if warnings.any?
|
|
176
|
+
parts.join("\n\n")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def database_section(db)
|
|
180
|
+
return nil if db.error
|
|
181
|
+
|
|
182
|
+
parts = ["## #{@multi ? "#{db.name} Database" : 'Database'} Tables"]
|
|
183
|
+
parts << "**Extensions:** #{db.extensions.join(', ')}" if db.extensions.any?
|
|
184
|
+
parts << table_summary(db)
|
|
185
|
+
if db.enums.any?
|
|
186
|
+
rows = db.enums.map { |name, values| [name, values.join(', ')] }
|
|
187
|
+
parts << "### #{"#{db.name} " if @multi}Enum Types\n\n#{self.class.markdown_table(%w[Name Values], rows)}"
|
|
188
|
+
end
|
|
189
|
+
parts.join("\n\n")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def table_summary(db)
|
|
193
|
+
described = db.tables.any?(&:comment)
|
|
194
|
+
headers = %w[Table Kind Models Rows Size]
|
|
195
|
+
headers << 'Description' if described
|
|
196
|
+
|
|
197
|
+
rows = db.tables.map do |t|
|
|
198
|
+
row = [page_link(db.name, t.name), t.kind,
|
|
199
|
+
models_for(db, t).map(&:name).join(', '), self.class.rows_label(t), self.class.human_size(t.bytes)]
|
|
200
|
+
row << t.comment if described
|
|
201
|
+
row
|
|
202
|
+
end
|
|
203
|
+
self.class.markdown_table(headers, rows)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def heading(db, table)
|
|
207
|
+
basename(db.name, table.name)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def models_for(db, table)
|
|
211
|
+
@models.fetch([db.name, table.name]) { [] }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def table_page(db, t)
|
|
215
|
+
parts = ["# #{heading(db, t)}", t.comment, table_facts(t), diagram_section(db, t)]
|
|
216
|
+
parts.concat(schema_sections(db, t))
|
|
217
|
+
parts << migrations_section(db, t)
|
|
218
|
+
parts.concat(models_for(db, t).map { |m| model_detail(m) })
|
|
219
|
+
parts << "[Back to the index](#{INDEX})"
|
|
220
|
+
parts.compact.join("\n\n") + "\n"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def table_facts(t)
|
|
224
|
+
facts = ["**Kind:** #{t.kind}"]
|
|
225
|
+
facts << "**Primary key:** #{Array(t.primary_key).join(', ')}" if t.primary_key
|
|
226
|
+
facts << "**Rows:** #{self.class.rows_label(t)}#{' (estimate)' if t.rows_estimated}" if t.rows
|
|
227
|
+
facts << "**Size:** #{self.class.human_size(t.bytes)}" if t.bytes
|
|
228
|
+
facts.join(' · ')
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Columns, indexes, keys, constraints and triggers; a section with nothing to list is nil
|
|
232
|
+
def schema_sections(db, t)
|
|
233
|
+
["## Columns\n\n#{columns_table(t)}",
|
|
234
|
+
section('Indexes', t.indexes) { indexes_table(t) },
|
|
235
|
+
section('Foreign Keys', t.foreign_keys) { foreign_keys_table(db, t) },
|
|
236
|
+
section('Referenced By', referencing_keys(db, t)) { |keys| referenced_by_table(db, keys) },
|
|
237
|
+
section('Check Constraints', t.check_constraints) { |constraints| constraints_table(constraints) },
|
|
238
|
+
section('Triggers', t.triggers) { |triggers| triggers_table(triggers) }]
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# A heading over the table the block builds from items, or nil when items is empty
|
|
242
|
+
def section(title, items, level: '##')
|
|
243
|
+
return nil if items.empty?
|
|
244
|
+
|
|
245
|
+
"#{level} #{title}\n\n#{yield(items)}"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def referenced_by_table(db, keys)
|
|
249
|
+
rows = keys.map { |from, fk| [page_link(db.name, from), Array(fk.column).join(', '), fk.on_delete, fk.name] }
|
|
250
|
+
self.class.markdown_table(['Table', 'Column', 'On Delete', 'Name'], rows)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def constraints_table(constraints)
|
|
254
|
+
self.class.markdown_table(%w[Name Expression], constraints.map { |c| [c.name, "`#{c.expression}`"] })
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def triggers_table(triggers)
|
|
258
|
+
rows = triggers.map { |tr| [tr.name, [tr.timing, tr.events].compact.join(' '), "`#{tr.action}`"] }
|
|
259
|
+
self.class.markdown_table(%w[Name When Action], rows)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# The ErdWriter for a database, shared with the related-tables list so the
|
|
263
|
+
# page and its diagram agree on what is related
|
|
264
|
+
def erd_for(db)
|
|
265
|
+
@erds ||= {}
|
|
266
|
+
@erds[db.name] ||= ErdWriter.new(db, models: @app.models.select { |m| m.database == db.name })
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Linked lists of the tables this one references and the tables that
|
|
270
|
+
# reference it, foreign keys and polymorphic associations alike. Markdown
|
|
271
|
+
# renderers show the diagram as a static image, so this is the navigation
|
|
272
|
+
# the diagram's own links would give.
|
|
273
|
+
def related_tables(db, t)
|
|
274
|
+
references = []
|
|
275
|
+
referenced = []
|
|
276
|
+
erd_for(db).relations(t).each do |from, key|
|
|
277
|
+
column = Array(key.column).join(', ')
|
|
278
|
+
note = key.is_a?(ErdWriter::PolymorphicKey) ? ' (polymorphic)' : ''
|
|
279
|
+
if from == t.name
|
|
280
|
+
references << "- #{page_link(db.name, key.to_table)} via `#{column}`#{note}"
|
|
281
|
+
else
|
|
282
|
+
referenced << "- #{page_link(db.name, from)} via `#{column}`#{note}"
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
return nil if references.empty? && referenced.empty?
|
|
286
|
+
|
|
287
|
+
parts = []
|
|
288
|
+
parts << "**References:**\n\n#{references.uniq.join("\n")}" if references.any?
|
|
289
|
+
parts << "**Referenced by:**\n\n#{referenced.uniq.join("\n")}" if referenced.any?
|
|
290
|
+
parts.join("\n\n")
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# The Relations section: the related-tables list and, when the ERD is an
|
|
294
|
+
# image format, the diagram. GitHub and most Markdown renderers show the
|
|
295
|
+
# image statically, so the links inside the SVG only work when it is opened
|
|
296
|
+
# from a checkout; the list above it is the navigation that always works.
|
|
297
|
+
def diagram_section(db, t)
|
|
298
|
+
parts = ['## Relations']
|
|
299
|
+
parts << (related_tables(db, t) || 'No other table is related to this one.')
|
|
300
|
+
if %w[svg png].include?(@erd_format)
|
|
301
|
+
file = "#{basename(db.name, t.name)}.#{@erd_format}"
|
|
302
|
+
parts << ""
|
|
303
|
+
parts << 'Solid edges are database foreign keys; dashed edges are polymorphic associations the models declare. ' \
|
|
304
|
+
'The table names in the diagram are links, but only when the SVG is opened on its own from a checkout; ' \
|
|
305
|
+
'on GitHub use the list above.'
|
|
306
|
+
end
|
|
307
|
+
parts.join("\n\n")
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def columns_table(t)
|
|
311
|
+
described = t.columns.any?(&:comment)
|
|
312
|
+
headers = %w[Column Type Null Default Key]
|
|
313
|
+
headers << 'Description' if described
|
|
314
|
+
|
|
315
|
+
rows = t.columns.map do |c|
|
|
316
|
+
row = [c.name, c.sql_type, c.null ? 'YES' : 'NO', c.default, column_keys(t, c.name)]
|
|
317
|
+
row << c.comment if described
|
|
318
|
+
row
|
|
319
|
+
end
|
|
320
|
+
self.class.markdown_table(headers, rows)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# "PK", "FK → table", "UQ" markers for a column
|
|
324
|
+
def column_keys(t, column_name)
|
|
325
|
+
keys = []
|
|
326
|
+
keys << 'PK' if Array(t.primary_key).include?(column_name)
|
|
327
|
+
t.foreign_keys.each { |fk| keys << "FK → #{fk.to_table}" if Array(fk.column).include?(column_name) }
|
|
328
|
+
keys << 'UQ' if t.indexes.any? { |i| i.unique && i.columns == [column_name] }
|
|
329
|
+
keys.join(', ')
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def indexes_table(t)
|
|
333
|
+
rows = t.indexes.map do |i|
|
|
334
|
+
[i.name, i.columns.join(', '), i.unique ? 'YES' : '', i.using, i.where && "`#{i.where}`"]
|
|
335
|
+
end
|
|
336
|
+
self.class.markdown_table(%w[Name Columns Unique Using Condition], rows)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def foreign_keys_table(db, t)
|
|
340
|
+
rows = t.foreign_keys.map do |fk|
|
|
341
|
+
[Array(fk.column).join(', '), "#{page_link(db.name, fk.to_table)}.#{Array(fk.primary_key).join(', ')}", fk.on_delete, fk.on_update, fk.name]
|
|
342
|
+
end
|
|
343
|
+
self.class.markdown_table(['Column', 'References', 'On Delete', 'On Update', 'Name'], rows)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# [[table_name, ForeignKey], ...] for keys in other tables pointing at this one
|
|
347
|
+
def referencing_keys(db, t)
|
|
348
|
+
db.tables.flat_map { |other| other.foreign_keys.select { |fk| fk.to_table == t.name }.map { |fk| [other.name, fk] } }
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# The table's current name plus every name it was renamed from, found by
|
|
352
|
+
# walking the renames back from newest to oldest
|
|
353
|
+
def table_names(db, t)
|
|
354
|
+
names = [t.name]
|
|
355
|
+
db.migrations.reverse_each do |m|
|
|
356
|
+
m.renames.each { |old, new| names << old if names.include?(new) && !names.include?(old) }
|
|
357
|
+
end
|
|
358
|
+
names
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# [[Migration, [change, ...]], ...] for the migrations that touched this table, oldest first
|
|
362
|
+
def migrations_for(db, t)
|
|
363
|
+
names = table_names(db, t)
|
|
364
|
+
db.migrations.filter_map do |m|
|
|
365
|
+
changes = names.flat_map { |n| m.tables.fetch(n) { [] } }
|
|
366
|
+
[m, changes] if changes.any?
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def migration_link(m)
|
|
371
|
+
return m.path unless @output_dir
|
|
372
|
+
|
|
373
|
+
Pathname.new(m.path).relative_path_from(Pathname.new(@output_dir)).to_s
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def migrations_section(db, t)
|
|
377
|
+
rows = migrations_for(db, t).map do |m, changes|
|
|
378
|
+
[self.class.format_version(m.version).sub(/ \(\d+\)\z/, ''),
|
|
379
|
+
"[#{m.name}](#{migration_link(m)})",
|
|
380
|
+
changes.map { |c| "`#{c}`" }.join(', ')]
|
|
381
|
+
end
|
|
382
|
+
return nil if rows.empty?
|
|
383
|
+
|
|
384
|
+
"## Migrations\n\n#{self.class.markdown_table(['Version', 'Migration', 'Changes to this table'], rows)}"
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def model_detail(m)
|
|
388
|
+
return "## Model: #{m.name}\n\nCould not be inspected: #{m.error}" if m.error
|
|
389
|
+
|
|
390
|
+
parts = ["## Model: #{m.name}", model_facts(m),
|
|
391
|
+
section("#{m.name} Associations", m.associations, level: '###') { |a| associations_table(a) },
|
|
392
|
+
section("#{m.name} Enums", m.enums, level: '###') { |e| enums_table(e) },
|
|
393
|
+
section("#{m.name} Validations", m.validations, level: '###') { |v| validations_table(v) }]
|
|
394
|
+
parts.concat(model_extras(m))
|
|
395
|
+
parts.compact.join("\n\n")
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def model_facts(m)
|
|
399
|
+
facts = []
|
|
400
|
+
facts << "**Source:** `#{m.source}`" if m.source
|
|
401
|
+
facts << "**STI subclass of:** #{m.sti_parent}" if m.sti_parent
|
|
402
|
+
facts << "**STI type column:** #{m.sti_column}" if m.sti_column
|
|
403
|
+
facts.join(' · ') unless facts.empty?
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def associations_table(associations)
|
|
407
|
+
rows = associations.map do |a|
|
|
408
|
+
[a.macro, a.name, a.class_name || '(polymorphic)', a.foreign_key, self.class.format_options(a.options)]
|
|
409
|
+
end
|
|
410
|
+
self.class.markdown_table(['Type', 'Name', 'Class', 'Foreign Key', 'Options'], rows)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def enums_table(enums)
|
|
414
|
+
self.class.markdown_table(%w[Attribute Values], enums.map { |attr, mapping| [attr, self.class.enum_values(mapping)] })
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def validations_table(validations)
|
|
418
|
+
rows = validations.map { |v| [v.kind, v.attributes.join(', '), self.class.format_options(v.options)] }
|
|
419
|
+
self.class.markdown_table(%w[Validation Attributes Options], rows)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Store accessors, attachments, encrypted and virtual attributes, one line each
|
|
423
|
+
def model_extras(m)
|
|
424
|
+
extras = m.stored_attributes.map { |store, keys| "**Store `#{store}`:** #{keys.join(', ')}" }
|
|
425
|
+
extras << "**Attachments:** #{m.attachments.join(', ')}" if m.attachments.any?
|
|
426
|
+
extras << "**Encrypted attributes:** #{m.encrypted_attributes.join(', ')}" if m.encrypted_attributes.any?
|
|
427
|
+
extras << "**Virtual attributes:** #{m.virtual_attributes.join(', ')}" if m.virtual_attributes.any?
|
|
428
|
+
extras
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def orphan_models
|
|
432
|
+
inspected = @app.databases.reject(&:error)
|
|
433
|
+
known = inspected.flat_map { |db| db.tables.map { |t| [db.name, t.name] } }
|
|
434
|
+
orphans = @app.models.select { |m| inspected.any? { |db| db.name == m.database } }
|
|
435
|
+
.reject { |m| known.include?([m.database, m.table_name]) }
|
|
436
|
+
return nil if orphans.empty?
|
|
437
|
+
|
|
438
|
+
rows = orphans.map { |m| [m.name, m.database, m.table_name, m.source, m.error] }
|
|
439
|
+
<<~SECTION.chomp
|
|
440
|
+
## Models Without a Database Table
|
|
441
|
+
|
|
442
|
+
These models point at a table that was not found in their database.
|
|
443
|
+
The table may not be migrated yet, or the model may be unused.
|
|
444
|
+
|
|
445
|
+
#{self.class.markdown_table(%w[Model Database Table Source Error], rows)}
|
|
446
|
+
SECTION
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def data_element_index
|
|
450
|
+
entries = @app.databases.flat_map do |db|
|
|
451
|
+
db.tables.flat_map { |t| t.columns.map { |c| [c, db.name, t.name] } }
|
|
452
|
+
end
|
|
453
|
+
return nil if entries.empty?
|
|
454
|
+
|
|
455
|
+
entries.sort_by! { |c, db_name, table_name| [c.name, db_name, table_name] }
|
|
456
|
+
described = entries.any? { |c, _, _| c.comment }
|
|
457
|
+
headers = ['Data Element Name', 'Table', 'Type', 'Null']
|
|
458
|
+
headers.insert(1, 'Database') if @multi
|
|
459
|
+
headers << 'Description' if described
|
|
460
|
+
rows = entries.map do |c, db_name, table_name|
|
|
461
|
+
row = [c.name, page_link(db_name, table_name), c.sql_type, c.null ? 'YES' : 'NO']
|
|
462
|
+
row.insert(1, db_name) if @multi
|
|
463
|
+
row << c.comment if described
|
|
464
|
+
row
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
<<~SECTION.chomp
|
|
468
|
+
## Data Element Index
|
|
469
|
+
|
|
470
|
+
Every column in alphabetic order. The same data element name may
|
|
471
|
+
be used in more than one table; compare the types to spot drift.
|
|
472
|
+
Each table name links to that table's page.
|
|
473
|
+
|
|
474
|
+
#{self.class.markdown_table(headers, rows)}
|
|
475
|
+
SECTION
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
end
|
data/lib/pgdd/types.rb
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgdd
|
|
4
|
+
# Everything the Collector learns about an application, as plain values.
|
|
5
|
+
# Report and ErdWriter consume these; nothing below depends on Rails.
|
|
6
|
+
App = Data.define(:name, :root, :environment, :rails_version, :ruby_version, :databases, :models)
|
|
7
|
+
|
|
8
|
+
Database = Data.define(:name, :adapter, :location, :server_version, :schema_version,
|
|
9
|
+
:pending_migrations, :extensions, :enums, :tables, :migrations, :error) do
|
|
10
|
+
def initialize(name:, adapter: nil, location: nil, server_version: nil, schema_version: nil,
|
|
11
|
+
pending_migrations: nil, extensions: [], enums: {}, tables: [], migrations: [], error: nil)
|
|
12
|
+
super
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
Table = Data.define(:name, :kind, :comment, :primary_key, :columns, :indexes, :foreign_keys,
|
|
17
|
+
:check_constraints, :triggers, :rows, :rows_estimated, :bytes) do
|
|
18
|
+
def initialize(name:, kind: 'table', comment: nil, primary_key: nil, columns: [], indexes: [],
|
|
19
|
+
foreign_keys: [], check_constraints: [], triggers: [], rows: nil,
|
|
20
|
+
rows_estimated: false, bytes: nil)
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
Column = Data.define(:name, :sql_type, :null, :default, :comment) do
|
|
26
|
+
def initialize(name:, sql_type:, null: true, default: nil, comment: nil)
|
|
27
|
+
super
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
Index = Data.define(:name, :columns, :unique, :where, :using) do
|
|
31
|
+
def initialize(name:, columns:, unique: false, where: nil, using: nil)
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
ForeignKey = Data.define(:name, :column, :to_table, :primary_key, :on_delete, :on_update) do
|
|
36
|
+
def initialize(column:, to_table:, name: nil, primary_key: 'id', on_delete: nil, on_update: nil)
|
|
37
|
+
super
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
Constraint = Data.define(:name, :expression)
|
|
41
|
+
Trigger = Data.define(:name, :timing, :events, :action)
|
|
42
|
+
|
|
43
|
+
# One migration file: tables maps each table it touches to the changes made
|
|
44
|
+
# to it, in file order; renames lists [old_name, new_name] pairs
|
|
45
|
+
Migration = Data.define(:version, :name, :path, :tables, :renames) do
|
|
46
|
+
def initialize(version:, name:, path:, tables: {}, renames: [])
|
|
47
|
+
super
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
Model = Data.define(:name, :table_name, :database, :source, :sti_parent, :sti_column,
|
|
52
|
+
:associations, :enums, :validations, :stored_attributes, :attachments,
|
|
53
|
+
:encrypted_attributes, :virtual_attributes, :error) do
|
|
54
|
+
def initialize(name:, table_name: nil, database: 'primary', source: nil, sti_parent: nil,
|
|
55
|
+
sti_column: nil, associations: [], enums: {}, validations: [],
|
|
56
|
+
stored_attributes: {}, attachments: [], encrypted_attributes: [],
|
|
57
|
+
virtual_attributes: [], error: nil)
|
|
58
|
+
super
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
Association = Data.define(:macro, :name, :class_name, :foreign_key, :options) do
|
|
63
|
+
def initialize(macro:, name:, class_name: nil, foreign_key: nil, options: {})
|
|
64
|
+
super
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
Validation = Data.define(:kind, :attributes, :options) do
|
|
68
|
+
def initialize(kind:, attributes:, options: {})
|
|
69
|
+
super
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/pgdd/version.rb
ADDED
data/lib/pgdd.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
require 'erb'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
require 'optparse'
|
|
7
|
+
require 'pathname'
|
|
8
|
+
|
|
9
|
+
# Pgdd boots a Rails application and writes as-built database documentation from
|
|
10
|
+
# its live database(s) and its ActiveRecord models: a Markdown index, one page
|
|
11
|
+
# per table, and Graphviz entity relationship diagrams.
|
|
12
|
+
#
|
|
13
|
+
# The pipeline is Collector (database + models -> App) -> Report (App ->
|
|
14
|
+
# Markdown pages) and ErdWriter (Database -> DOT/SVG). CLI wires them to the
|
|
15
|
+
# command line; the Railtie adds the pgdd:generate rake task.
|
|
16
|
+
module Pgdd
|
|
17
|
+
class Error < StandardError; end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
require_relative 'pgdd/version'
|
|
21
|
+
require_relative 'pgdd/types'
|
|
22
|
+
require_relative 'pgdd/migration_scanner'
|
|
23
|
+
require_relative 'pgdd/database_inspector'
|
|
24
|
+
require_relative 'pgdd/model_inspector'
|
|
25
|
+
require_relative 'pgdd/erd_writer'
|
|
26
|
+
require_relative 'pgdd/report'
|
|
27
|
+
require_relative 'pgdd/collector'
|
|
28
|
+
require_relative 'pgdd/cli'
|
|
29
|
+
require_relative 'pgdd/railtie' if defined?(Rails::Railtie)
|
data/lib/tasks/pgdd.rake
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :pgdd do
|
|
4
|
+
desc 'Write the database documentation and ERDs to OUTPUT (default docs/database); extra pgdd flags in PGDD_OPTIONS'
|
|
5
|
+
task :generate, [:output] => :environment do |_task, args|
|
|
6
|
+
output = args[:output] || ENV.fetch('PGDD_OUTPUT', 'docs/database')
|
|
7
|
+
argv = ['--output', output] + ENV.fetch('PGDD_OPTIONS', '').split
|
|
8
|
+
status = Pgdd::CLI.run(argv)
|
|
9
|
+
raise "pgdd exited with status #{status}" unless status.zero?
|
|
10
|
+
end
|
|
11
|
+
end
|
data/reek_output.txt
ADDED
data/rubocop_output.txt
ADDED
data/scrap_output.txt
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
|
|
2
|
+
test/cli_test.rb
|
|
3
|
+
score 32.35 MEDIUM mode LOCAL ai REVIEW_FIRST tests=8 avg=7.37 max=15.01
|
|
4
|
+
why low-assert=0.12 branching=0.00 mocking=0.00 helper-hidden=8 harmful-dup=4 extraction=0.0 matrix=0 subject-rep=8
|
|
5
|
+
LOW Be skeptical of helper extraction that only hides setup; helper-hidden complexity should still count as complexity.
|
|
6
|
+
15.0 L47 test_command_line_is_the_invocation_as_typed helper-hidden-complexity
|
|
7
|
+
8.3 L53 test_help_and_version_exit_zero
|
|
8
|
+
8.0 L26 test_every_flag literal-heavy-setup
|
|
9
|
+
8.0 L74 test_directory_without_a_rails_app_exits_one temp-resource-work
|
|
10
|
+
5.0 L13 test_defaults
|
|
11
|
+
|
|
12
|
+
test/migration_scanner_test.rb
|
|
13
|
+
score 24.79 MEDIUM mode LOCAL ai AUTO_REFACTOR tests=14 avg=5.37 max=17.95
|
|
14
|
+
why low-assert=0.57 branching=0.14 mocking=0.00 helper-hidden=0 harmful-dup=14 extraction=0.0 matrix=4 subject-rep=13
|
|
15
|
+
HIGH Convert repeated low-complexity tests into table-driven checks; treat this as coverage-matrix repetition, not harmful duplication.
|
|
16
|
+
HIGH Strengthen assertions in weak tests before doing structural cleanup.
|
|
17
|
+
18.0 L96 test_loop_variables_attribute_to_every_listed_table low-assertion-density,large-example,literal-heavy-setup
|
|
18
|
+
11.3 L154 test_scans_a_directory_in_version_order temp-resource-work
|
|
19
|
+
10.0 L8 test_create_table_with_indexes low-assertion-density,literal-heavy-setup
|
|
20
|
+
10.0 L135 test_model_backfill_with_explicit_table_name low-assertion-density,literal-heavy-setup
|
|
21
|
+
8.0 L33 test_down_method_and_dir_down_are_skipped large-example,literal-heavy-setup
|
|
22
|
+
|
|
23
|
+
5 files, 58 tests, 0 structure errors
|
|
24
|
+
modes: LOCAL=2 STABLE=3
|
|
25
|
+
ai: AUTO_REFACTOR=1 LEAVE_ALONE=3 REVIEW_FIRST=1
|
|
26
|
+
levels: MEDIUM=2 STABLE=3
|
|
27
|
+
smells: literal-heavy-setup=6 temp-resource-work=6 low-assertion-density=3 large-example=2 helper-hidden-complexity=1 multiple-phases=1
|
|
28
|
+
extractions recommended: 0, table-drive candidates: 4
|
data/test_output.txt
ADDED
data/typos_output.txt
ADDED
|
File without changes
|