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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2ec7b3e45b4b1e40622b867cceb8e28fdc9e4d5ac2cb19e4a75c95215581eb10
4
+ data.tar.gz: b4ad296661b2b1ae8a5775bb05c49801c10f569ac6d58354b242c1e0f9d518fa
5
+ SHA512:
6
+ metadata.gz: ff4f072c2529929e3da0f1737b16fc744831c8a794c3bac453928925618b8375af5f124eac886644e14fea3fdb53ebee696ac3aaec376b0d216244d2d5713fcf
7
+ data.tar.gz: 13590142d6fa9cc19f849b6cfdb6c22f99c00f59d2d3d346d7ee37390508e7ec18b82228c80fc0eb011d4356e73dd5b5032eb9525fdeaaf9736f8eed70af28c8
data/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-10-22
4
+
5
+ Initial release, converted from the single-file `data_dictionary_v3.rb` script.
6
+
7
+ - `pgdd` command and `pgdd:generate` rake task.
8
+ - A generated `README.md` in the output directory, from `lib/pgdd/readme_template.md`: what the files are, how to read the diagrams, and the command that produced the copy.
9
+ - Markdown index page and one page per table: columns, indexes, foreign keys, referencing tables, check constraints, triggers, migrations and model details.
10
+ - Graphviz ERD per table (focus table in full, related tables by key columns) and one for the whole schema; dark theme, transparent background, boxes and edges link to the pages.
11
+ - Polymorphic associations drawn as dashed edges from the models' `has_one`/`has_many ..., as:` declarations.
12
+ - Migrations that changed each table, found by reading `db/migrate`: schema DSL calls, SQL in `execute`, model backfills with an explicit table name, and renames.
13
+ - Several databases (`--database`, `--all-databases`), SQLite and PostgreSQL specifics, exact or estimated row counts.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Dewayne VanHoozer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # pgdd
2
+
3
+ As-built database documentation for a Rails application, with entity relationship diagrams.
4
+
5
+ `pgdd` boots your application, reads its live database and its ActiveRecord models, and writes Markdown you can commit next to the code: an index page, one page per table, and a Graphviz diagram per table plus one of the whole schema. Nothing is hand-written and nothing comes from a design document. It records what the application actually has.
6
+
7
+ From the database it reads tables, views, columns, keys, indexes, foreign keys, check constraints, triggers, comments, enum types, extensions, row counts and sizes. From the models it reads associations, enums, validations, STI hierarchies, store accessors, attachments, encrypted and virtual attributes. From `db/migrate` it works out which migrations changed each table.
8
+
9
+ PostgreSQL gets the fullest treatment (enum types, triggers, planner row estimates, materialized views). SQLite is supported, and anything else ActiveRecord can connect to gets the parts the adapter exposes.
10
+
11
+ ## Installation
12
+
13
+ Add it to the application's Gemfile, in the development group:
14
+
15
+ ```ruby
16
+ group :development do
17
+ gem 'pgdd'
18
+ end
19
+ ```
20
+
21
+ The diagrams are rendered with Graphviz, so `dot` must be on the `PATH` (`brew install graphviz`, `apt install graphviz`). Without it, use `--erd-format dot` to write the DOT source instead, or `--no-erd`.
22
+
23
+ Requires Ruby 3.2+ and Rails 6.1+. The gem has no runtime dependencies of its own; it runs inside the application's Rails.
24
+
25
+ ## Usage
26
+
27
+ From the application's root:
28
+
29
+ ```bash
30
+ bundle exec pgdd --output docs/database
31
+ ```
32
+
33
+ Or as a rake task, which does the same thing inside the loaded application:
34
+
35
+ ```bash
36
+ bin/rails "pgdd:generate[docs/database]"
37
+ PGDD_OPTIONS="--count --all-databases" bin/rails pgdd:generate
38
+ ```
39
+
40
+ Without `--output`, only the index page is written, to STDOUT.
41
+
42
+ | Option | Effect |
43
+ | ----------------------- | ------------------------------------------------------------------------------------------ |
44
+ | `-o`, `--output DIR` | Write the index, one page per table and the diagrams to DIR |
45
+ | `-e`, `--environment` | Rails environment (default: `RAILS_ENV` or `development`) |
46
+ | `-d`, `--database NAME` | Report this database config; repeatable (default: the primary only) |
47
+ | `-a`, `--all-databases` | Report every database config (cache, queue, cable, ...) |
48
+ | `-c`, `--count` | Exact row counts with `SELECT COUNT(*)` instead of planner estimates (can be slow) |
49
+ | `-i`, `--internal` | Include `schema_migrations` and `ar_internal_metadata` |
50
+ | `-f`, `--erd-format` | `svg` (default), `png`, `pdf`, or `dot` for the source |
51
+ | `--no-erd` | Skip the diagrams |
52
+
53
+ `pgdd` only reads. The whole run happens inside `ActiveRecord::Base.while_preventing_writes`.
54
+
55
+ ## What it writes
56
+
57
+ | File | Content |
58
+ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
59
+ | `README.md` | A guide to the directory: what the files are, how to read the diagrams, and the exact command that generated this copy. Rendered from the gem's `readme_template.md` |
60
+ | `INDEX.md` | The index: each database with its adapter, version and schema version; every table with a link to its page; enum types; models without a table; a data element index of every column across all tables |
61
+ | `<table>.md` | Columns, indexes, foreign keys, the tables that reference it, check constraints, triggers, the migrations that changed it, and the models backed by it |
62
+ | `<table>.svg` | The table's entity relationship diagram, embedded in its page |
63
+ | `schema.svg` | Every table in one diagram |
64
+
65
+ With more than one database, page and diagram names are prefixed: `queue.solid_queue_jobs.md`.
66
+
67
+ The Markdown passes markdownlint and prettier as written: table pipes are aligned, and there are no hard-wrapped tables.
68
+
69
+ ### The diagrams
70
+
71
+ Each table's diagram draws that table in full. Its related tables are collapsed to their primary key and the columns that take part in the relationship, so a table with a dozen children stays readable instead of becoming several screens tall. The full schema of a related table is one click away: every table name and column in a diagram links to that table's page. `schema.svg` draws every table the compact way.
72
+
73
+ - Solid edges are foreign keys that exist in the database. Crow's foot on the many side, bar and ring on the parent key.
74
+ - Dashed edges are polymorphic associations the models declare (`has_one`/`has_many ..., as:`), which Rails cannot back with a database foreign key. A single bar on the child end means the inverse is `has_one`.
75
+ - Hovering shows table and column comments, and for a dashed edge the model the type column must name.
76
+
77
+ Dark theme, transparent background, so they look right on both GitHub themes.
78
+
79
+ **The links inside an SVG do not work on GitHub.** GitHub shows an SVG as a static image, embedded or on its own, and clicking an image opens the file. The links work when the SVG is opened from a checkout. For navigation that works everywhere, each page's Relations section opens with a linked list of the tables it references and the tables that reference it, polymorphic ones included.
80
+
81
+ ### Migrations
82
+
83
+ Each page lists every migration that changed the table, oldest first, with a link to the file and what it did (`create_table`, `add_column availability`, `ALTER TABLE (SQL)`). The list comes from reading the migration files, not replaying them. It covers the schema DSL calls, including calls inside loops over a `%i[...]` list of tables, SQL inside `execute` strings and heredocs, backfills through a migration-local model with an explicit `table_name`, and `rename_table`, so a renamed table's page includes the migrations under its old name. Only the forward direction is read; `down` methods and `dir.down` blocks are ignored. A migration that changes data only through application models it does not name by table will not appear.
84
+
85
+ ## Library use
86
+
87
+ The command is a thin wrapper. Inside a booted application:
88
+
89
+ ```ruby
90
+ app = Pgdd::Collector.new(root: Rails.root.to_s, count_rows: true).call # Pgdd::App
91
+ report = Pgdd::Report.new(app, erd_format: 'svg', output_dir: 'docs/database')
92
+ report.pages # { 'README.md' => '...', 'INDEX.md' => '...', 'users.md' => '...' }
93
+ Pgdd::ErdWriter.new(app.databases.first, models: app.models).diagrams # { 'users' => 'digraph ...', 'schema' => '...' }
94
+ ```
95
+
96
+ `Pgdd::App` and everything under it are plain `Data` values, so `Report` and `ErdWriter` need no database and are tested without one. `Pgdd::MigrationScanner.tables(source)` works on any migration source string.
97
+
98
+ ## Development
99
+
100
+ ```bash
101
+ bundle install
102
+ bundle exec rake test
103
+ ```
104
+
105
+ The tests cover the scanner, the report and the diagram builder with hand-built values. Running the command end to end needs a Rails application; point it at one with `bundle exec bin/pgdd path/to/app --output /tmp/dd`.
106
+
107
+ ## License
108
+
109
+ MIT. See [LICENSE.txt](LICENSE.txt).
data/bin/pgdd ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Runs from a checkout (bin on the PATH) as well as from the installed gem
5
+ lib = File.expand_path('../lib', __dir__)
6
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
7
+
8
+ require 'pgdd'
9
+
10
+ exit Pgdd::CLI.run(ARGV)
@@ -0,0 +1,10 @@
1
+ Updating ruby-advisory-db ...
2
+ From https://github.com/rubysec/ruby-advisory-db
3
+ * branch master -> FETCH_HEAD
4
+ Already up to date.
5
+ Updated ruby-advisory-db
6
+ ruby-advisory-db:
7
+ advisories: 1266 advisories
8
+ last updated: 2026-10-21 00:11:45 +0200
9
+ commit: 5fd86bbd406995ea4ed2df84651b209e28072031
10
+ No vulnerabilities found
@@ -0,0 +1 @@
1
+ 11 files inspected, 0 offenses detected
data/flay_output.txt ADDED
@@ -0,0 +1 @@
1
+ Flay: no structural duplication detected (mass >= 150)
data/flog_output.txt ADDED
@@ -0,0 +1,29 @@
1
+ Flog warnings (20.0–50.0):
2
+ 42.2: Pgdd::Report#database_summary
3
+ 39.0: Pgdd::Report::markdown_table
4
+ 36.5: Pgdd::Report#related_tables
5
+ 36.2: Pgdd::ErdWriter#key_columns
6
+ 35.9: Pgdd::ErdWriter::node
7
+ 34.6: Pgdd::Report#orphan_models
8
+ 34.5: Pgdd::ModelInspector#associations
9
+ 34.3: Pgdd::Report#data_element_index
10
+ 34.0: Pgdd::MigrationScanner::structural_changes
11
+ 33.5: Pgdd::CLI#parse_options
12
+ 29.8: Pgdd::Report#table_summary
13
+ 29.7: Pgdd::CLI#run
14
+ 27.4: Pgdd::ErdWriter#relations
15
+ 27.1: Pgdd::ErdWriter#polymorphic_key
16
+ 25.6: Pgdd::ErdWriter::edge
17
+ 24.3: Pgdd::Report::human_size
18
+ 24.2: Pgdd::Report#database_section
19
+ 23.5: Pgdd::Report#model_detail
20
+ 22.8: Pgdd::ErdWriter#table_dot
21
+ 22.4: Pgdd::Report#column_keys
22
+ 22.2: Pgdd::Report::format_version
23
+ 22.0: Pgdd::MigrationScanner::forward_source
24
+ 22.0: Pgdd::Collector#call
25
+ 22.0: Pgdd::Report#model_extras
26
+ 21.7: Pgdd::Report#table_facts
27
+
28
+ Flog failures (>= 50.0):
29
+ (none)
data/lib/pgdd/cli.rb ADDED
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgdd
4
+ # The pgdd command: parses options, boots the application unless it is
5
+ # already loaded (bin/rails runner, a rake task), collects, and writes
6
+ class CLI
7
+ DEFAULTS = { environment: nil, output: nil, count_rows: false, include_internal: false, databases: [],
8
+ all_databases: false, erd: true, erd_format: 'svg' }.freeze
9
+
10
+ # Runs with argv and returns the process exit status
11
+ def self.run(argv, out: $stdout, err: $stderr)
12
+ new(out: out, err: err).run(argv)
13
+ end
14
+
15
+ def initialize(out: $stdout, err: $stderr)
16
+ @out = out
17
+ @err = err
18
+ end
19
+
20
+ def run(argv)
21
+ options = parse_options(argv)
22
+ return show(options[:help]) if options[:help]
23
+
24
+ root = rails_root(options)
25
+ return fail_with("not a Rails application: #{root}") unless File.exist?(File.join(root, 'config', 'environment.rb'))
26
+
27
+ boot(root, options[:environment])
28
+ ActiveRecord::Base.logger = nil
29
+ app = ActiveRecord::Base.while_preventing_writes do
30
+ Collector.new(root: root, **options.slice(:count_rows, :include_internal, :databases, :all_databases)).call
31
+ end
32
+ write(app, options, command_line(argv))
33
+ rescue OptionParser::ParseError, ErdWriter::Error => e
34
+ fail_with(e.message)
35
+ rescue Errno::ENOENT => e
36
+ raise unless e.message.end_with?('- dot')
37
+
38
+ fail_with('Graphviz dot not found; install graphviz or use --erd-format dot')
39
+ end
40
+
41
+ def show(text)
42
+ @out.puts text
43
+ 0
44
+ end
45
+
46
+ def fail_with(message)
47
+ @err.puts "ERROR: #{message}"
48
+ 1
49
+ end
50
+
51
+ # Rails.root when running inside the application, else the argument
52
+ def rails_root(options)
53
+ defined?(::Rails.root) && ::Rails.root ? ::Rails.root.to_s : options[:root]
54
+ end
55
+
56
+ # The pages and diagrams into the output directory, or the index to STDOUT
57
+ def write(app, options, command)
58
+ dir = options[:output]
59
+ erd_format = options[:erd] && dir ? options[:erd_format] : nil
60
+ report = Report.new(app, erd_format: erd_format, output_dir: dir, command: command)
61
+
62
+ if dir
63
+ FileUtils.mkdir_p(dir)
64
+ report.pages.each { |name, markdown| File.write(File.join(dir, name), markdown) }
65
+ write_erds(app, report, dir, erd_format) if erd_format
66
+ else
67
+ @out.print report.index_markdown
68
+ end
69
+ 0
70
+ end
71
+
72
+ # The invocation as typed, for the generated README
73
+ def command_line(argv)
74
+ (['pgdd'] + argv.map { |a| a.match?(/[\s"']/) ? a.inspect : a }).join(' ')
75
+ end
76
+
77
+ # The option hash for argv; :root is the Rails root argument, expanded
78
+ def parse_options(argv)
79
+ options = DEFAULTS.transform_values(&:dup)
80
+
81
+ parser = OptionParser.new do |opts|
82
+ opts.banner = 'Usage: pgdd [options] [RAILS_ROOT]'
83
+ opts.on('-e', '--environment ENV', 'Rails environment (default: RAILS_ENV or development)') { |v| options[:environment] = v }
84
+ opts.on('-o', '--output DIR', "Write #{Report::INDEX}, a page per table and the diagrams to DIR",
85
+ "(without it only #{Report::INDEX} is written, to STDOUT)") { |v| options[:output] = File.expand_path(v) }
86
+ opts.on('-d', '--database NAME', 'Report this database config (repeatable; default: the primary only)') { |v| options[:databases] << v }
87
+ opts.on('-a', '--all-databases', 'Report every database config (cache, queue, cable, ...)') { options[:all_databases] = true }
88
+ opts.on('-c', '--count', 'Exact row counts with SELECT COUNT(*) (can be slow)') { options[:count_rows] = true }
89
+ opts.on('-i', '--internal', 'Include schema_migrations and ar_internal_metadata') { options[:include_internal] = true }
90
+ opts.on('-f', '--erd-format FORMAT', ErdWriter::FORMATS, "ERD format: #{ErdWriter::FORMATS.join(', ')} (default: svg; needs Graphviz dot unless dot)") { |v| options[:erd_format] = v }
91
+ opts.on('--no-erd', 'Skip the ERD diagrams') { options[:erd] = false }
92
+ opts.on('-v', '--version', 'Show the version') { options[:help] = "pgdd #{VERSION}" }
93
+ opts.on('-h', '--help', 'Show this help') { options[:help] = opts.help }
94
+ end
95
+
96
+ options[:root] = File.expand_path(parser.parse(argv).first || '.')
97
+ options
98
+ end
99
+
100
+ # Loads the application unless we are already inside it
101
+ def boot(root, environment)
102
+ return if defined?(::Rails.application) && ::Rails.application
103
+
104
+ ENV['RAILS_ENV'] = environment if environment
105
+ ENV['RAILS_ENV'] ||= 'development'
106
+ Dir.chdir(root)
107
+ require File.join(root, 'config', 'environment')
108
+ end
109
+
110
+ # Diagrams share the report's file stems so the pages and the links inside the SVGs line up
111
+ def write_erds(app, report, dir, format)
112
+ app.databases.reject(&:error).flat_map do |db|
113
+ models = app.models.select { |m| m.database == db.name }
114
+ ErdWriter.new(db, models: models, name_for: ->(table_name) { report.basename(db.name, table_name) }).write(dir, format: format)
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgdd
4
+ # Reads the already-booted Rails application's databases and models into
5
+ # an App. Only reads: callers wrap it in while_preventing_writes.
6
+ class Collector
7
+ def initialize(root:, count_rows: false, include_internal: false, databases: [], all_databases: false)
8
+ @root = root
9
+ @count_rows = count_rows
10
+ @include_internal = include_internal
11
+ @databases = databases
12
+ @all_databases = all_databases
13
+ end
14
+
15
+ def call
16
+ configs = database_configs(@databases, all: @all_databases)
17
+ missing = configs.to_h { |c| [c.name, missing_sqlite_file(c.adapter, c.database)&.delete_prefix("#{@root}/")] }.compact
18
+ models = inspect_models(configs.map(&:name), missing)
19
+ dbs = inspect_databases(configs, missing)
20
+
21
+ App.new(name: app_name, root: @root, environment: Rails.env.to_s, rails_version: Rails.version,
22
+ ruby_version: RUBY_VERSION, databases: dbs, models: models)
23
+ end
24
+
25
+ def app_name
26
+ app_class = Rails.application.class
27
+ app_class.respond_to?(:module_parent_name) ? app_class.module_parent_name : app_class.parent_name
28
+ end
29
+
30
+ # Models on the named databases. Inspecting a model can open its
31
+ # connection, so a model on a missing database is reported, not inspected
32
+ def inspect_models(names, missing)
33
+ ModelInspector.models.filter_map do |m|
34
+ inspector = ModelInspector.new(m, root: @root)
35
+ db_name = inspector.database_name
36
+ next unless names.include?(db_name)
37
+ next Model.new(name: m.name, table_name: m.table_name, database: db_name, error: "database file not found: #{missing[db_name]}") if missing[db_name]
38
+
39
+ inspector.call
40
+ end
41
+ end
42
+
43
+ # A Database per config; one that cannot be read is returned with its error
44
+ def inspect_databases(configs, missing)
45
+ database_connections(configs).map do |name, klass|
46
+ missing[name] ? missing_database(name, klass, missing[name]) : inspect_database(name, klass)
47
+ rescue StandardError => e
48
+ Database.new(name: name, error: "#{e.class}: #{e.message.lines.first&.strip}")
49
+ end
50
+ end
51
+
52
+ def missing_database(name, klass, file)
53
+ config = klass.connection_db_config
54
+ Database.new(name: name, adapter: config.adapter, location: config.database, error: "database file not found: #{file}")
55
+ end
56
+
57
+ def inspect_database(name, klass)
58
+ with_connection(klass) do |connection|
59
+ DatabaseInspector.new(connection, name: name, root: @root, count_rows: @count_rows, include_internal: @include_internal).call
60
+ end
61
+ end
62
+
63
+ # The configs to report: the ones named, or all of them, or just the primary
64
+ def database_configs(only = [], all: false)
65
+ configs = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env)
66
+ configs = configs.reject { |c| c.respond_to?(:replica?) && c.replica? }
67
+ return configs.select { |c| only.include?(c.name) } if only.any?
68
+ return configs if all
69
+
70
+ primary = ActiveRecord::Base.connection_db_config.name
71
+ configs.select { |c| c.name == primary }
72
+ end
73
+
74
+ # [[config_name, connection_class], ...]; a secondary database gets an
75
+ # abstract class of its own, reused if one was already made for it
76
+ def database_connections(configs)
77
+ primary = ActiveRecord::Base.connection_db_config.name
78
+
79
+ configs.map do |config|
80
+ next [config.name, ActiveRecord::Base] if config.name == primary
81
+
82
+ [config.name, connection_class_for(config.name)]
83
+ end
84
+ end
85
+
86
+ def connection_class_for(config_name)
87
+ const = :"Connection#{config_name.to_s.gsub(/\W/, '_').capitalize}"
88
+ return self.class.const_get(const, false) if self.class.const_defined?(const, false)
89
+
90
+ klass = self.class.const_set(const, Class.new(ActiveRecord::Base) { self.abstract_class = true })
91
+ klass.establish_connection(config_name.to_sym)
92
+ klass
93
+ end
94
+
95
+ # Connecting to a missing SQLite file would create an empty one
96
+ def missing_sqlite_file(adapter, database)
97
+ return nil unless adapter.to_s.start_with?('sqlite') && database && !database.start_with?(':memory:', 'file:')
98
+
99
+ path = File.expand_path(database, @root)
100
+ path unless File.exist?(path)
101
+ end
102
+
103
+ def with_connection(klass, &)
104
+ return klass.with_connection(&) if klass.respond_to?(:with_connection)
105
+
106
+ yield klass.connection
107
+ end
108
+ end
109
+ end