annotato 0.2.5 → 0.3.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 +4 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +27 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +14 -76
- data/README.md +53 -17
- data/annotato.gemspec +3 -2
- data/lib/annotato/adapter.rb +19 -0
- data/lib/annotato/annotation_builder.rb +17 -3
- data/lib/annotato/auto_annotate.rb +21 -0
- data/lib/annotato/check_constraint_formatter.rb +5 -0
- data/lib/annotato/column_formatter.rb +52 -40
- data/lib/annotato/enum_formatter.rb +21 -26
- data/lib/annotato/index_formatter.rb +25 -2
- data/lib/annotato/model_annotator.rb +65 -18
- data/lib/annotato/railtie.rb +9 -4
- data/lib/annotato/trigger_formatter.rb +9 -6
- data/lib/annotato/version.rb +1 -1
- data/lib/annotato/wrap_helper.rb +24 -24
- data/lib/annotato.rb +9 -0
- data/lib/tasks/annotato.rake +28 -9
- data/sig/annotato.rbs +24 -1
- metadata +21 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a87fe0063a6a76448199f86495ad0dad9dbc1680681fc001e98a93736f54ac20
|
|
4
|
+
data.tar.gz: 65ad0aa21cba0c583e31f1de2246ca63889e59a5a6ec178e885bec58f0715dc2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 24cd3aff9a33a461070e8dda327c3fc3c54467ee832b9f131cc20fb5646465729fd9b173f684e222f8c295d2d61fb3ac1e573e7c0b3574e9635a6655b33f41bc
|
|
7
|
+
data.tar.gz: 22b6d1dc87290ed155f142b6ce5a051e0df00c546885460c5e1afb1e5ee9bdde0094e030f77b9ef88b7c45b488821abab141bf081fcd5dc27121597d06a6869e
|
data/.gitignore
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.3.0] - 2026-10-14
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Replacing a legacy `annotate` block above a `module`-wrapped class no longer deletes the rest of the file
|
|
8
|
+
- Never write into model files outside the application (engines, gems, `vendor/`); skip anonymous classes and HABTM join models
|
|
9
|
+
- MySQL, Trilogy and SQLite no longer crash on PostgreSQL-only enum queries; SQLite triggers are now listed
|
|
10
|
+
- Check constraints are skipped on adapters/Rails versions that don't support them
|
|
11
|
+
- Models on a secondary database are annotated from their own connection
|
|
12
|
+
- `rake "annotato:models[User,Post]"` annotates every listed model; unknown names are skipped with a warning
|
|
13
|
+
- Columns whose only unique index is partial are marked `unique (partial)` instead of `unique`
|
|
14
|
+
- JSON defaults are rendered as valid JSON (numbers, nested values, `null`, escaped quotes)
|
|
15
|
+
- Tables without columns no longer crash; SQL is never wrapped inside a quoted literal
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- `rake annotato:check` for CI: fails when annotations are missing or outdated
|
|
20
|
+
- Database function defaults (`default(now())`), generated columns (`generated(...)`), table comments, index method / order / `INCLUDE` / `NULLS NOT DISTINCT`
|
|
21
|
+
- `MODEL` environment variable for `annotato:models`
|
|
22
|
+
- `ANNOTATO_SKIP=1` to disable automatic annotation after migrations
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- Automatic annotation after migrations now runs only in development, and also after `db:rollback` and per-database migrate tasks
|
|
27
|
+
- Model files are written atomically
|
|
28
|
+
- Depends on `activerecord` and `railties` (>= 6.1) instead of the whole `rails` gem
|
|
29
|
+
|
|
3
30
|
## [0.1.0] - 2025-05-16
|
|
4
31
|
|
|
5
32
|
- Initial release
|
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
|
@@ -1,32 +1,13 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
annotato (0.
|
|
5
|
-
|
|
4
|
+
annotato (0.3.0)
|
|
5
|
+
activerecord (>= 6.1)
|
|
6
|
+
railties (>= 6.1)
|
|
6
7
|
|
|
7
8
|
GEM
|
|
8
9
|
remote: https://rubygems.org/
|
|
9
10
|
specs:
|
|
10
|
-
actioncable (8.0.2)
|
|
11
|
-
actionpack (= 8.0.2)
|
|
12
|
-
activesupport (= 8.0.2)
|
|
13
|
-
nio4r (~> 2.0)
|
|
14
|
-
websocket-driver (>= 0.6.1)
|
|
15
|
-
zeitwerk (~> 2.6)
|
|
16
|
-
actionmailbox (8.0.2)
|
|
17
|
-
actionpack (= 8.0.2)
|
|
18
|
-
activejob (= 8.0.2)
|
|
19
|
-
activerecord (= 8.0.2)
|
|
20
|
-
activestorage (= 8.0.2)
|
|
21
|
-
activesupport (= 8.0.2)
|
|
22
|
-
mail (>= 2.8.0)
|
|
23
|
-
actionmailer (8.0.2)
|
|
24
|
-
actionpack (= 8.0.2)
|
|
25
|
-
actionview (= 8.0.2)
|
|
26
|
-
activejob (= 8.0.2)
|
|
27
|
-
activesupport (= 8.0.2)
|
|
28
|
-
mail (>= 2.8.0)
|
|
29
|
-
rails-dom-testing (~> 2.2)
|
|
30
11
|
actionpack (8.0.2)
|
|
31
12
|
actionview (= 8.0.2)
|
|
32
13
|
activesupport (= 8.0.2)
|
|
@@ -37,34 +18,18 @@ GEM
|
|
|
37
18
|
rails-dom-testing (~> 2.2)
|
|
38
19
|
rails-html-sanitizer (~> 1.6)
|
|
39
20
|
useragent (~> 0.16)
|
|
40
|
-
actiontext (8.0.2)
|
|
41
|
-
actionpack (= 8.0.2)
|
|
42
|
-
activerecord (= 8.0.2)
|
|
43
|
-
activestorage (= 8.0.2)
|
|
44
|
-
activesupport (= 8.0.2)
|
|
45
|
-
globalid (>= 0.6.0)
|
|
46
|
-
nokogiri (>= 1.8.5)
|
|
47
21
|
actionview (8.0.2)
|
|
48
22
|
activesupport (= 8.0.2)
|
|
49
23
|
builder (~> 3.1)
|
|
50
24
|
erubi (~> 1.11)
|
|
51
25
|
rails-dom-testing (~> 2.2)
|
|
52
26
|
rails-html-sanitizer (~> 1.6)
|
|
53
|
-
activejob (8.0.2)
|
|
54
|
-
activesupport (= 8.0.2)
|
|
55
|
-
globalid (>= 0.3.6)
|
|
56
27
|
activemodel (8.0.2)
|
|
57
28
|
activesupport (= 8.0.2)
|
|
58
29
|
activerecord (8.0.2)
|
|
59
30
|
activemodel (= 8.0.2)
|
|
60
31
|
activesupport (= 8.0.2)
|
|
61
32
|
timeout (>= 0.4.0)
|
|
62
|
-
activestorage (8.0.2)
|
|
63
|
-
actionpack (= 8.0.2)
|
|
64
|
-
activejob (= 8.0.2)
|
|
65
|
-
activerecord (= 8.0.2)
|
|
66
|
-
activesupport (= 8.0.2)
|
|
67
|
-
marcel (~> 1.0)
|
|
68
33
|
activesupport (8.0.2)
|
|
69
34
|
base64
|
|
70
35
|
benchmark (>= 0.3)
|
|
@@ -91,8 +56,6 @@ GEM
|
|
|
91
56
|
docile (1.4.1)
|
|
92
57
|
drb (2.2.1)
|
|
93
58
|
erubi (1.13.1)
|
|
94
|
-
globalid (1.2.1)
|
|
95
|
-
activesupport (>= 6.1)
|
|
96
59
|
i18n (1.14.7)
|
|
97
60
|
concurrent-ruby (~> 1.0)
|
|
98
61
|
io-console (0.8.0)
|
|
@@ -104,25 +67,14 @@ GEM
|
|
|
104
67
|
loofah (2.24.0)
|
|
105
68
|
crass (~> 1.0.2)
|
|
106
69
|
nokogiri (>= 1.12.0)
|
|
107
|
-
mail (2.8.1)
|
|
108
|
-
mini_mime (>= 0.1.1)
|
|
109
|
-
net-imap
|
|
110
|
-
net-pop
|
|
111
|
-
net-smtp
|
|
112
|
-
marcel (1.0.4)
|
|
113
70
|
method_source (1.1.0)
|
|
114
|
-
|
|
115
|
-
minitest (
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
net-protocol (0.2.2)
|
|
122
|
-
timeout
|
|
123
|
-
net-smtp (0.5.1)
|
|
124
|
-
net-protocol
|
|
125
|
-
nio4r (2.7.4)
|
|
71
|
+
mini_portile2 (2.8.9)
|
|
72
|
+
minitest (6.0.6)
|
|
73
|
+
drb (~> 2.0)
|
|
74
|
+
prism (~> 1.5)
|
|
75
|
+
nokogiri (1.18.8)
|
|
76
|
+
mini_portile2 (~> 2.8.2)
|
|
77
|
+
racc (~> 1.4)
|
|
126
78
|
nokogiri (1.18.8-arm64-darwin)
|
|
127
79
|
racc (~> 1.4)
|
|
128
80
|
nokogiri (1.18.8-x86_64-linux-gnu)
|
|
@@ -130,6 +82,7 @@ GEM
|
|
|
130
82
|
pp (0.6.2)
|
|
131
83
|
prettyprint
|
|
132
84
|
prettyprint (0.2.0)
|
|
85
|
+
prism (1.9.0)
|
|
133
86
|
pry (0.15.2)
|
|
134
87
|
coderay (~> 1.1)
|
|
135
88
|
method_source (~> 1.0)
|
|
@@ -145,20 +98,6 @@ GEM
|
|
|
145
98
|
rack (>= 1.3)
|
|
146
99
|
rackup (2.2.1)
|
|
147
100
|
rack (>= 3)
|
|
148
|
-
rails (8.0.2)
|
|
149
|
-
actioncable (= 8.0.2)
|
|
150
|
-
actionmailbox (= 8.0.2)
|
|
151
|
-
actionmailer (= 8.0.2)
|
|
152
|
-
actionpack (= 8.0.2)
|
|
153
|
-
actiontext (= 8.0.2)
|
|
154
|
-
actionview (= 8.0.2)
|
|
155
|
-
activejob (= 8.0.2)
|
|
156
|
-
activemodel (= 8.0.2)
|
|
157
|
-
activerecord (= 8.0.2)
|
|
158
|
-
activestorage (= 8.0.2)
|
|
159
|
-
activesupport (= 8.0.2)
|
|
160
|
-
bundler (>= 1.15.0)
|
|
161
|
-
railties (= 8.0.2)
|
|
162
101
|
rails-dom-testing (2.2.0)
|
|
163
102
|
activesupport (>= 5.0.0)
|
|
164
103
|
minitest
|
|
@@ -199,6 +138,8 @@ GEM
|
|
|
199
138
|
simplecov_json_formatter (~> 0.1)
|
|
200
139
|
simplecov-html (0.13.1)
|
|
201
140
|
simplecov_json_formatter (0.1.4)
|
|
141
|
+
sqlite3 (2.9.6-arm64-darwin)
|
|
142
|
+
sqlite3 (2.9.6-x86_64-linux-gnu)
|
|
202
143
|
stringio (3.1.7)
|
|
203
144
|
thor (1.3.2)
|
|
204
145
|
timeout (0.4.3)
|
|
@@ -206,10 +147,6 @@ GEM
|
|
|
206
147
|
concurrent-ruby (~> 1.0)
|
|
207
148
|
uri (1.0.3)
|
|
208
149
|
useragent (0.16.11)
|
|
209
|
-
websocket-driver (0.7.7)
|
|
210
|
-
base64
|
|
211
|
-
websocket-extensions (>= 0.1.0)
|
|
212
|
-
websocket-extensions (0.1.5)
|
|
213
150
|
zeitwerk (2.7.2)
|
|
214
151
|
|
|
215
152
|
PLATFORMS
|
|
@@ -224,6 +161,7 @@ DEPENDENCIES
|
|
|
224
161
|
rake (~> 13.0)
|
|
225
162
|
rspec (~> 3.0)
|
|
226
163
|
simplecov
|
|
164
|
+
sqlite3 (>= 2.1)
|
|
227
165
|
|
|
228
166
|
BUNDLED WITH
|
|
229
167
|
2.6.9
|
data/README.md
CHANGED
|
@@ -12,30 +12,42 @@
|
|
|
12
12
|
* Annotates Rails models with:
|
|
13
13
|
|
|
14
14
|
* Columns and types
|
|
15
|
-
* Default values and constraints
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* Default values (including database functions like `now()`) and constraints
|
|
16
|
+
* Table and column comments
|
|
17
|
+
* PostgreSQL native enums with their values
|
|
18
|
+
* Indexes with method, order, `INCLUDE` and `WHERE` clauses (only if present)
|
|
18
19
|
* Triggers (only if present)
|
|
19
20
|
* Check Constraints (only if present)
|
|
20
|
-
*
|
|
21
|
+
* Works with PostgreSQL, MySQL/Trilogy and SQLite (enums are PostgreSQL-only)
|
|
22
|
+
* Supports multiple databases (each model uses its own connection)
|
|
23
|
+
* Skips unchanged annotations and replaces legacy `annotate` gem blocks
|
|
24
|
+
* Only writes files inside your application (never engine or gem models)
|
|
21
25
|
* Smart formatting with aligned columns
|
|
22
26
|
|
|
23
|
-
Example:
|
|
27
|
+
Example (the annotation is appended to the end of the model file):
|
|
24
28
|
|
|
25
29
|
```ruby
|
|
30
|
+
class User < ApplicationRecord
|
|
31
|
+
end
|
|
32
|
+
|
|
26
33
|
# == Annotato Schema Info
|
|
27
34
|
# Table: users
|
|
35
|
+
# Comment: People who can log in
|
|
28
36
|
#
|
|
29
37
|
# Columns:
|
|
30
|
-
# id :bigint
|
|
31
|
-
# email :
|
|
32
|
-
# role :
|
|
38
|
+
# id :bigint not null, primary key
|
|
39
|
+
# email :character varying not null, unique
|
|
40
|
+
# role :user_role default("member"), not null, enum
|
|
41
|
+
# created_at :timestamp(6) without time zone default(now()), not null
|
|
33
42
|
#
|
|
34
43
|
# Enums:
|
|
35
|
-
# role:
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
44
|
+
# role (user_role): [
|
|
45
|
+
# member,
|
|
46
|
+
# admin
|
|
47
|
+
# ]
|
|
48
|
+
#
|
|
49
|
+
# Indexes:
|
|
50
|
+
# index_users_on_email (email) unique
|
|
39
51
|
```
|
|
40
52
|
|
|
41
53
|
---
|
|
@@ -68,27 +80,51 @@ bundle exec rake annotato:models
|
|
|
68
80
|
|
|
69
81
|
### Annotate Specific Models
|
|
70
82
|
|
|
71
|
-
You can
|
|
83
|
+
You can pass one or multiple model names to the task, as task arguments or via `MODEL`.
|
|
72
84
|
|
|
73
85
|
```bash
|
|
74
86
|
# Annotate only the User model
|
|
75
87
|
rake annotato:models[User]
|
|
76
88
|
|
|
77
89
|
# Annotate multiple models (User and Admin::Account)
|
|
78
|
-
rake annotato:models[
|
|
90
|
+
rake "annotato:models[User,Admin::Account]"
|
|
91
|
+
MODEL=User,Admin::Account rake annotato:models
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Unknown names are reported and skipped.
|
|
95
|
+
|
|
96
|
+
### Check Annotations in CI
|
|
97
|
+
|
|
98
|
+
`annotato:check` writes nothing and exits with a non-zero status if any annotation is missing or outdated:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
bundle exec rake annotato:check
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Automatic Annotation After Migrations
|
|
105
|
+
|
|
106
|
+
In the `development` environment, models are re-annotated automatically after
|
|
107
|
+
`db:migrate`, `db:rollback` and their variants (`db:migrate:primary`, `db:migrate:up`, ...).
|
|
108
|
+
Other environments (production, CI) never rewrite model files.
|
|
109
|
+
|
|
110
|
+
To turn it off locally:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
ANNOTATO_SKIP=1 rake db:migrate
|
|
79
114
|
```
|
|
80
115
|
|
|
81
116
|
---
|
|
82
117
|
|
|
83
|
-
## Rake
|
|
118
|
+
## Rake Tasks
|
|
84
119
|
|
|
85
120
|
```bash
|
|
86
121
|
rake annotato:models[MODEL]
|
|
122
|
+
rake annotato:check[MODEL]
|
|
87
123
|
```
|
|
88
124
|
|
|
89
125
|
* Automatically loads all models via `Rails.application.eager_load!`
|
|
90
|
-
* Modifies model files in place
|
|
91
|
-
*
|
|
126
|
+
* Modifies model files in place (atomically, via a temp file)
|
|
127
|
+
* Replaces existing Annotato and legacy annotate blocks
|
|
92
128
|
* **Pass `MODEL` argument (single or comma-separated) to target specific models**
|
|
93
129
|
|
|
94
130
|
---
|
data/annotato.gemspec
CHANGED
|
@@ -22,7 +22,7 @@ Gem::Specification.new do |spec|
|
|
|
22
22
|
|
|
23
23
|
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
24
24
|
`git ls-files -z`.split("\x0").reject do |f|
|
|
25
|
-
f.match(%r{^(test|spec|features)/}) || f.start_with?(".github")
|
|
25
|
+
f.match(%r{^(test|spec|features|gemfiles)/}) || f.start_with?(".github")
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
28
|
|
|
@@ -30,5 +30,6 @@ Gem::Specification.new do |spec|
|
|
|
30
30
|
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
31
31
|
spec.require_paths = ["lib"]
|
|
32
32
|
|
|
33
|
-
spec.add_dependency "
|
|
33
|
+
spec.add_dependency "activerecord", ">= 6.1"
|
|
34
|
+
spec.add_dependency "railties", ">= 6.1"
|
|
34
35
|
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Annotato
|
|
4
|
+
# Normalizes ActiveRecord adapter names ("PostgreSQL", "PostGIS", "Mysql2",
|
|
5
|
+
# "Trilogy", "SQLite", ...) into a small set of symbols.
|
|
6
|
+
module Adapter
|
|
7
|
+
def self.kind(conn)
|
|
8
|
+
case conn.adapter_name.downcase
|
|
9
|
+
when /postg/ then :postgresql
|
|
10
|
+
when /mysql|trilogy/ then :mysql
|
|
11
|
+
when /sqlite/ then :sqlite
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.postgresql?(conn)
|
|
16
|
+
kind(conn) == :postgresql
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -11,18 +11,31 @@ require_relative "check_constraint_formatter"
|
|
|
11
11
|
module Annotato
|
|
12
12
|
class AnnotationBuilder
|
|
13
13
|
def self.build(model)
|
|
14
|
-
conn
|
|
14
|
+
with_connection(model) { |conn| build_with(model, conn) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Uses the model's own connection (multi-database apps), and with_connection on
|
|
18
|
+
# Rails 7.2+ where holding a permanent connection is deprecated.
|
|
19
|
+
def self.with_connection(model, &block)
|
|
20
|
+
model.respond_to?(:with_connection) ? model.with_connection(&block) : yield(model.connection)
|
|
21
|
+
end
|
|
22
|
+
private_class_method :with_connection
|
|
23
|
+
|
|
24
|
+
def self.build_with(model, conn)
|
|
15
25
|
table_name = model.table_name
|
|
26
|
+
pg_enums = EnumFormatter.pg_enums(conn)
|
|
16
27
|
|
|
17
28
|
lines = []
|
|
18
29
|
lines << "== Annotato Schema Info"
|
|
19
30
|
lines << "Table: #{table_name}"
|
|
31
|
+
comment = conn.respond_to?(:table_comment) && conn.table_comment(table_name)
|
|
32
|
+
lines << "Comment: #{comment}" if comment && !comment.empty?
|
|
20
33
|
lines << ""
|
|
21
34
|
|
|
22
35
|
lines << "Columns:"
|
|
23
|
-
lines += ColumnFormatter.format(model, conn)
|
|
36
|
+
lines += ColumnFormatter.format(model, conn, pg_enums: pg_enums)
|
|
24
37
|
|
|
25
|
-
enums = EnumFormatter.format(conn, conn.columns(table_name))
|
|
38
|
+
enums = EnumFormatter.format(conn, conn.columns(table_name), pg_enums: pg_enums)
|
|
26
39
|
unless enums.empty?
|
|
27
40
|
lines << ""
|
|
28
41
|
lines << "Enums:"
|
|
@@ -52,5 +65,6 @@ module Annotato
|
|
|
52
65
|
|
|
53
66
|
LineFormatter.format(lines)
|
|
54
67
|
end
|
|
68
|
+
private_class_method :build_with
|
|
55
69
|
end
|
|
56
70
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Annotato
|
|
4
|
+
# Decides when annotations are refreshed automatically after migrations.
|
|
5
|
+
module AutoAnnotate
|
|
6
|
+
# db:migrate, db:rollback and their per-database / up / down / redo variants.
|
|
7
|
+
TASK_PATTERN = /\Adb:(?:migrate|rollback)(?::(?!status\z)\w+)?\z/
|
|
8
|
+
|
|
9
|
+
# Only in development, so deploys and CI never rewrite model files.
|
|
10
|
+
# ANNOTATO_SKIP=1 turns it off locally too.
|
|
11
|
+
def self.enabled?(rails_env, env = ENV)
|
|
12
|
+
return false if %w[1 true yes].include?(env["ANNOTATO_SKIP"].to_s.downcase)
|
|
13
|
+
|
|
14
|
+
rails_env.to_s == "development"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.task_names(all_names)
|
|
18
|
+
all_names.grep(TASK_PATTERN)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "wrap_helper"
|
|
4
|
+
|
|
3
5
|
module Annotato
|
|
4
6
|
class CheckConstraintFormatter
|
|
5
7
|
extend WrapHelper
|
|
6
8
|
|
|
7
9
|
def self.format(conn, table_name)
|
|
10
|
+
# check_constraints exists since Rails 6.1 and not every adapter supports it.
|
|
11
|
+
return [] unless conn.respond_to?(:check_constraints) && conn.supports_check_constraints?
|
|
12
|
+
|
|
8
13
|
conn.check_constraints(table_name).sort_by(&:name).map do |chk|
|
|
9
14
|
expr_clause = ""
|
|
10
15
|
if chk.expression
|
|
@@ -2,20 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "wrap_helper"
|
|
5
|
+
require_relative "enum_formatter"
|
|
5
6
|
|
|
6
7
|
module Annotato
|
|
7
8
|
class ColumnFormatter
|
|
8
9
|
extend WrapHelper
|
|
9
10
|
|
|
10
11
|
# Main entry: returns an array of comment lines per column, flattened.
|
|
11
|
-
def self.format(model, connection)
|
|
12
|
+
def self.format(model, connection, pg_enums: EnumFormatter.pg_enums(connection))
|
|
12
13
|
table_name = model.table_name
|
|
13
14
|
primary_key = model.primary_key
|
|
14
15
|
unique_indexes = connection.indexes(table_name).select(&:unique)
|
|
15
16
|
columns = connection.columns(table_name)
|
|
16
|
-
|
|
17
|
-
# Collect native PG enum type names once up front (avoids N+1 queries).
|
|
18
|
-
pg_enum_types = pg_enum_type_names(connection)
|
|
17
|
+
return [] if columns.empty?
|
|
19
18
|
|
|
20
19
|
# Compute max widths for name and sql_type so that the table lines up.
|
|
21
20
|
name_width = columns.map(&:name).map(&:length).max
|
|
@@ -40,22 +39,27 @@ module Annotato
|
|
|
40
39
|
opts << "__MULTILINE__" # placeholder
|
|
41
40
|
elsif !col.default.nil?
|
|
42
41
|
opts << "default(#{col.default.inspect})"
|
|
42
|
+
elsif generated?(col)
|
|
43
|
+
opts << "generated(#{col.default_function})"
|
|
44
|
+
elsif show_default_function?(col)
|
|
45
|
+
opts << "default(#{col.default_function})"
|
|
43
46
|
end
|
|
44
47
|
opts << "not null" unless col.null
|
|
45
48
|
opts << "primary key" if name == primary_key
|
|
46
49
|
opts << "is an Array" if type.end_with?("[]")
|
|
47
|
-
|
|
48
|
-
opts <<
|
|
50
|
+
unique_flag = unique_option(unique_indexes, name)
|
|
51
|
+
opts << unique_flag if unique_flag
|
|
52
|
+
opts << "enum" if pg_enums.key?(type.delete_suffix("[]"))
|
|
49
53
|
opts << "comment: #{col.comment.inspect}" if col.respond_to?(:comment) && col.comment && !col.comment.empty?
|
|
50
54
|
|
|
51
55
|
# Emit either a multiline block or a single line.
|
|
52
56
|
if default_block
|
|
53
57
|
# 1) opening line
|
|
54
|
-
lines = ["#{left} default(#{
|
|
58
|
+
lines = ["#{left} default(#{default_block[:open]}"]
|
|
55
59
|
# 2) each interior line, prefixed by "# " + indent_str
|
|
56
|
-
lines += default_block.map { |l| "# #{indent_str}#{l}" }
|
|
60
|
+
lines += default_block[:lines].map { |l| "# #{indent_str}#{l}" }
|
|
57
61
|
# 3) closing line with trailing options
|
|
58
|
-
closing = "# #{closing_indent_str}#{
|
|
62
|
+
closing = "# #{closing_indent_str}#{default_block[:close]})"
|
|
59
63
|
trailing = opts.reject { |o| o == "__MULTILINE__" }
|
|
60
64
|
closing += ", #{trailing.join(', ')}" unless trailing.empty?
|
|
61
65
|
lines << closing
|
|
@@ -65,7 +69,10 @@ module Annotato
|
|
|
65
69
|
opts_str = opts.join(", ")
|
|
66
70
|
line = opts_str.empty? ? left : "#{left} #{opts_str}"
|
|
67
71
|
if line.length > WrapHelper::MAX_LINE && !opts_str.empty?
|
|
68
|
-
|
|
72
|
+
# Align the continuation under the type column so it reads as part of this column;
|
|
73
|
+
# fall back to a short indent if that still doesn't fit.
|
|
74
|
+
aligned = "##{' ' * (left.index(':') - 1)}#{opts_str}"
|
|
75
|
+
[left, aligned.length <= WrapHelper::MAX_LINE ? aligned : "# #{opts_str}"]
|
|
69
76
|
else
|
|
70
77
|
[line]
|
|
71
78
|
end
|
|
@@ -73,8 +80,34 @@ module Annotato
|
|
|
73
80
|
end
|
|
74
81
|
end
|
|
75
82
|
|
|
76
|
-
#
|
|
77
|
-
#
|
|
83
|
+
# "unique" for a full single-column unique index; "unique (partial)" when every such
|
|
84
|
+
# index has a WHERE clause (uniqueness holds only for some rows, see Indexes section).
|
|
85
|
+
def self.unique_option(unique_indexes, name)
|
|
86
|
+
indexes = unique_indexes.select { |idx| idx.columns == [name] }
|
|
87
|
+
return if indexes.empty?
|
|
88
|
+
|
|
89
|
+
indexes.any? { |idx| idx.where.to_s.empty? } ? "unique" : "unique (partial)"
|
|
90
|
+
end
|
|
91
|
+
private_class_method :unique_option
|
|
92
|
+
|
|
93
|
+
# Generated columns (GENERATED ALWAYS AS ... ): Rails stores the expression in default_function.
|
|
94
|
+
def self.generated?(col)
|
|
95
|
+
col.respond_to?(:virtual?) && col.virtual? && !col.default_function.nil?
|
|
96
|
+
end
|
|
97
|
+
private_class_method :generated?
|
|
98
|
+
|
|
99
|
+
# Function defaults like now() are shown; sequence defaults of serial columns
|
|
100
|
+
# (nextval('users_id_seq'::regclass)) are implied by the type and only add noise.
|
|
101
|
+
def self.show_default_function?(col)
|
|
102
|
+
return false unless col.respond_to?(:default_function) && col.default_function
|
|
103
|
+
|
|
104
|
+
!col.default_function.start_with?("nextval(")
|
|
105
|
+
end
|
|
106
|
+
private_class_method :show_default_function?
|
|
107
|
+
|
|
108
|
+
# Returns nil for non-JSON/empty values, or { open:, close:, lines: } for multiline
|
|
109
|
+
# formatting, where lines are the un-indented inner lines of the pretty-printed JSON:
|
|
110
|
+
# e.g. { open: "[", close: "]", lines: ["\"A\",", "\"B\""] } for a JSON array.
|
|
78
111
|
def self.build_default_block(value)
|
|
79
112
|
return nil if value.nil? || !value.is_a?(String)
|
|
80
113
|
s = value.strip
|
|
@@ -82,35 +115,14 @@ module Annotato
|
|
|
82
115
|
|
|
83
116
|
parsed = JSON.parse(s) rescue nil
|
|
84
117
|
return nil unless parsed.is_a?(Array) || parsed.is_a?(Hash)
|
|
118
|
+
return nil if parsed.empty?
|
|
85
119
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
%Q{"#{e}"#{comma}}
|
|
93
|
-
end
|
|
94
|
-
else
|
|
95
|
-
return nil if parsed.empty?
|
|
96
|
-
|
|
97
|
-
# JSON hash → key/value pairs
|
|
98
|
-
parsed.map.with_index do |(k, v), i|
|
|
99
|
-
comma = i == parsed.size - 1 ? "" : ","
|
|
100
|
-
%Q{"#{k}": #{v.inspect}#{comma}}
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
# Fetches all native PostgreSQL enum type names in one query.
|
|
106
|
-
# Returns a Set for O(1) membership checks.
|
|
107
|
-
def self.pg_enum_type_names(connection)
|
|
108
|
-
rows = connection.exec_query(
|
|
109
|
-
"SELECT typname FROM pg_type WHERE typtype = 'e'",
|
|
110
|
-
"SQL"
|
|
111
|
-
)
|
|
112
|
-
rows.map { |r| r["typname"] }.to_set
|
|
120
|
+
pretty = JSON.pretty_generate(parsed).lines.map(&:rstrip)
|
|
121
|
+
{
|
|
122
|
+
open: pretty.first,
|
|
123
|
+
close: pretty.last,
|
|
124
|
+
lines: pretty[1..-2].map { |l| l.delete_prefix(" ") }
|
|
125
|
+
}
|
|
113
126
|
end
|
|
114
|
-
private_class_method :pg_enum_type_names
|
|
115
127
|
end
|
|
116
128
|
end
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "adapter"
|
|
4
|
+
|
|
3
5
|
module Annotato
|
|
4
6
|
# Formats PostgreSQL native enum type definitions for columns whose sql_type
|
|
5
7
|
# refers to a custom DB enum (e.g. `access_link_category`). Queries pg_enum
|
|
6
8
|
# directly so the output reflects the actual database definition, not the
|
|
7
9
|
# ActiveRecord-level enum mapping already visible in the model source.
|
|
8
10
|
class EnumFormatter
|
|
9
|
-
def self.format(conn, columns)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
def self.format(conn, columns, pg_enums: pg_enums(conn))
|
|
12
|
+
columns.flat_map do |col|
|
|
13
|
+
labels = pg_enums[col.sql_type.delete_suffix("[]")]
|
|
14
|
+
next [] unless labels
|
|
13
15
|
|
|
14
|
-
enum_columns.flat_map do |col|
|
|
15
|
-
labels = pg_enum_labels(conn, col.sql_type)
|
|
16
16
|
lines = ["# #{col.name} (#{col.sql_type}): ["]
|
|
17
17
|
labels.each_with_index do |label, i|
|
|
18
18
|
comma = i == labels.size - 1 ? "" : ","
|
|
@@ -23,27 +23,22 @@ module Annotato
|
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
[type_name]
|
|
33
|
-
)
|
|
34
|
-
result.any?
|
|
35
|
-
end
|
|
26
|
+
# Returns { type_name => [labels in sort order] } for every native PG enum, in one query.
|
|
27
|
+
# Keys come from format_type(), the same function PostgreSQL uses for a column's sql_type,
|
|
28
|
+
# so types outside the search_path are schema-qualified exactly as in the column.
|
|
29
|
+
# Returns {} for non-PostgreSQL adapters.
|
|
30
|
+
def self.pg_enums(conn)
|
|
31
|
+
return {} unless Adapter.postgresql?(conn)
|
|
36
32
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
).map { |r| r["enumlabel"] }
|
|
33
|
+
rows = conn.exec_query(
|
|
34
|
+
"SELECT format_type(t.oid, NULL) AS type_name, e.enumlabel AS label " \
|
|
35
|
+
"FROM pg_type t JOIN pg_enum e ON e.enumtypid = t.oid " \
|
|
36
|
+
"ORDER BY t.oid, e.enumsortorder",
|
|
37
|
+
"SQL"
|
|
38
|
+
)
|
|
39
|
+
rows.each_with_object({}) do |r, acc|
|
|
40
|
+
(acc[r["type_name"]] ||= []) << r["label"]
|
|
41
|
+
end
|
|
47
42
|
end
|
|
48
43
|
end
|
|
49
44
|
end
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "wrap_helper"
|
|
4
|
+
|
|
3
5
|
module Annotato
|
|
4
6
|
class IndexFormatter
|
|
5
7
|
extend WrapHelper
|
|
6
8
|
|
|
7
9
|
def self.format(conn, table_name)
|
|
8
10
|
conn.indexes(table_name).sort_by(&:name).map do |idx|
|
|
9
|
-
cols_list = Array(idx.columns).join(',')
|
|
11
|
+
cols_list = Array(idx.columns).map { |col| with_order(idx, col) }.join(',')
|
|
10
12
|
unique_clause = idx.unique ? " unique" : ""
|
|
11
13
|
|
|
12
14
|
where_clause = ""
|
|
@@ -14,8 +16,29 @@ module Annotato
|
|
|
14
16
|
where_clause = "\n" + wrap_sql(idx.where, first_prefix: "# where (", cont_prefix: "# ")
|
|
15
17
|
end
|
|
16
18
|
|
|
17
|
-
"# #{idx.name} (#{cols_list})#{unique_clause}#{where_clause}"
|
|
19
|
+
"# #{idx.name} (#{cols_list})#{unique_clause}#{extras(idx)}#{where_clause}"
|
|
18
20
|
end
|
|
19
21
|
end
|
|
22
|
+
|
|
23
|
+
# Index method (other than the default btree), INCLUDE columns and NULLS NOT DISTINCT.
|
|
24
|
+
# Guarded with respond_to? because these attributes vary across Rails versions.
|
|
25
|
+
def self.extras(idx)
|
|
26
|
+
parts = []
|
|
27
|
+
using = idx.respond_to?(:using) && idx.using
|
|
28
|
+
parts << "using #{using}" if using && using.to_s != "btree"
|
|
29
|
+
include_cols = idx.respond_to?(:include) && idx.include
|
|
30
|
+
parts << "include (#{Array(include_cols).join(',')})" if include_cols && !Array(include_cols).empty?
|
|
31
|
+
parts << "nulls not distinct" if idx.respond_to?(:nulls_not_distinct) && idx.nulls_not_distinct
|
|
32
|
+
parts.empty? ? "" : " #{parts.join(' ')}"
|
|
33
|
+
end
|
|
34
|
+
private_class_method :extras
|
|
35
|
+
|
|
36
|
+
def self.with_order(idx, col)
|
|
37
|
+
return col unless idx.respond_to?(:orders)
|
|
38
|
+
|
|
39
|
+
order = idx.orders.is_a?(Hash) ? idx.orders[col.to_s] || idx.orders[col.to_sym] : idx.orders
|
|
40
|
+
order ? "#{col} #{order.to_s.upcase}" : col
|
|
41
|
+
end
|
|
42
|
+
private_class_method :with_order
|
|
20
43
|
end
|
|
21
44
|
end
|
|
@@ -6,8 +6,20 @@ require_relative "annotation_builder"
|
|
|
6
6
|
|
|
7
7
|
module Annotato
|
|
8
8
|
class ModelAnnotator
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
# A block is its header line plus every directly following comment line.
|
|
10
|
+
# Matching stops at the first non-comment line (blank lines right after the block are
|
|
11
|
+
# consumed too), so code after the block is never touched.
|
|
12
|
+
ANNOTATO_BLOCK = /^# == Annotato Schema Info\n(?:#.*(?:\n|\z))*\n*/
|
|
13
|
+
LEGACY_BLOCK = /^# == Schema Information\n(?:#.*(?:\n|\z))*\n*/
|
|
14
|
+
|
|
15
|
+
# Names of models whose annotation is missing or outdated (filled in check mode).
|
|
16
|
+
attr_reader :outdated
|
|
17
|
+
|
|
18
|
+
# check: true reports outdated annotations without writing any file.
|
|
19
|
+
def initialize(output: $stdout, check: false)
|
|
20
|
+
@output = output
|
|
21
|
+
@check = check
|
|
22
|
+
@outdated = []
|
|
11
23
|
end
|
|
12
24
|
|
|
13
25
|
def run(one_model = nil)
|
|
@@ -21,24 +33,45 @@ module Annotato
|
|
|
21
33
|
end
|
|
22
34
|
end
|
|
23
35
|
|
|
36
|
+
# Annotates models given by name (e.g. ["User", "Admin::Account"]).
|
|
37
|
+
# Unknown names are reported and skipped instead of aborting the whole run.
|
|
38
|
+
def run_named(names)
|
|
39
|
+
names.each do |name|
|
|
40
|
+
klass = name.safe_constantize
|
|
41
|
+
unless klass.is_a?(Class) && klass < ActiveRecord::Base
|
|
42
|
+
@output.puts "⚠️ Skipped #{name} — not an ActiveRecord model"
|
|
43
|
+
next
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
run(klass)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
24
50
|
private
|
|
25
51
|
|
|
26
52
|
def models
|
|
27
53
|
Rails.application.eager_load!
|
|
28
|
-
# All AR models, but skip STI subclasses (only keep base classes)
|
|
54
|
+
# All AR models, but skip STI subclasses (only keep base classes),
|
|
55
|
+
# anonymous classes and generated HABTM join models.
|
|
29
56
|
ActiveRecord::Base.descendants
|
|
30
57
|
.reject(&:abstract_class?)
|
|
31
58
|
.select { |m| m.base_class == m }
|
|
59
|
+
.reject { |m| m.name.nil? || m.name.demodulize.start_with?("HABTM_") }
|
|
32
60
|
end
|
|
33
61
|
|
|
62
|
+
# Only files inside the application are ever written; models defined
|
|
63
|
+
# in gems or engines (e.g. ActiveStorage::Blob) are skipped.
|
|
34
64
|
def model_file(model)
|
|
35
|
-
|
|
65
|
+
return if model.name.nil?
|
|
36
66
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
67
|
+
file = Object.const_source_location(model.name)&.first
|
|
68
|
+
file if file && app_file?(file)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def app_file?(file)
|
|
72
|
+
root = "#{File.expand_path(Rails.root.to_s)}/"
|
|
73
|
+
path = File.expand_path(file)
|
|
74
|
+
path.start_with?(root) && !path.start_with?("#{root}vendor/")
|
|
42
75
|
end
|
|
43
76
|
|
|
44
77
|
def write_annotation(model, annotation)
|
|
@@ -53,25 +86,39 @@ module Annotato
|
|
|
53
86
|
return
|
|
54
87
|
end
|
|
55
88
|
|
|
56
|
-
#
|
|
57
|
-
old_annotation = content[
|
|
89
|
+
# Skip writing if the annotation hasn't changed and there is no legacy block to clean up
|
|
90
|
+
old_annotation = content[ANNOTATO_BLOCK]
|
|
91
|
+
if old_annotation && old_annotation.strip == annotation.strip && !content.match?(LEGACY_BLOCK)
|
|
92
|
+
return
|
|
93
|
+
end
|
|
58
94
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
@output.puts "
|
|
95
|
+
if @check
|
|
96
|
+
@outdated << model.name
|
|
97
|
+
@output.puts "❌ Outdated annotation: #{model.name}"
|
|
62
98
|
return
|
|
63
99
|
end
|
|
64
100
|
|
|
65
101
|
# Remove old Annotato blocks and legacy annotate-gem blocks
|
|
66
|
-
content.gsub!(
|
|
67
|
-
content.gsub!(
|
|
102
|
+
content.gsub!(ANNOTATO_BLOCK, "")
|
|
103
|
+
content.gsub!(LEGACY_BLOCK, "")
|
|
68
104
|
content.rstrip!
|
|
69
105
|
|
|
70
106
|
# Append the new annotation at the end of the file
|
|
71
|
-
content += "\n\n#{annotation}\n"
|
|
72
|
-
|
|
107
|
+
content += "\n\n#{annotation.rstrip}\n"
|
|
108
|
+
write_file(file, content)
|
|
73
109
|
|
|
74
110
|
@output.puts "✍️ Annotated #{model.name}"
|
|
75
111
|
end
|
|
112
|
+
|
|
113
|
+
# Writes via a temp file + rename so an interrupted run never leaves a truncated model file.
|
|
114
|
+
def write_file(file, content)
|
|
115
|
+
target = File.realpath(file)
|
|
116
|
+
tmp = "#{target}.annotato-#{Process.pid}.tmp"
|
|
117
|
+
File.write(tmp, content)
|
|
118
|
+
File.chmod(File.stat(target).mode, tmp)
|
|
119
|
+
File.rename(tmp, target)
|
|
120
|
+
ensure
|
|
121
|
+
File.delete(tmp) if tmp && File.exist?(tmp)
|
|
122
|
+
end
|
|
76
123
|
end
|
|
77
124
|
end
|
data/lib/annotato/railtie.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "rails/railtie"
|
|
4
|
+
require_relative "auto_annotate"
|
|
4
5
|
|
|
5
6
|
module Annotato
|
|
6
7
|
class Railtie < ::Rails::Railtie
|
|
@@ -8,10 +9,14 @@ module Annotato
|
|
|
8
9
|
# Load all annotato rake tasks as before
|
|
9
10
|
Dir[File.expand_path("../tasks/**/*.rake", __dir__)].each { |f| load f }
|
|
10
11
|
|
|
11
|
-
# After
|
|
12
|
-
Rake::Task
|
|
13
|
-
Rake::Task[
|
|
14
|
-
|
|
12
|
+
# After migrating or rolling back, run annotato:models automatically (development only)
|
|
13
|
+
AutoAnnotate.task_names(Rake::Task.tasks.map(&:name)).each do |name|
|
|
14
|
+
Rake::Task[name].enhance do
|
|
15
|
+
next unless AutoAnnotate.enabled?(Rails.env)
|
|
16
|
+
|
|
17
|
+
Rake::Task["annotato:models"].reenable
|
|
18
|
+
Rake::Task["annotato:models"].invoke
|
|
19
|
+
end
|
|
15
20
|
end
|
|
16
21
|
end
|
|
17
22
|
end
|
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "adapter"
|
|
4
|
+
|
|
3
5
|
module Annotato
|
|
4
6
|
class TriggerFormatter
|
|
5
7
|
def self.format(conn, table_name)
|
|
6
|
-
rows = case conn
|
|
7
|
-
when
|
|
8
|
+
rows = case Adapter.kind(conn)
|
|
9
|
+
when :postgresql
|
|
8
10
|
conn.exec_query(
|
|
9
11
|
"SELECT tgname AS name FROM pg_trigger WHERE tgrelid = $1::regclass AND NOT tgisinternal",
|
|
10
12
|
"SQL", [table_name]
|
|
11
13
|
)
|
|
12
|
-
when
|
|
14
|
+
when :mysql
|
|
15
|
+
# Quoted instead of bound: the Trilogy adapter does not support bind params.
|
|
13
16
|
conn.exec_query(
|
|
14
17
|
"SELECT TRIGGER_NAME AS name FROM information_schema.TRIGGERS " \
|
|
15
|
-
"WHERE EVENT_OBJECT_SCHEMA = DATABASE() AND EVENT_OBJECT_TABLE =
|
|
16
|
-
"SQL"
|
|
18
|
+
"WHERE EVENT_OBJECT_SCHEMA = DATABASE() AND EVENT_OBJECT_TABLE = #{conn.quote(table_name)}",
|
|
19
|
+
"SQL"
|
|
17
20
|
)
|
|
18
|
-
when
|
|
21
|
+
when :sqlite
|
|
19
22
|
conn.exec_query(
|
|
20
23
|
"SELECT name FROM sqlite_master WHERE type = 'trigger' AND tbl_name = ?",
|
|
21
24
|
"SQL", [table_name]
|
data/lib/annotato/version.rb
CHANGED
data/lib/annotato/wrap_helper.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
module Annotato
|
|
4
4
|
module WrapHelper
|
|
5
5
|
MAX_LINE = 120
|
|
6
|
+
# Preferred break points, best first; the token stays at the end of the line.
|
|
7
|
+
BREAK_TOKENS = [" AND ", " OR ", ", ", ") ", " "].freeze
|
|
6
8
|
|
|
7
9
|
# Produces one or more lines, each already prefixed with "# "
|
|
8
10
|
# First line: "# where (..."
|
|
@@ -29,30 +31,8 @@ module Annotato
|
|
|
29
31
|
break
|
|
30
32
|
end
|
|
31
33
|
|
|
32
|
-
# Prefer breaking on logical operators within the window
|
|
33
|
-
|
|
34
|
-
cut =
|
|
35
|
-
window.rindex(" AND ") ||
|
|
36
|
-
window.rindex(" OR ") ||
|
|
37
|
-
window.rindex(", ") ||
|
|
38
|
-
window.rindex(") ") ||
|
|
39
|
-
window.rindex(" ") # last resort: whitespace
|
|
40
|
-
|
|
41
|
-
# If we found a breakpoint, include it in the line (so operators stay visible)
|
|
42
|
-
if cut
|
|
43
|
-
# If we’re cutting on AND/OR/comma, keep that token at the end of the line when possible
|
|
44
|
-
if window[cut, 5] == " AND " || window[cut, 4] == " OR "
|
|
45
|
-
cut += (window[cut, 5] == " AND " ? 5 : 4)
|
|
46
|
-
elsif window[cut, 2] == ", "
|
|
47
|
-
cut += 2
|
|
48
|
-
elsif window[cut, 2] == ") "
|
|
49
|
-
cut += 2
|
|
50
|
-
else
|
|
51
|
-
cut += 1
|
|
52
|
-
end
|
|
53
|
-
else
|
|
54
|
-
cut = available
|
|
55
|
-
end
|
|
34
|
+
# Prefer breaking on logical operators within the window; hard cut if none.
|
|
35
|
+
cut = find_break(remaining[0, available]) || available
|
|
56
36
|
|
|
57
37
|
lines << "#{current_prefix}#{remaining[0, cut].rstrip}"
|
|
58
38
|
remaining = remaining[cut..].to_s.lstrip
|
|
@@ -64,5 +44,25 @@ module Annotato
|
|
|
64
44
|
|
|
65
45
|
lines.join("\n")
|
|
66
46
|
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Position right after the best break token in window, or nil.
|
|
51
|
+
# Never breaks at position 0 (would emit an empty line) or inside a quoted SQL literal.
|
|
52
|
+
def find_break(window)
|
|
53
|
+
BREAK_TOKENS.each do |token|
|
|
54
|
+
pos = window.rindex(token)
|
|
55
|
+
while pos && pos.positive?
|
|
56
|
+
return pos + token.length unless inside_quotes?(window, pos)
|
|
57
|
+
|
|
58
|
+
pos = window.rindex(token, pos - 1)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def inside_quotes?(text, pos)
|
|
65
|
+
text[0, pos].count("'").odd?
|
|
66
|
+
end
|
|
67
67
|
end
|
|
68
68
|
end
|
data/lib/annotato.rb
CHANGED
|
@@ -1,2 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "annotato/version"
|
|
4
|
+
|
|
5
|
+
module Annotato
|
|
6
|
+
autoload :AnnotationBuilder, "annotato/annotation_builder"
|
|
7
|
+
autoload :AutoAnnotate, "annotato/auto_annotate"
|
|
8
|
+
autoload :ModelAnnotator, "annotato/model_annotator"
|
|
9
|
+
end
|
|
10
|
+
|
|
2
11
|
require "annotato/railtie" if defined?(Rails::Railtie)
|
data/lib/tasks/annotato.rake
CHANGED
|
@@ -3,18 +3,24 @@
|
|
|
3
3
|
require "annotato/model_annotator"
|
|
4
4
|
|
|
5
5
|
namespace :annotato do
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
# Model names come from the task args and/or ENV["MODEL"]. Rake splits task args
|
|
7
|
+
# on commas, so annotato:models[User,Admin::Account] arrives as MODEL + extras.
|
|
8
|
+
model_names = lambda do |args|
|
|
9
|
+
[args[:MODEL], *args.extras, ENV["MODEL"]]
|
|
10
|
+
.compact
|
|
11
|
+
.flat_map { |names| names.split(",") }
|
|
12
|
+
.map(&:strip)
|
|
13
|
+
.reject(&:empty?)
|
|
14
|
+
.uniq
|
|
15
|
+
end
|
|
10
16
|
|
|
17
|
+
desc "Annotate models with schema info. Pass MODEL=name or comma-separated list"
|
|
18
|
+
task :models, [:MODEL] => :environment do |_t, args|
|
|
19
|
+
names = model_names.call(args)
|
|
11
20
|
annotator = Annotato::ModelAnnotator.new
|
|
12
21
|
|
|
13
|
-
if
|
|
14
|
-
|
|
15
|
-
model_list.map(&:constantize).each do |klass|
|
|
16
|
-
annotator.run(klass)
|
|
17
|
-
end
|
|
22
|
+
if names.any?
|
|
23
|
+
annotator.run_named(names)
|
|
18
24
|
else
|
|
19
25
|
# No MODEL passed → annotate all models
|
|
20
26
|
annotator.run
|
|
@@ -22,4 +28,17 @@ namespace :annotato do
|
|
|
22
28
|
|
|
23
29
|
puts "✅ Annotato completed"
|
|
24
30
|
end
|
|
31
|
+
|
|
32
|
+
desc "Check that model annotations are up to date (no files are written); fails if any are outdated"
|
|
33
|
+
task :check, [:MODEL] => :environment do |_t, args|
|
|
34
|
+
names = model_names.call(args)
|
|
35
|
+
annotator = Annotato::ModelAnnotator.new(check: true)
|
|
36
|
+
names.any? ? annotator.run_named(names) : annotator.run
|
|
37
|
+
|
|
38
|
+
if annotator.outdated.any?
|
|
39
|
+
abort "❌ #{annotator.outdated.size} outdated annotation(s). Run `rake annotato:models` to update."
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
puts "✅ Annotations are up to date"
|
|
43
|
+
end
|
|
25
44
|
end
|
data/sig/annotato.rbs
CHANGED
|
@@ -1,4 +1,27 @@
|
|
|
1
1
|
module Annotato
|
|
2
2
|
VERSION: String
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
class ModelAnnotator
|
|
5
|
+
attr_reader outdated: Array[String]
|
|
6
|
+
|
|
7
|
+
def initialize: (?output: IO, ?check: bool) -> void
|
|
8
|
+
def run: (?untyped one_model) -> void
|
|
9
|
+
def run_named: (Array[String] names) -> void
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class AnnotationBuilder
|
|
13
|
+
def self.build: (untyped model) -> String
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module AutoAnnotate
|
|
17
|
+
TASK_PATTERN: Regexp
|
|
18
|
+
|
|
19
|
+
def self.enabled?: (_ToS rails_env, ?Hash[String, String?] env) -> bool
|
|
20
|
+
def self.task_names: (Array[String] all_names) -> Array[String]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
module Adapter
|
|
24
|
+
def self.kind: (untyped conn) -> (:postgresql | :mysql | :sqlite | nil)
|
|
25
|
+
def self.postgresql?: (untyped conn) -> bool
|
|
26
|
+
end
|
|
4
27
|
end
|
metadata
CHANGED
|
@@ -1,28 +1,42 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: annotato
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Serhii Bodnaruk
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-10-14 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
|
-
name:
|
|
13
|
+
name: activerecord
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '6.
|
|
18
|
+
version: '6.1'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '6.
|
|
25
|
+
version: '6.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: railties
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '6.1'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '6.1'
|
|
26
40
|
description: Annotato automatically adds or updates comments at the end of your Rails
|
|
27
41
|
models to show schema details like columns, indexes, and PostgreSQL triggers.
|
|
28
42
|
email:
|
|
@@ -44,7 +58,9 @@ files:
|
|
|
44
58
|
- bin/console
|
|
45
59
|
- bin/setup
|
|
46
60
|
- lib/annotato.rb
|
|
61
|
+
- lib/annotato/adapter.rb
|
|
47
62
|
- lib/annotato/annotation_builder.rb
|
|
63
|
+
- lib/annotato/auto_annotate.rb
|
|
48
64
|
- lib/annotato/check_constraint_formatter.rb
|
|
49
65
|
- lib/annotato/column_formatter.rb
|
|
50
66
|
- lib/annotato/enum_formatter.rb
|