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,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
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgdd
4
+ # Adds the pgdd:generate rake task to applications that bundle the gem
5
+ class Railtie < Rails::Railtie
6
+ rake_tasks do
7
+ load File.expand_path('../tasks/pgdd.rake', __dir__)
8
+ end
9
+ end
10
+ end
@@ -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.