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
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
|
data/fasterer_output.txt
ADDED
|
@@ -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
|