annotaterb 4.23.0 → 4.24.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.
Files changed (25) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -3
  3. data/README.md +116 -0
  4. data/VERSION +1 -1
  5. data/lib/annotate_rb/model_annotator/annotation/annotation_builder.rb +8 -1
  6. data/lib/annotate_rb/model_annotator/exclusion_constraint_annotation/annotation.rb +40 -0
  7. data/lib/annotate_rb/model_annotator/exclusion_constraint_annotation/annotation_builder.rb +38 -0
  8. data/lib/annotate_rb/model_annotator/exclusion_constraint_annotation/exclusion_constraint_component.rb +27 -0
  9. data/lib/annotate_rb/model_annotator/exclusion_constraint_annotation.rb +11 -0
  10. data/lib/annotate_rb/model_annotator/file_parser/annotation_finder.rb +38 -2
  11. data/lib/annotate_rb/model_annotator/file_parser/yml_parser.rb +43 -4
  12. data/lib/annotate_rb/model_annotator/foreign_key_annotation/foreign_key_component_builder.rb +6 -0
  13. data/lib/annotate_rb/model_annotator/index_annotation/annotation_builder.rb +29 -0
  14. data/lib/annotate_rb/model_annotator/model_wrapper.rb +33 -1
  15. data/lib/annotate_rb/model_annotator/single_file_annotation_remover.rb +1 -1
  16. data/lib/annotate_rb/model_annotator/single_file_annotator.rb +1 -1
  17. data/lib/annotate_rb/model_annotator/unique_constraint_annotation/annotation.rb +40 -0
  18. data/lib/annotate_rb/model_annotator/unique_constraint_annotation/annotation_builder.rb +37 -0
  19. data/lib/annotate_rb/model_annotator/unique_constraint_annotation/unique_constraint_component.rb +27 -0
  20. data/lib/annotate_rb/model_annotator/unique_constraint_annotation.rb +11 -0
  21. data/lib/annotate_rb/model_annotator.rb +2 -0
  22. data/lib/annotate_rb/options.rb +6 -0
  23. data/lib/annotate_rb/parser.rb +16 -1
  24. data/lib/annotate_rb/runner.rb +0 -2
  25. metadata +9 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 249172f3c37299ac55df233909754c48456b629d11517ce03c84fae62232677d
4
- data.tar.gz: 9fa726c0abbb3bc7508257d24e7e0f0e4f9a79c94e255909b8bf7162a2f25d9f
3
+ metadata.gz: ff11e0177eddc61744806ec5749c7a5d234023e0e7d75d46bd5fc21e4d0998fc
4
+ data.tar.gz: f8c87d260bf6cafbeb120568ecda6131f6bd335ef2c3882630a45cc2e6631a05
5
5
  SHA512:
6
- metadata.gz: f695ccb014c329b7c897f1832c4bb053df9fbec718198d68466884585b17f17fbfb6805a053024a38133196f7d9934aaff56d89c83598160966b1fc4dcaaaa3c
7
- data.tar.gz: 73994c40f74a0c793cd974cf1369b59403e3b3a49ce6c643b661f9dfdffb7425dcfbb96c98ba2a23c3347861fa4cd759be380613d92627cb4046956af249dea3
6
+ metadata.gz: 6e2edbfc22db4f8b1bd150313e795993e3f5366ac4594084fec2f7ee9f1d2650d98cef0933bdc92a0ff0a4e52af4bb77b35672cbd0049fed9c8e6c256743e00e
7
+ data.tar.gz: ddf3161a49b9c041083ce42c9264823da16e23703a8f013b98098f24dc4f7d46642a659621f0d0612572865fec1099a91e7622139acc3a1c689e7df1d96e81bf
data/CHANGELOG.md CHANGED
@@ -1,9 +1,67 @@
1
1
  # Changelog
2
2
 
