pgdd 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE.txt +21 -0
- data/README.md +109 -0
- data/bin/pgdd +10 -0
- data/bundler_audit_output.txt +10 -0
- data/fasterer_output.txt +1 -0
- data/flay_output.txt +1 -0
- data/flog_output.txt +29 -0
- data/lib/pgdd/cli.rb +118 -0
- data/lib/pgdd/collector.rb +109 -0
- data/lib/pgdd/database_inspector.rb +242 -0
- data/lib/pgdd/erd_writer.rb +205 -0
- data/lib/pgdd/migration_scanner.rb +147 -0
- data/lib/pgdd/model_inspector.rb +162 -0
- data/lib/pgdd/railtie.rb +10 -0
- data/lib/pgdd/readme_template.md +61 -0
- data/lib/pgdd/report.rb +478 -0
- data/lib/pgdd/types.rb +72 -0
- data/lib/pgdd/version.rb +5 -0
- data/lib/pgdd.rb +29 -0
- data/lib/tasks/pgdd.rake +11 -0
- data/reek_output.txt +2 -0
- data/rubocop_output.txt +4 -0
- data/scrap_output.txt +28 -0
- data/test_output.txt +9 -0
- data/typos_output.txt +0 -0
- metadata +74 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgdd
|
|
4
|
+
# Reads what the application code says about one ActiveRecord model
|
|
5
|
+
class ModelInspector
|
|
6
|
+
SHOWN_OPTIONS = %i[through source source_type as polymorphic dependent inverse_of
|
|
7
|
+
counter_cache optional required touch primary_key foreign_type
|
|
8
|
+
autosave validate].freeze
|
|
9
|
+
ACTIVE_STORAGE = %w[ActiveStorage::Attachment ActiveStorage::Blob].freeze
|
|
10
|
+
|
|
11
|
+
def self.models
|
|
12
|
+
Rails.application.eager_load!
|
|
13
|
+
ActiveRecord::Base.descendants.select { |m| reportable?(m) }.sort_by(&:name)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.reportable?(model)
|
|
17
|
+
name = model.name
|
|
18
|
+
return false if name.nil? || model.abstract_class?
|
|
19
|
+
|
|
20
|
+
!name.include?('HABTM_') && !name.start_with?('ActiveRecord::', 'Pgdd::')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(model, root: Dir.pwd)
|
|
24
|
+
@model = model
|
|
25
|
+
@root = root
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call
|
|
29
|
+
Model.new(
|
|
30
|
+
name: @model.name,
|
|
31
|
+
table_name: @model.table_name,
|
|
32
|
+
database: database_name,
|
|
33
|
+
source: source,
|
|
34
|
+
sti_parent: sti_parent,
|
|
35
|
+
sti_column: sti_column,
|
|
36
|
+
associations: associations,
|
|
37
|
+
**own_definitions
|
|
38
|
+
)
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
Model.new(name: @model.name, table_name: (@model.table_name rescue nil), error: "#{e.class}: #{e.message}")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Definitions that can be inherited; an STI subclass only reports what it adds
|
|
44
|
+
def definitions
|
|
45
|
+
{
|
|
46
|
+
enums: enums,
|
|
47
|
+
validations: validations,
|
|
48
|
+
stored_attributes: stored_attributes,
|
|
49
|
+
attachments: attachments,
|
|
50
|
+
encrypted_attributes: encrypted_attributes,
|
|
51
|
+
virtual_attributes: virtual_attributes
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def own_definitions
|
|
56
|
+
mine = definitions
|
|
57
|
+
return mine unless sti_parent
|
|
58
|
+
|
|
59
|
+
inherited = self.class.new(@model.superclass, root: @root).definitions
|
|
60
|
+
mine.to_h { |key, value| [key, value.is_a?(Hash) ? value.reject { |k, v| inherited[key][k] == v } : value - inherited[key]] }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def database_name
|
|
64
|
+
@model.connection_db_config.name
|
|
65
|
+
rescue StandardError
|
|
66
|
+
'primary'
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def source
|
|
70
|
+
path = Object.const_source_location(@model.name)&.first
|
|
71
|
+
return nil unless path
|
|
72
|
+
|
|
73
|
+
path = File.expand_path(path)
|
|
74
|
+
return path.delete_prefix("#{@root}/") if path.start_with?("#{@root}/")
|
|
75
|
+
|
|
76
|
+
path[%r{/gems/(.+)}, 1] || path
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def sti_parent
|
|
80
|
+
@model.superclass.name unless @model.base_class == @model
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The inheritance column, when this model is the base of an STI hierarchy
|
|
84
|
+
def sti_column
|
|
85
|
+
column = @model.inheritance_column
|
|
86
|
+
return nil unless @model.base_class == @model && @model.descendants.any?
|
|
87
|
+
|
|
88
|
+
column if @model.column_names.include?(column)
|
|
89
|
+
rescue StandardError
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def associations
|
|
94
|
+
inherited = sti_parent ? @model.superclass.reflect_on_all_associations.map(&:name) : []
|
|
95
|
+
|
|
96
|
+
@model.reflect_on_all_associations.filter_map do |r|
|
|
97
|
+
next if inherited.include?(r.name) || ACTIVE_STORAGE.include?(r.options[:class_name].to_s)
|
|
98
|
+
|
|
99
|
+
options = r.options.slice(*SHOWN_OPTIONS)
|
|
100
|
+
options[:scope] = :lambda if r.scope
|
|
101
|
+
Association.new(
|
|
102
|
+
macro: r.macro,
|
|
103
|
+
name: r.name,
|
|
104
|
+
class_name: r.polymorphic? ? nil : r.class_name,
|
|
105
|
+
foreign_key: (r.options[:through] ? nil : (r.foreign_key rescue nil)),
|
|
106
|
+
options: options
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# { attribute => { key => stored_value } }
|
|
112
|
+
def enums
|
|
113
|
+
@model.defined_enums.transform_values(&:to_h)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def validations
|
|
117
|
+
validators = @model.validators.map do |v|
|
|
118
|
+
Validation.new(kind: v.kind, attributes: v.attributes.map(&:to_s), options: v.options)
|
|
119
|
+
end
|
|
120
|
+
validators + custom_validations
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# `validate :method_name` style validations, skipping the ones Rails adds itself
|
|
124
|
+
def custom_validations
|
|
125
|
+
@model._validate_callbacks.map(&:filter).grep(Symbol).filter_map do |name|
|
|
126
|
+
next if rails_method?(name)
|
|
127
|
+
|
|
128
|
+
Validation.new(kind: :validate, attributes: [name.to_s], options: {})
|
|
129
|
+
end
|
|
130
|
+
rescue StandardError
|
|
131
|
+
[]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def rails_method?(name)
|
|
135
|
+
path = @model.instance_method(name).source_location&.first.to_s
|
|
136
|
+
path.match?(%r{/(activerecord|activemodel|activesupport)-[^/]+/})
|
|
137
|
+
rescue NameError
|
|
138
|
+
false
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def stored_attributes
|
|
142
|
+
@model.stored_attributes.to_h { |store, keys| [store.to_s, keys.map(&:to_s)] }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def attachments
|
|
146
|
+
return [] unless @model.respond_to?(:reflect_on_all_attachments)
|
|
147
|
+
|
|
148
|
+
@model.reflect_on_all_attachments.map { |a| "#{a.name} (#{a.macro})" }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def encrypted_attributes
|
|
152
|
+
@model.respond_to?(:encrypted_attributes) ? @model.encrypted_attributes.to_a.map(&:to_s) : []
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Attributes declared with the attribute API that have no database column
|
|
156
|
+
def virtual_attributes
|
|
157
|
+
@model.attribute_types.keys - @model.column_names
|
|
158
|
+
rescue StandardError
|
|
159
|
+
[]
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
data/lib/pgdd/railtie.rb
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# <%= app.name %> Database Documentation
|
|
2
|
+
|
|
3
|
+
**Start with [<%= index %>](<%= index %>).** It is the primary file in this directory: it describes each database the application uses, lists every table with a link to that table's own page, and ends with the enum types and a data element index of every column.
|
|
4
|
+
|
|
5
|
+
## As built, not as designed
|
|
6
|
+
|
|
7
|
+
This documentation is **as built**. It is generated automatically, not written by hand, from two sources:
|
|
8
|
+
|
|
9
|
+
- the schema of the database in the <%= app.environment %> environment, as it exists after every migration has run: tables, columns, types, indexes, foreign keys, constraints, triggers and enum types;
|
|
10
|
+
- the ActiveRecord model definitions in the codebase: associations, enums, validations, STI hierarchies and delegated types.
|
|
11
|
+
|
|
12
|
+
It therefore records what the application actually has, not what any design document says it should have. Where the two disagree, this directory is the one that is right about the current state, and the design document is the one that needs a conversation. The generation date and the schema version are recorded at the top of [<%= index %>](<%= index %>), and a warning appears there if the database had pending migrations when the report was generated.
|
|
13
|
+
|
|
14
|
+
Do not edit these files by hand; regenerate them instead (see below). This README is generated too.
|
|
15
|
+
|
|
16
|
+
## What is in this directory
|
|
17
|
+
|
|
18
|
+
<%= files_table %>
|
|
19
|
+
<%- if multi -%>
|
|
20
|
+
|
|
21
|
+
There is more than one database, so page and diagram names are prefixed with the database name: `queue.jobs.md`.
|
|
22
|
+
<%- end -%>
|
|
23
|
+
<%- if erd_format -%>
|
|
24
|
+
|
|
25
|
+
## Reading the diagrams
|
|
26
|
+
|
|
27
|
+
Each table's page <%= image ? 'embeds' : 'has' %> a diagram of the table and the tables it is related to.
|
|
28
|
+
|
|
29
|
+
**The table the page is about is drawn in full, with every column. The related tables are not.** A related table shows only its primary key and the columns that take part in the relationship being drawn, such as the foreign key column that points at the table in focus. This keeps a diagram readable: a table with a dozen children would otherwise stack a dozen full column lists on top of each other, and the picture becomes several screens tall.
|
|
30
|
+
|
|
31
|
+
**The full schema of any table is one click away.** Every table name in a diagram is a link to that table's own markdown page, where all of its columns, indexes and constraints are listed. Column cells are links too.
|
|
32
|
+
|
|
33
|
+
`<%= schema_stem %>.<%= erd_format %>` draws every table the same compact way, keys only, so that the shape of the whole database fits on one page.
|
|
34
|
+
|
|
35
|
+
### Notation
|
|
36
|
+
|
|
37
|
+
- An edge runs from the foreign key column of the child table to the primary key of the parent. The crow's foot marks the many side; the bar and ring mark the one, optional parent.
|
|
38
|
+
- **Solid edges** are foreign keys that exist in the database.
|
|
39
|
+
- **Dashed edges** are polymorphic associations declared in the models (`has_one`/`has_many ..., as:`), which Rails cannot back with a database foreign key. The related table shows the `_id` and `_type` column pair for these. A single bar instead of a crow's foot on the child end means the inverse is `has_one`.
|
|
40
|
+
- Hovering over a table, a column or an edge shows its comment, where one exists, and for a dashed edge the model the type column must name.
|
|
41
|
+
|
|
42
|
+
### Links inside the diagrams
|
|
43
|
+
|
|
44
|
+
The table names and columns in each diagram are hyperlinks to the table pages, but **they do not work on GitHub**. GitHub shows an SVG as a static image, both when it is embedded in a page and when the file itself is viewed, and clicking an image on GitHub opens the image file. The same is true of most Markdown renderers. The links only work when the diagram is opened on its own in a browser from a checkout of the repository, where the pages sit next to it.
|
|
45
|
+
|
|
46
|
+
For navigation that works everywhere, every page's Relations section starts with a list of the tables it references and the tables that reference it, each one a link, including the polymorphic associations. The Foreign Keys and Referenced By tables further down link to the related tables as well.
|
|
47
|
+
<%- end -%>
|
|
48
|
+
|
|
49
|
+
## Migrations
|
|
50
|
+
|
|
51
|
+
Each table page lists every migration that changed the table, oldest first, with a link to the file and a summary of what it did to this table (`create_table`, `add_column availability`, `ALTER TABLE (SQL)`, and so on). The list is built by reading the migration files, not by replaying them, so it covers the schema DSL calls, the SQL inside `execute`, and backfills that go through a model with an explicit table name. A migration that touches a table only through application models it does not name will not appear. Only the forward direction is read; `down` methods are ignored.
|
|
52
|
+
|
|
53
|
+
## Regenerating
|
|
54
|
+
|
|
55
|
+
The files are produced by the `pgdd` gem, which boots the application, reads the database and the models, and writes the pages<%= ' and diagrams' if erd_format %> into this directory.<%= ' The diagrams are rendered with Graphviz, so `dot` has to be on the `PATH`.' if erd_format && erd_format != 'dot' %> This copy was generated on <%= date %> with:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
<%= command %>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Run it after any migration that changes the schema and commit the result together with the migration.
|