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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 11a779fb4ddeec8b6cf9f7858f821104466a9a769e58e137245636b9d62d459f
4
- data.tar.gz: 2addea232a4a29643a66dd29396bf3d56614f2ff7923d516aba48e7b6b3f2379
3
+ metadata.gz: a87fe0063a6a76448199f86495ad0dad9dbc1680681fc001e98a93736f54ac20
4
+ data.tar.gz: 65ad0aa21cba0c583e31f1de2246ca63889e59a5a6ec178e885bec58f0715dc2
5
5
  SHA512:
6
- metadata.gz: 7a04edef5bb90afad0d50ebb18c4590dc73e6ee8902f73d9a592291855d8f78c591c2174799d31d904d0fc1347df25baeedbe0383e57af156c9e59c16a2661c4
7
- data.tar.gz: 8bf3fee198f5c60ed40b85b6d89ce6eecd3723171a51874338a8702a3b7436e1894db2a92dc8f62a08be534d782e35040d2fe74a36042836e13a5d9d597743bb
6
+ metadata.gz: 24cd3aff9a33a461070e8dda327c3fc3c54467ee832b9f131cc20fb5646465729fd9b173f684e222f8c295d2d61fb3ac1e573e7c0b3574e9635a6655b33f41bc
7
+ data.tar.gz: 22b6d1dc87290ed155f142b6ce5a051e0df00c546885460c5e1afb1e5ee9bdde0094e030f77b9ef88b7c45b488821abab141bf081fcd5dc27121597d06a6869e
data/.gitignore CHANGED
@@ -9,3 +9,4 @@
9
9
 
10
10
  # rspec failure tracking
11
11
  .rspec_status
12
+ gemfiles/*.lock
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
@@ -13,4 +13,5 @@ gem "rspec", "~> 3.0"
13
13
  group :development, :test do
14
14
  gem "simplecov", require: false
15
15
  gem "pry", require: false
16
+ gem "sqlite3", ">= 2.1"
16
17
  end
data/Gemfile.lock CHANGED
@@ -1,32 +1,13 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- annotato (0.2.5)
5
- rails (>= 6.0)
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
- mini_mime (1.1.5)
115
- minitest (5.25.5)
116
- net-imap (0.5.8)
117
- date
118
- net-protocol
119
- net-pop (0.1.2)
120
- net-protocol
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
- * Enums with values
17
- * Indexes (only if present)
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
- * Skips unchanged annotations
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 not null, primary key
31
- # email :string not null, unique
32
- # role :string default("user"), not null
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: { user, admin }
36
-
37
- class User < ApplicationRecord
38
- end
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 now pass one or multiple model names to the task.
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["User,Admin::Account"]
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 Task
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
- * Replace existing Annotato and legacy annotate blocks
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 "rails", ">= 6.0"
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 = ActiveRecord::Base.connection
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
- opts << "unique" if unique_indexes.any? { |idx| idx.columns == [name] }
48
- opts << "enum" if pg_enum_types.include?(type.delete_suffix("[]"))
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(#{col.default[0]}"]
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}#{col.default[-1]})"
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
- [left, "# #{opts_str}"]
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
- # Returns nil for non-JSON/empty values, or an Array of un-indented inner lines
77
- # for multiline formatting: e.g. ["\"A\",", "\"B\""] for a JSON array.
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
- if parsed.is_a?(Array)
87
- return nil if parsed.empty?
88
-
89
- # JSON array of strings
90
- parsed.map.with_index do |e, i|
91
- comma = i == parsed.size - 1 ? "" : ","
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
- # Collect columns backed by a native PG enum type.
11
- enum_columns = columns.select { |col| pg_enum_type?(conn, col.sql_type) }
12
- return [] if enum_columns.empty?
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
- private_class_method def self.pg_enum_type?(conn, sql_type)
27
- # Strip array suffix (e.g. "my_enum[]") before checking.
28
- type_name = sql_type.delete_suffix("[]")
29
- result = conn.exec_query(
30
- "SELECT 1 FROM pg_type WHERE typname = $1 AND typtype = 'e' LIMIT 1",
31
- "SQL",
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
- private_class_method def self.pg_enum_labels(conn, sql_type)
38
- type_name = sql_type.delete_suffix("[]")
39
- conn.exec_query(
40
- "SELECT e.enumlabel FROM pg_enum e " \
41
- "JOIN pg_type t ON e.enumtypid = t.oid " \
42
- "WHERE t.typname = $1 AND t.typtype = 'e' " \
43
- "ORDER BY e.enumsortorder",
44
- "SQL",
45
- [type_name]
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
- def initialize(output: $stdout)
10
- @output = output
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
- file = model.instance_method(:initialize).source_location&.first
65
+ return if model.name.nil?
36
66
 
37
- if file.nil? || file.include?("/gems/")
38
- Object.const_source_location(model.name)&.first
39
- else
40
- file
41
- end
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
- # Extract the old Annotato annotation block if it exists
57
- old_annotation = content[/^# == Annotato Schema Info.*?(?=^class|\z)/m]
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
- # Skip writing if the annotation hasn't changed
60
- if old_annotation && old_annotation.strip == annotation.strip
61
- @output.puts "ℹ️ Skipped #{model.name} — annotation unchanged"
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!(/^# == Annotato Schema Info.*?(?=^class|\z)/m, "")
67
- content.gsub!(/^# == Schema Information.*?(?=^class|\z)/m, "")
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
- File.write(file, content)
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
@@ -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 db:migrate finishes, run annotato:models automatically
12
- Rake::Task["db:migrate"].enhance do
13
- Rake::Task["annotato:models"].reenable
14
- Rake::Task["annotato:models"].invoke
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.adapter_name.downcase
7
- when "postgresql"
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 "mysql2", "mysql", "trilogy"
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", [table_name]
18
+ "WHERE EVENT_OBJECT_SCHEMA = DATABASE() AND EVENT_OBJECT_TABLE = #{conn.quote(table_name)}",
19
+ "SQL"
17
20
  )
18
- when "sqlite3"
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]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Annotato
4
- VERSION = "0.2.5"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -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
- window = remaining[0, available]
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)
@@ -3,18 +3,24 @@
3
3
  require "annotato/model_annotator"
4
4
 
5
5
  namespace :annotato do
6
- desc "Annotate models with schema info. Pass MODEL=name or comma-separated list"
7
- task :models, [:MODEL] => :environment do |t, args|
8
- # Parse MODEL argument into an array of model names, if given
9
- model_list = args[:MODEL]&.split(",")&.map(&:strip)
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 model_list && model_list.any?
14
- # Constantize each name and annotate only those classes
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
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
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.2.5
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-07-24 00:00:00.000000000 Z
10
+ date: 2026-10-14 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
- name: rails
13
+ name: activerecord
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '6.0'
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.0'
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