3
+ ## [v4.23.0](https://github.com/drwl/annotaterb/tree/v4.23.0) (2026-06-25)
4
+
5
+ [Full Changelog](https://github.com/drwl/annotaterb/compare/v4.22.0...v4.23.0)
6
+
7
+ **Implemented enhancements:**
8
+
9
+ - \[Request\] Support for multiple serializers for one model [\#329](https://github.com/drwl/annotaterb/issues/329)
10
+ - Support COMMENT on an INDEX [\#258](https://github.com/drwl/annotaterb/issues/258)
11
+
12
+ **Fixed bugs:**
13
+
14
+ - Re-running annotaterb strips indentation from nested \(indented\) schema annotations [\#332](https://github.com/drwl/annotaterb/issues/332)
15
+ - Setting all sort options to `false` sometimes doesn't respect migration/DB order [\#237](https://github.com/drwl/annotaterb/issues/237)
16
+
17
+ **Closed issues:**
18
+
19
+ - mistake post please delete this [\#314](https://github.com/drwl/annotaterb/issues/314)
20
+ - Removal of final empty comment line after foreign keys results in duplicate `fk_rails_...` line [\#297](https://github.com/drwl/annotaterb/issues/297)
21
+ - Default settings does not indent correctly inside a module [\#271](https://github.com/drwl/annotaterb/issues/271)
22
+ - Top does not do what I think it should? And annotator fails to indent. [\#202](https://github.com/drwl/annotaterb/issues/202)
23
+ - annotaterb is moving my class doc [\#201](https://github.com/drwl/annotaterb/issues/201)
24
+
25
+ **Merged pull requests:**
26
+
27
+ - Bump version to v4.23.0 [\#347](https://github.com/drwl/annotaterb/pull/347) ([drwl](https://github.com/drwl))
28
+ - Bump actions/checkout from 6 to 7 [\#346](https://github.com/drwl/annotaterb/pull/346) ([dependabot[bot]](https://github.com/apps/dependabot))
29
+ - Fix duplicate annotations when a file ends with a trailing blank line [\#342](https://github.com/drwl/annotaterb/pull/342) ([OdenTakashi](https://github.com/OdenTakashi))
30
+ - Fix update\_config for non-default config locations [\#341](https://github.com/drwl/annotaterb/pull/341) ([OdenTakashi](https://github.com/OdenTakashi))
31
+ - Fix NameError in ZeitwerkClassGetter for collapsed Zeitwerk paths [\#339](https://github.com/drwl/annotaterb/pull/339) ([KaiyuanMa](https://github.com/KaiyuanMa))
32
+ - Fix annotation inserted inside module when doc comment is present [\#338](https://github.com/drwl/annotaterb/pull/338) ([OdenTakashi](https://github.com/OdenTakashi))
33
+ - Add `position_in_class: after_doc` to keep class doc adjacent to class [\#337](https://github.com/drwl/annotaterb/pull/337) ([yamat47](https://github.com/yamat47))
34
+ - Fix annotation placement above inner class declarations under `nested_position` [\#336](https://github.com/drwl/annotaterb/pull/336) ([yamat47](https://github.com/yamat47))
35
+ - Drop Ruby \< 3.3 from CI matrix [\#335](https://github.com/drwl/annotaterb/pull/335) ([drwl](https://github.com/drwl))
36
+ - Fix annotation placement in nested files and around trailing user comments [\#334](https://github.com/drwl/annotaterb/pull/334) ([yamat47](https://github.com/yamat47))
37
+ - Preserve indentation when re-running annotaterb on nested-position annotations. [\#333](https://github.com/drwl/annotaterb/pull/333) ([yamat47](https://github.com/yamat47))
38
+ - Respect `--config-path` during config loading [\#331](https://github.com/drwl/annotaterb/pull/331) ([OdenTakashi](https://github.com/OdenTakashi))
39
+ - Fall back to current task when no top-level task is set [\#328](https://github.com/drwl/annotaterb/pull/328) ([jordan-brough](https://github.com/jordan-brough))
40
+ - Fix --show-migration option not working on Rails 7.2+. [\#327](https://github.com/drwl/annotaterb/pull/327) ([OdenTakashi](https://github.com/OdenTakashi))
41
+ - Exit with non-zero code when database is not accessible [\#326](https://github.com/drwl/annotaterb/pull/326) ([OdenTakashi](https://github.com/OdenTakashi))
42
+ - fix potential ReDoS in route annotator regex [\#325](https://github.com/drwl/annotaterb/pull/325) ([OdenTakashi](https://github.com/OdenTakashi))
43
+ - Support opt-in display of PostgreSQL enum types in annotations [\#324](https://github.com/drwl/annotaterb/pull/324) ([OdenTakashi](https://github.com/OdenTakashi))
44
+ - Fix error when column comments contain `%` [\#322](https://github.com/drwl/annotaterb/pull/322) ([willnet](https://github.com/willnet))
45
+ - Add unit tests for AnnotateRb::Helper [\#321](https://github.com/drwl/annotaterb/pull/321) ([OdenTakashi](https://github.com/OdenTakashi))
46
+ - Migrate issue templates to folder-based format [\#320](https://github.com/drwl/annotaterb/pull/320) ([OdenTakashi](https://github.com/OdenTakashi))
47
+ - Add opt-in show\_indexes\_comments option to display index comments [\#319](https://github.com/drwl/annotaterb/pull/319) ([olleolleolle](https://github.com/olleolleolle))
48
+ - Only connect SecondaryRecord to secondary DB when MULTI\_DB\_TEST is set [\#318](https://github.com/drwl/annotaterb/pull/318) ([OdenTakashi](https://github.com/OdenTakashi))
49
+ - Correct secondary database setup in dummy app [\#317](https://github.com/drwl/annotaterb/pull/317) ([OdenTakashi](https://github.com/OdenTakashi))
50
+ - Remove duplicate ignore\_unknown\_models option definition [\#316](https://github.com/drwl/annotaterb/pull/316) ([OdenTakashi](https://github.com/OdenTakashi))
51
+ - Fix column annotation alignment for CJK and fullwidth characters [\#315](https://github.com/drwl/annotaterb/pull/315) ([SergeyGildenshtern](https://github.com/SergeyGildenshtern))
52
+ - Added command example for applying annotations to README [\#313](https://github.com/drwl/annotaterb/pull/313) ([zackerms](https://github.com/zackerms))
53
+ - Generate changelog for v4.22.0 [\#311](https://github.com/drwl/annotaterb/pull/311) ([drwl](https://github.com/drwl))
54
+ - Add opt-in support for automatically annotating routes after migration tasks [\#293](https://github.com/drwl/annotaterb/pull/293) ([OdenTakashi](https://github.com/OdenTakashi))
55
+
3
56
  ## [v4.22.0](https://github.com/drwl/annotaterb/tree/v4.22.0) (2026-02-12)
4
57
 
5
58
  [Full Changelog](https://github.com/drwl/annotaterb/compare/v4.21.0...v4.22.0)
6
59
 
60
+ **Implemented enhancements:**
61
+
62
+ - Feature: ruby-lsp addon [\#175](https://github.com/drwl/annotaterb/issues/175)
63
+ - Mounting ActionCable leads to weird annotation [\#161](https://github.com/drwl/annotaterb/issues/161)
64
+
7
65
  **Fixed bugs:**
8
66
 
9
67
  - Yardoc formatting for comments on database attributes [\#162](https://github.com/drwl/annotaterb/issues/162)
@@ -13,8 +71,6 @@
13
71
  - New `ignore_multi_database_name` option seems to be non-functional [\#303](https://github.com/drwl/annotaterb/issues/303)
14
72
  - Changing sort options does not change annotations [\#294](https://github.com/drwl/annotaterb/issues/294)
15
73
  - CLI script for annotaterb not installed or runnable [\#290](https://github.com/drwl/annotaterb/issues/290)
16
- - Feature: ruby-lsp addon [\#175](https://github.com/drwl/annotaterb/issues/175)
17
- - Mounting ActionCable leads to weird annotation [\#161](https://github.com/drwl/annotaterb/issues/161)
18
74
 
19
75
  **Merged pull requests:**
20
76
 
@@ -22,7 +78,7 @@
22
78
  - Run CI on CRuby 4.0 [\#308](https://github.com/drwl/annotaterb/pull/308) ([viralpraxis](https://github.com/viralpraxis))
23
79
  - Generate changelog for v4.21.0 [\#307](https://github.com/drwl/annotaterb/pull/307) ([drwl](https://github.com/drwl))
24
80
  - fix NoMethodError when using nested\_position with fixture files [\#298](https://github.com/drwl/annotaterb/pull/298) ([OdenTakashi](https://github.com/OdenTakashi))
25
- - fix: Respect configured sort [\#295](https://github.com/drwl/annotaterb/pull/295) ([patrickarnett](https://github.com/patrickarnett)) **(Maintainer note: this could result in annotations shifting depending on configuration, please create an issue if it is a breaking change)**
81
+ - fix: Respect configured sort [\#295](https://github.com/drwl/annotaterb/pull/295) ([patrickarnett](https://github.com/patrickarnett))
26
82
  - Use `#lease_connection` if available [\#292](https://github.com/drwl/annotaterb/pull/292) ([viralpraxis](https://github.com/viralpraxis))
27
83
  - refactor: simplify primary key check logic \(no functional changes\) [\#285](https://github.com/drwl/annotaterb/pull/285) ([OdenTakashi](https://github.com/OdenTakashi))
28
84
  - Honor skip\_on\_db\_migrate config option when runnig migrate tasks [\#274](https://github.com/drwl/annotaterb/pull/274) ([martinechtner](https://github.com/martinechtner))
data/README.md CHANGED
@@ -237,6 +237,122 @@ Annotaterb reads first the configuration file, if it exists, passes its content
237
237
 
238
238
  For further details visit the [section in the migration guide](MIGRATION_GUIDE.md#automatic-annotations-after-running-database-migration-commands).
239
239
 
240
+ ### Configuration options
241
+
242
+ Keys use snake_case and match the gem defaults in `AnnotateRb::Options`. CLI flags override values from the config file.
243
+
244
+ #### Position
245
+
246
+ | Option | Default | Description |
247
+ | --- | --- | --- |
248
+ | `position` | `before` | Fallback position for all `position_in_*` options. One of `before`, `top`, `after`, `bottom`, `before_doc`. |
249
+ | `position_in_class` | `before` | Position in model files. `before_doc` keeps class documentation adjacent to the class. |
250
+ | `position_in_factory` | `before` | Position in FactoryBot factory files. |
251
+ | `position_in_fixture` | `before` | Position in fixture files. |
252
+ | `position_in_test` | `before` | Position in test/spec files. |
253
+ | `position_in_routes` | `before` | Position in `config/routes.rb`. |
254
+ | `position_in_serializer` | `before` | Position in serializer files. |
255
+ | `position_in_additional_file_patterns` | `before` | Position in files matched by `additional_file_patterns`. |
256
+ | `nested_position` | `false` | Place annotations directly above nested classes/modules instead of at the top of the file. |
257
+
258
+ #### Schema annotation content
259
+
260
+ | Option | Default | Description |
261
+ | --- | --- | --- |
262
+ | `show_foreign_keys` | `true` | List foreign key constraints. |
263
+ | `show_complete_foreign_keys` | `false` | Use complete foreign key names. |
264
+ | `show_indexes` | `true` | List table indexes. |
265
+ | `show_indexes_comments` | `false` | Include index comments. |
266
+ | `show_indexes_include` | `false` | Include `INCLUDE` columns on indexes. |
267
+ | `simple_indexes` | `false` | Concat related indexes onto each column line. |
268
+ | `show_check_constraints` | `false` | List check constraints. |
269
+ | `show_enums` | `false` | Show PostgreSQL enum types. |
270
+ | `show_virtual_columns` | `false` | Show virtual/generated columns. |
271
+ | `include_version` | `false` | Include the migration version number. |
272
+ | `with_comment` | `true` | Include database comments (fallback for column/table comment flags). |
273
+ | `with_column_comments` | `true` | Include column comments. |
274
+ | `with_table_comments` | `true` | Include table comments. |
275
+ | `position_of_column_comment` | `with_name` | Column comment placement: `with_name` or `rightmost_column`. |
276
+ | `hide_default_column_types` | `""` | Comma-separated column types that omit defaults (e.g. `json,jsonb,hstore`). |
277
+ | `hide_limit_column_types` | `""` | Comma-separated column types that omit limits (e.g. `integer,boolean,text`). |
278
+ | `ignore_columns` | `null` | Regex of column names to skip. |
279
+ | `ignore_database_name` | `false` | Omit the database name from annotations. |
280
+ | `ignore_multi_database_name` | `false` | Omit the database name in multi-database setups. |
281
+ | `timestamp_columns` | `[created_at, updated_at]` | Column names treated as timestamps for classified sorting. |
282
+ | `classes_default_to_s` | `[]` | Class names whose default values are rendered with `to_s`. |
283
+
284
+ #### Sorting and format
285
+
286
+ | Option | Default | Description |
287
+ | --- | --- | --- |
288
+ | `sort` | `false` | Sort columns alphabetically. |
289
+ | `classified_sort` | `true` | Sort as id → other columns → timestamps → associations. |
290
+ | `grouped_polymorphic` | `false` | Group polymorphic associations when using `classified_sort`. |
291
+ | `format_markdown` | `false` | Render annotations as Markdown. |
292
+ | `format_rdoc` | `false` | Render annotations as RDoc. |
293
+ | `format_yard` | `false` | Render annotations as YARD. |
294
+
295
+ #### What to annotate / exclude
296
+
297
+ | Option | Default | Description |
298
+ | --- | --- | --- |
299
+ | `active_admin` | `false` | Annotate ActiveAdmin models. |
300
+ | `exclude_controllers` | `true` | Skip controller files. |
301
+ | `exclude_factories` | `false` | Skip factory files. |
302
+ | `exclude_fixtures` | `false` | Skip fixture files. |
303
+ | `exclude_helpers` | `true` | Skip helper files. |
304
+ | `exclude_scaffolds` | `true` | Skip scaffold files. |
305
+ | `exclude_serializers` | `false` | Skip serializer files. |
306
+ | `exclude_sti_subclasses` | `false` | Skip STI subclasses. |
307
+ | `exclude_tests` | `false` | Skip test/spec files. Can also be an array of symbols. |
308
+ | `ignore_model_sub_dir` | `false` | Ignore subdirectories under `model_dir`. |
309
+ | `ignore_unknown_models` | `false` | Suppress warnings for bad model files. |
310
+
311
+ #### Paths
312
+
313
+ | Option | Default | Description |
314
+ | --- | --- | --- |
315
+ | `model_dir` | `[app/models]` | Directories containing models. |
316
+ | `root_dir` | `[""]` | Root directories for multi-project layouts. |
317
+ | `additional_file_patterns` | `[]` | Extra paths/globs to annotate (supports `%MODEL_NAME%`). |
318
+ | `require` | `[]` | Extra files to require before loading models. |
319
+
320
+ #### Routes
321
+
322
+ | Option | Default | Description |
323
+ | --- | --- | --- |
324
+ | `ignore_routes` | `null` | Regex of routes to skip. |
325
+ | `timestamp` | `false` | Include a timestamp in route annotations. |
326
+ | `auto_annotate_routes_after_migrate` | `false` | Also annotate routes after DB migrate tasks. |
327
+
328
+ #### Wrappers and behavior
329
+
330
+ | Option | Default | Description |
331
+ | --- | --- | --- |
332
+ | `wrapper` | `null` | Text used for both opening and closing wrappers. |
333
+ | `wrapper_open` | `null` | Opening wrapper text (falls back to `wrapper`). |
334
+ | `wrapper_close` | `null` | Closing wrapper text (falls back to `wrapper`). |
335
+ | `force` | `false` | Rewrite annotations even when unchanged. |
336
+ | `frozen` | `false` | Exit non-zero if annotations would change. |
337
+ | `skip_on_db_migrate` | `false` | Skip automatic annotation after DB migrate tasks. |
338
+ | `debug` | `false` | Print resolved options for debugging. |
339
+ | `trace` | `false` | Print full stack traces when annotation fails. |
340
+
341
+ Example:
342
+
343
+ ```yml
344
+ # .annotaterb.yml
345
+ position: after
346
+ show_foreign_keys: true
347
+ show_indexes: true
348
+ show_enums: true
349
+ classified_sort: true
350
+ model_dir:
351
+ - app/models
352
+ - app/models/concerns
353
+ skip_on_db_migrate: false
354
+ ```
355
+
240
356
  ### Preserving class documentation comments
241
357
 
242
358
  By default, when `position_in_class` is `before` (or `top`), AnnotateRb places the schema annotation immediately before the class declaration line. Any human-written documentation comment that was directly above the class is therefore pushed above the annotation.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 4.23.0
1
+ 4.24.0
@@ -27,6 +27,8 @@ module AnnotateRb
27
27
  IndexAnnotation::AnnotationBuilder.new(@model, @options).build,
28
28
  ForeignKeyAnnotation::AnnotationBuilder.new(@model, @options).build,
29
29
  CheckConstraintAnnotation::AnnotationBuilder.new(@model, @options).build,
30
+ UniqueConstraintAnnotation::AnnotationBuilder.new(@model, @options).build,
31
+ ExclusionConstraintAnnotation::AnnotationBuilder.new(@model, @options).build,
30
32
  EnumAnnotation::AnnotationBuilder.new(@model, @options).build,
31
33
  SchemaFooter.new
32
34
  ]
@@ -65,7 +67,7 @@ module AnnotateRb
65
67
  table_name = @model.table_name
66
68
  table_comment = @model.connection.try(:table_comment, @model.table_name)
67
69
  max_size = @model.max_schema_info_width
68
- database_name = @model.database_name if multi_db_environment?
70
+ database_name = @model.database_name if show_database_name?
69
71
 
70
72
  _annotation = Annotation.new(@options,
71
73
  version: version, table_name: table_name, table_comment: table_comment,
@@ -74,6 +76,11 @@ module AnnotateRb
74
76
 
75
77
  private
76
78
 
79
+ # TODO: Consolidate ignore_database_name and ignore_multi_database_name; they serve the same purpose
80
+ def show_database_name?
81
+ !@options[:ignore_database_name] && multi_db_environment?
82
+ end
83
+
77
84
  def multi_db_environment?
78
85
  return false if @options[:ignore_multi_database_name]
79
86
 
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnnotateRb
4
+ module ModelAnnotator
5
+ module ExclusionConstraintAnnotation
6
+ class Annotation
7
+ HEADER_TEXT = "Exclusion Constraints"
8
+
9
+ def initialize(constraints)
10
+ @constraints = constraints
11
+ end
12
+
13
+ def body
14
+ [
15
+ Components::BlankCommentLine.new,
16
+ Components::Header.new(HEADER_TEXT),
17
+ Components::BlankCommentLine.new,
18
+ *@constraints
19
+ ]
20
+ end
21
+
22
+ def to_markdown
23
+ body.map(&:to_markdown).join("\n")
24
+ end
25
+
26
+ def to_rdoc
27
+ body.map(&:to_rdoc).join("\n")
28
+ end
29
+
30
+ def to_yard
31
+ body.map(&:to_yard).join("\n")
32
+ end
33
+
34
+ def to_default
35
+ body.map(&:to_default).join("\n")
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnnotateRb
4
+ module ModelAnnotator
5
+ module ExclusionConstraintAnnotation
6
+ class AnnotationBuilder
7
+ def initialize(model, options)
8
+ @model = model
9
+ @options = options
10
+ end
11
+
12
+ def build
13
+ return Components::NilComponent.new if !@options[:show_exclusion_constraints]
14
+ return Components::NilComponent.new unless @model.connection.respond_to?(:supports_exclusion_constraints?) &&
15
+ @model.connection.supports_exclusion_constraints? && @model.connection.respond_to?(:exclusion_constraints)
16
+
17
+ exclusion_constraints = @model.connection.exclusion_constraints(@model.table_name)
18
+ return Components::NilComponent.new if exclusion_constraints.empty?
19
+
20
+ max_size = exclusion_constraints.map { |exclusion_constraint| exclusion_constraint.name.size }.max + 1
21
+
22
+ constraints = exclusion_constraints.sort_by(&:name).map do |exclusion_constraint|
23
+ details = "(#{exclusion_constraint.expression})"
24
+ details += " USING #{exclusion_constraint.using}" if exclusion_constraint.using
25
+ details += " WHERE (#{exclusion_constraint.where})" if exclusion_constraint.where
26
+ if exclusion_constraint.deferrable
27
+ details += " DEFERRABLE INITIALLY #{exclusion_constraint.deferrable.to_s.upcase}"
28
+ end
29
+
30
+ ExclusionConstraintComponent.new(exclusion_constraint.name, details, max_size)
31
+ end
32
+
33
+ _annotation = Annotation.new(constraints)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnnotateRb
4
+ module ModelAnnotator
5
+ module ExclusionConstraintAnnotation
6
+ class ExclusionConstraintComponent < Components::Base
7
+ attr_reader :name, :details, :max_size
8
+
9
+ def initialize(name, details, max_size)
10
+ @name = name
11
+ @details = details
12
+ @max_size = max_size
13
+ end
14
+
15
+ def to_default
16
+ # standard:disable Lint/FormatParameterMismatch
17
+ sprintf("# %-#{max_size}.#{max_size}s %s", name, details).rstrip
18
+ # standard:enable Lint/FormatParameterMismatch
19
+ end
20
+
21
+ def to_markdown
22
+ sprintf("# * `%s`: `%s`", name, details)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnnotateRb
4
+ module ModelAnnotator
5
+ module ExclusionConstraintAnnotation
6
+ autoload :AnnotationBuilder, "annotate_rb/model_annotator/exclusion_constraint_annotation/annotation_builder"
7
+ autoload :Annotation, "annotate_rb/model_annotator/exclusion_constraint_annotation/annotation"
8
+ autoload :ExclusionConstraintComponent, "annotate_rb/model_annotator/exclusion_constraint_annotation/exclusion_constraint_component"
9
+ end
10
+ end
11
+ end
@@ -19,9 +19,19 @@ module AnnotateRb
19
19
  SCHEMA_HEADER_EXACT = [
20
20
  IndexAnnotation::Annotation::HEADER_TEXT,
21
21
  ForeignKeyAnnotation::Annotation::HEADER_TEXT,
22
- CheckConstraintAnnotation::Annotation::HEADER_TEXT
22
+ CheckConstraintAnnotation::Annotation::HEADER_TEXT,
23
+ UniqueConstraintAnnotation::Annotation::HEADER_TEXT,
24
+ ExclusionConstraintAnnotation::Annotation::HEADER_TEXT,
25
+ EnumAnnotation::Annotation::HEADER_TEXT
23
26
  ].freeze
24
27
 
28
+ # `format_markdown: true` renders each of the section headers above as
29
+ # "### <name>" instead of "<name>", so SCHEMA_HEADER_EXACT can't match it directly.
30
+ # There's no HEADER_TEXT constant for the columns table itself,
31
+ # so "Columns" is listed explicitly here.
32
+ MARKDOWN_HEADER_PREFIX = "### "
33
+ MARKDOWN_SECTION_HEADERS = (SCHEMA_HEADER_EXACT + ["Columns"]).freeze
34
+
25
35
  class MalformedAnnotation < StandardError; end
26
36
 
27
37
  class NoAnnotationFound < StandardError; end
@@ -118,7 +128,18 @@ module AnnotateRb
118
128
  ending
119
129
  end
120
130
 
121
- # Tabular rows have ≥2 leading spaces after `#`; prose has at most one.
131
+ # A line is part of the schema block if it matches one of the known
132
+ # header prefixes/exact texts, or if it looks like a tabular/list row
133
+ # of the annotation body:
134
+ #
135
+ # - Default/plain format: rows have >= 2 leading spaces after `#`
136
+ # (e.g. "# id :integer not null"); prose has at most one.
137
+ # - Markdown format (`format_markdown: true`) instead uses a single
138
+ # leading space after `#` for everything, so it needs its own
139
+ # detection: "### <name>" section headers (matched against
140
+ # MARKDOWN_SECTION_HEADERS with the prefix stripped), "|"-delimited
141
+ # table rows (header/divider/data), and "* `...`" bulleted list
142
+ # items for indexes/foreign keys/enums.
122
143
  def schema_like?(comment)
123
144
  return true if comment == DEFAULT_ANNOTATION_ENDING
124
145
 
@@ -128,6 +149,21 @@ module AnnotateRb
128
149
  return true if SCHEMA_HEADER_PREFIXES.any? { |p| text.start_with?(p) }
129
150
  return true if SCHEMA_HEADER_EXACT.include?(text)
130
151
 
152
+ # Markdown section headers, e.g. "### Columns", "### Indexes".
153
+ if text.start_with?(MARKDOWN_HEADER_PREFIX)
154
+ return MARKDOWN_SECTION_HEADERS.include?(text.delete_prefix(MARKDOWN_HEADER_PREFIX))
155
+ end
156
+
157
+ # Requires at least two | separators to identify Markdown tables.
158
+ # This prevents normal user comments containing a single | from being mistaken for a table.
159
+ return true if text.count("|") >= 2
160
+
161
+ # Markdown bulleted list items used for foreign keys/indexes/enums,
162
+ # e.g. "* `index_name`:" or the nested " * **`column`**". The
163
+ # backtick right after the bullet marker distinguishes these from
164
+ # an unrelated user comment that starts with a plain "* ".
165
+ return true if text.match?(/\A\s*\*\s+\*{0,2}`/)
166
+
131
167
  comment.match?(/\A#\s{2,}\S/)
132
168
  end
133
169
  end
@@ -43,10 +43,20 @@ module AnnotateRb
43
43
  begin
44
44
  parser.parse(@input)
45
45
  rescue Psych::SyntaxError => _e
46
- # "Dynamic fixtures with ERB" exist in Rails, and will cause Psych.parser to error
47
- # This is a hacky solution to get around this and still have it parse
48
- erb_yml = ERB.new(@input).result
49
- parser.parse(erb_yml)
46
+ # "Dynamic fixtures with ERB" exist in Rails and cause Psych.parser to error.
47
+ #
48
+ # We deliberately do not evaluate the ERB and read line numbers off the
49
+ # result: evaluating runs arbitrary code, and the line numbers from the
50
+ # evaluated output do not map back to the original file (ERB tags spanning
51
+ # multiple lines shift the offsets), which would place annotations inside
52
+ # an ERB tag. Instead we derive the content bounds straight from the
53
+ # original lines so annotations land around the ERB body.
54
+ #
55
+ # Only fall back for actual ERB fixtures. Genuinely malformed YAML
56
+ # (no ERB tags) should keep surfacing the parse error rather than
57
+ # being silently annotated.
58
+ raise unless erb_fixture?
59
+ return record_erb_positions
50
60
  end
51
61
 
52
62
  stream = parser.handler.root
@@ -68,6 +78,35 @@ module AnnotateRb
68
78
  @ends << [nil, stream.end_line]
69
79
  end
70
80
  end
81
+
82
+ # Locates the content bounds of an ERB fixture directly from the original
83
+ # lines, treating the ERB/YAML body as the doc. The start is the first
84
+ # non-blank, non-comment line so annotations are written above the ERB
85
+ # block (and after any leading comments), never inside a tag.
86
+ def record_erb_positions
87
+ lines = @input.split($/)
88
+ content_start = lines.index { |line| content_line?(line) }
89
+
90
+ if content_start.nil?
91
+ @starts << [nil, 0]
92
+ @ends << [nil, 0]
93
+ else
94
+ content_end = lines.rindex { |line| content_line?(line) }
95
+ @starts << [nil, content_start]
96
+ @ends << [nil, content_end + 1]
97
+ end
98
+ end
99
+
100
+ def content_line?(line)
101
+ stripped = line.strip
102
+ !stripped.empty? && !stripped.start_with?("#")
103
+ end
104
+
105
+ # True when the input contains an ERB tag, i.e. it is a dynamic fixture
106
+ # rather than plain (possibly malformed) YAML.
107
+ def erb_fixture?
108
+ @input.match?(/<%.*?%>/m)
109
+ end
71
110
  end
72
111
  end
73
112
  end
@@ -32,6 +32,12 @@ module AnnotateRb
32
32
  constraints_info = ""
33
33
  constraints_info += "ON DELETE => #{foreign_key.on_delete} " if foreign_key.on_delete
34
34
  constraints_info += "ON UPDATE => #{foreign_key.on_update} " if foreign_key.on_update
35
+ if foreign_key.respond_to?(:deferrable) && foreign_key.deferrable
36
+ # Rails 7.0's extract_foreign_key_deferrable returns `true` for the initially-immediate case;
37
+ # 7.1+ normalized this to `:immediate`. Map `true` back to IMMEDIATE so we don't emit "INITIALLY TRUE".
38
+ initially = (foreign_key.deferrable == true) ? "IMMEDIATE" : foreign_key.deferrable.to_s.upcase
39
+ constraints_info += "DEFERRABLE INITIALLY #{initially} "
40
+ end
35
41
  constraints_info.strip
36
42
  end
37
43
  end
@@ -15,6 +15,9 @@ module AnnotateRb
15
15
  indexes = @model.retrieve_indexes_from_table
16
16
  return Components::NilComponent.new if indexes.empty?
17
17
 
18
+ indexes = reject_constraint_backed_indexes(indexes)
19
+ return Components::NilComponent.new if indexes.empty?
20
+
18
21
  max_size = indexes.map { |index| index.name.size }.max + 1
19
22
 
20
23
  indexes = indexes.sort_by(&:name).map do |index|
@@ -23,6 +26,32 @@ module AnnotateRb
23
26
 
24
27
  _annotation = Annotation.new(indexes)
25
28
  end
29
+
30
+ private
31
+
32
+ # Mirrors ActiveRecord's schema_dumper#indexes_in_create: PostgreSQL's
33
+ # unique and exclusion constraints are backed by indexes with the same
34
+ # name, so those show up in `connection.indexes` too. Drop them here so
35
+ # they only appear under their dedicated sections.
36
+ def reject_constraint_backed_indexes(indexes)
37
+ connection = @model.connection
38
+
39
+ if connection.respond_to?(:supports_exclusion_constraints?) &&
40
+ connection.supports_exclusion_constraints? &&
41
+ connection.respond_to?(:exclusion_constraints)
42
+ excl_names = connection.exclusion_constraints(@model.table_name).map(&:name)
43
+ indexes = indexes.reject { |index| excl_names.include?(index.name) }
44
+ end
45
+
46
+ if connection.respond_to?(:supports_unique_constraints?) &&
47
+ connection.supports_unique_constraints? &&
48
+ connection.respond_to?(:unique_constraints)
49
+ unique_names = connection.unique_constraints(@model.table_name).map(&:name)
50
+ indexes = indexes.reject { |index| unique_names.include?(index.name) }
51
+ end
52
+
53
+ indexes
54
+ end
26
55
  end
27
56
  end
28
57
  end
@@ -68,8 +68,20 @@ module AnnotateRb
68
68
  connection.table_comment(@klass.table_name).present?
69
69
  end
70
70
 
71
+ # Returns column defaults for annotations.
72
+ #
73
+ # `Model#column_defaults` reflects `attribute :foo, default: X` overrides,
74
+ # which would incorrectly show the Ruby-side default in annotations
75
+ # instead of the DB schema default. To preserve model-level decorations
76
+ # such as `TimeZoneConverter` on datetime columns, we start from
77
+ # `column_defaults` and only substitute the DB schema value when a
78
+ # difference indicates an attribute-level override.
71
79
  def column_defaults
72
- @klass.column_defaults
80
+ @column_defaults ||= @klass.column_defaults.each_with_object({}) do |(name, value), result|
81
+ column = @klass.columns_hash[name]
82
+ schema_value = schema_default_for(column)
83
+ result[name] = (value == schema_value) ? value : schema_value
84
+ end
73
85
  end
74
86
 
75
87
  # Add columns managed by the globalize gem if this gem is being used.
@@ -263,6 +275,26 @@ module AnnotateRb
263
275
 
264
276
  @options.get_state(cache_key)
265
277
  end
278
+
279
+ private
280
+
281
+ def schema_default_for(column)
282
+ return nil if column.nil? || column.default.nil? || column.default_function
283
+ cast_type_for(column).deserialize(column.default)
284
+ end
285
+
286
+ # Rails post-8.1 exposes `Column#cast_type` directly; Rails 8.1 introduced
287
+ # the transitional `Column#fetch_cast_type(connection)`; older versions
288
+ # required `connection.lookup_cast_type_from_column(column)`.
289
+ def cast_type_for(column)
290
+ if column.respond_to?(:cast_type)
291
+ column.cast_type
292
+ elsif column.respond_to?(:fetch_cast_type)
293
+ column.fetch_cast_type(connection)
294
+ else
295
+ connection.lookup_cast_type_from_column(column)
296
+ end
297
+ end
266
298
  end
267
299
  end
268
300
  end
@@ -18,7 +18,7 @@ module AnnotateRb
18
18
  parsed_file = FileParser::ParsedFile.new(old_content, "", parser_klass, options).parse
19
19
  rescue FileParser::AnnotationFinder::MalformedAnnotation => e
20
20
  warn "Unable to process #{file_name}: #{e.message}"
21
- warn "\t" + e.backtrace.join("\n\t") if @options[:trace]
21
+ warn "\t" + e.backtrace.join("\n\t") if options[:trace]
22
22
  return false
23
23
  end
24
24
 
@@ -31,7 +31,7 @@ module AnnotateRb
31
31
  parsed_file = FileParser::ParsedFile.new(old_content, annotation, parser_klass, options, model_class_name: model_class_name).parse
32
32
  rescue FileParser::AnnotationFinder::MalformedAnnotation => e
33
33
  warn "Unable to process #{file_name}: #{e.message}"
34
- warn "\t" + e.backtrace.join("\n\t") if @options[:trace]
34
+ warn "\t" + e.backtrace.join("\n\t") if options[:trace]
35
35
  return false
36
36
  end
37
37
 
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnnotateRb
4
+ module ModelAnnotator
5
+ module UniqueConstraintAnnotation
6
+ class Annotation
7
+ HEADER_TEXT = "Unique Constraints"
8
+
9
+ def initialize(constraints)
10
+ @constraints = constraints
11
+ end
12
+
13
+ def body
14
+ [
15
+ Components::BlankCommentLine.new,
16
+ Components::Header.new(HEADER_TEXT),
17
+ Components::BlankCommentLine.new,
18
+ *@constraints
19
+ ]
20
+ end
21
+
22
+ def to_markdown
23
+ body.map(&:to_markdown).join("\n")
24
+ end
25
+
26
+ def to_rdoc
27
+ body.map(&:to_rdoc).join("\n")
28
+ end
29
+
30
+ def to_yard
31
+ body.map(&:to_yard).join("\n")
32
+ end
33
+
34
+ def to_default
35
+ body.map(&:to_default).join("\n")
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnnotateRb
4
+ module ModelAnnotator
5
+ module UniqueConstraintAnnotation
6
+ class AnnotationBuilder
7
+ def initialize(model, options)
8
+ @model = model
9
+ @options = options
10
+ end
11
+
12
+ def build
13
+ return Components::NilComponent.new if !@options[:show_unique_constraints]
14
+ return Components::NilComponent.new unless @model.connection.respond_to?(:supports_unique_constraints?) &&
15
+ @model.connection.supports_unique_constraints? && @model.connection.respond_to?(:unique_constraints)
16
+
17
+ unique_constraints = @model.connection.unique_constraints(@model.table_name)
18
+ return Components::NilComponent.new if unique_constraints.empty?
19
+
20
+ max_size = unique_constraints.map { |unique_constraint| unique_constraint.name.size }.max + 1
21
+
22
+ constraints = unique_constraints.sort_by(&:name).map do |unique_constraint|
23
+ columns = Array(unique_constraint.column)
24
+ details = "(#{columns.join(", ")})"
25
+ if unique_constraint.deferrable
26
+ details += " DEFERRABLE INITIALLY #{unique_constraint.deferrable.to_s.upcase}"
27
+ end
28
+
29
+ UniqueConstraintComponent.new(unique_constraint.name, details, max_size)
30
+ end
31
+
32
+ _annotation = Annotation.new(constraints)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnnotateRb
4
+ module ModelAnnotator
5
+ module UniqueConstraintAnnotation
6
+ class UniqueConstraintComponent < Components::Base
7
+ attr_reader :name, :details, :max_size
8
+
9
+ def initialize(name, details, max_size)
10
+ @name = name
11
+ @details = details
12
+ @max_size = max_size
13
+ end
14
+
15
+ def to_default
16
+ # standard:disable Lint/FormatParameterMismatch
17
+ sprintf("# %-#{max_size}.#{max_size}s %s", name, details).rstrip
18
+ # standard:enable Lint/FormatParameterMismatch
19
+ end
20
+
21
+ def to_markdown
22
+ sprintf("# * `%s`: `%s`", name, details)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnnotateRb
4
+ module ModelAnnotator
5
+ module UniqueConstraintAnnotation
6
+ autoload :AnnotationBuilder, "annotate_rb/model_annotator/unique_constraint_annotation/annotation_builder"
7
+ autoload :Annotation, "annotate_rb/model_annotator/unique_constraint_annotation/annotation"
8
+ autoload :UniqueConstraintComponent, "annotate_rb/model_annotator/unique_constraint_annotation/unique_constraint_component"
9
+ end
10
+ end
11
+ end
@@ -27,6 +27,8 @@ module AnnotateRb
27
27
  autoload :FileParser, "annotate_rb/model_annotator/file_parser"
28
28
  autoload :ZeitwerkClassGetter, "annotate_rb/model_annotator/zeitwerk_class_getter"
29
29
  autoload :CheckConstraintAnnotation, "annotate_rb/model_annotator/check_constraint_annotation"
30
+ autoload :UniqueConstraintAnnotation, "annotate_rb/model_annotator/unique_constraint_annotation"
31
+ autoload :ExclusionConstraintAnnotation, "annotate_rb/model_annotator/exclusion_constraint_annotation"
30
32
  autoload :EnumAnnotation, "annotate_rb/model_annotator/enum_annotation"
31
33
  autoload :FileToParserMapper, "annotate_rb/model_annotator/file_to_parser_mapper"
32
34
  autoload :Components, "annotate_rb/model_annotator/components"
@@ -42,11 +42,14 @@ module AnnotateRb
42
42
  format_yard: false, # ModelAnnotator
43
43
  frozen: false, # ModelAnnotator, but should be used by both
44
44
  grouped_polymorphic: false, # ModelAnnotator
45
+ ignore_database_name: false, # ModelAnnotator
45
46
  ignore_model_sub_dir: false, # ModelAnnotator
46
47
  ignore_unknown_models: false, # ModelAnnotator
47
48
  include_version: false, # ModelAnnotator
48
49
  show_complete_foreign_keys: false, # ModelAnnotator
49
50
  show_check_constraints: false, # ModelAnnotator
51
+ show_unique_constraints: false, # ModelAnnotator
52
+ show_exclusion_constraints: false, # ModelAnnotator
50
53
  show_enums: false, # ModelAnnotator
51
54
  show_foreign_keys: true, # ModelAnnotator
52
55
  show_indexes: true, # ModelAnnotator
@@ -118,10 +121,13 @@ module AnnotateRb
118
121
  :format_yard,
119
122
  :frozen,
120
123
  :grouped_polymorphic,
124
+ :ignore_database_name,
121
125
  :ignore_model_sub_dir,
122
126
  :ignore_unknown_models,
123
127
  :include_version,
124
128
  :show_check_constraints,
129
+ :show_unique_constraints,
130
+ :show_exclusion_constraints,
125
131
  :show_enums,
126
132
  :show_complete_foreign_keys,
127
133
  :show_foreign_keys,
@@ -207,6 +207,16 @@ module AnnotateRb
207
207
  @options[:show_check_constraints] = true
208
208
  end
209
209
 
210
+ option_parser.on("--show-unique-constraints",
211
+ "List the table's unique constraints in the annotation") do
212
+ @options[:show_unique_constraints] = true
213
+ end
214
+
215
+ option_parser.on("--show-exclusion-constraints",
216
+ "List the table's exclusion constraints in the annotation") do
217
+ @options[:show_exclusion_constraints] = true
218
+ end
219
+
210
220
  option_parser.on("--hide-limit-column-types VALUES",
211
221
  "don't show limit for given column types, separated by commas (i.e., `integer,boolean,text`)") do |values|
212
222
  @options[:hide_limit_column_types] = values.to_s
@@ -222,6 +232,11 @@ module AnnotateRb
222
232
  @options[:ignore_unknown_models] = true
223
233
  end
224
234
 
235
+ option_parser.on("--ignore-database-name",
236
+ "don't include the database name in the annotation") do
237
+ @options[:ignore_database_name] = true
238
+ end
239
+
225
240
  option_parser.on("-I",
226
241
  "--ignore-columns REGEX",
227
242
  "don't annotate columns that match a given REGEX (i.e., `annotate -I '^(id|updated_at|created_at)'`") do |regex|
@@ -234,7 +249,7 @@ module AnnotateRb
234
249
  end
235
250
 
236
251
  option_parser.on("--without-comment",
237
- "include database comments in model annotations") do
252
+ "exclude database comments in model annotations") do
238
253
  @options[:with_comment] = false
239
254
  end
240
255
 
@@ -46,8 +46,6 @@ module AnnotateRb
46
46
  raise "Didn't specify a command" unless @options[:command]
47
47
 
48
48
  @options[:command].call(@options)
49
-
50
- # TODO
51
49
  end
52
50
  end
53
51
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: annotaterb
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.23.0
4
+ version: 4.24.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew W. Lee
@@ -94,6 +94,10 @@ files:
94
94
  - lib/annotate_rb/model_annotator/enum_annotation/annotation.rb
95
95
  - lib/annotate_rb/model_annotator/enum_annotation/annotation_builder.rb
96
96
  - lib/annotate_rb/model_annotator/enum_annotation/enum_component.rb
97
+ - lib/annotate_rb/model_annotator/exclusion_constraint_annotation.rb
98
+ - lib/annotate_rb/model_annotator/exclusion_constraint_annotation/annotation.rb
99
+ - lib/annotate_rb/model_annotator/exclusion_constraint_annotation/annotation_builder.rb
100
+ - lib/annotate_rb/model_annotator/exclusion_constraint_annotation/exclusion_constraint_component.rb
97
101
  - lib/annotate_rb/model_annotator/file_name_resolver.rb
98
102
  - lib/annotate_rb/model_annotator/file_parser.rb
99
103
  - lib/annotate_rb/model_annotator/file_parser/annotation_finder.rb
@@ -124,6 +128,10 @@ files:
124
128
  - lib/annotate_rb/model_annotator/single_file_annotator.rb
125
129
  - lib/annotate_rb/model_annotator/single_file_annotator_instruction.rb
126
130
  - lib/annotate_rb/model_annotator/single_file_remove_annotation_instruction.rb
131
+ - lib/annotate_rb/model_annotator/unique_constraint_annotation.rb
132
+ - lib/annotate_rb/model_annotator/unique_constraint_annotation/annotation.rb
133
+ - lib/annotate_rb/model_annotator/unique_constraint_annotation/annotation_builder.rb
134
+ - lib/annotate_rb/model_annotator/unique_constraint_annotation/unique_constraint_component.rb
127
135
  - lib/annotate_rb/model_annotator/zeitwerk_class_getter.rb
128
136
  - lib/annotate_rb/options.rb
129
137
  - lib/annotate_rb/parser.rb