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,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 << "![#{t.name} entity relationship diagram](#{file})"
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgdd
4
+ VERSION = "0.1.0"
5
+ end
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)
@@ -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
@@ -0,0 +1,2 @@
1
+ Reek: 62 warning(s) across 8 file(s) (62 grandfathered across 8 file(s)).
2
+ Reek: no new or worsened files (quality gate passed)
@@ -0,0 +1,4 @@
1
+ Inspecting 22 files
2
+ ......................
3
+
4
+ 22 files inspected, no offenses detected
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
@@ -0,0 +1,9 @@
1
+ Run options: --seed 38033
2
+
3
+ # Running:
4
+
5
+ ..........................................................
6
+
7
+ Finished in 0.076255s, 760.6059 runs/s, 4065.3072 assertions/s.
8
+
9
+ 58 runs, 310 assertions, 0 failures, 0 errors, 0 skips
data/typos_output.txt ADDED
File without changes