Zenn
Closed13

【Rails】カラム追加したときにannotaterb gemがたまに動かないので調査する

hatsuhatsu

この Gemの挙動の調査である。
https://github.com/drwl/annotaterb

簡単にGemの紹介をすると、migrationしたとき model ファイルに そのテーブル情報を書き込んでくれる。
schema.rbを参照しなくてもModelを見たらテーブル情報がわかるというのが便利。

例) app/models/company.rb

# == Schema Information
#
# Table name: companies(利用会社)
#
#  id                      :uuid             not null, primary key
#  subdomain(サブドメイン) :string(255)      not null
#  created_at              :datetime         not null
#  updated_at              :datetime         not null
#
# Indexes
#
#  index_companies_on_subdomain  (subdomain) UNIQUE
#
class Company < ApplicationRecord
  has_one :company_profile, dependent: :restrict_with_error
end

前提

berbundle exec rails のエイリアスである。

alias ber='bundle exec rails'

各種バージョンはこんな感じ。

❯ ruby -v
ruby 3.3.6 (2024-11-05 revision 75015d4c1f) [arm64-darwin24]

❯ ber -v        
Rails 7.2.2

❯ be annotaterb -v                                                                                      
AnnotateRb v4.13.0
hatsuhatsu

Annotaterbが動いている例

Postモデルを用意

ber g model Post

migrationファイルは以下のようにした

class CreatePosts < ActiveRecord::Migration[7.2]
  def change
    create_table :posts, id: :uuid do |t|
      t.string :title, null: false, comment: "タイトル"
      t.text :content, null: false, comment: "内容"

      t.timestamps
    end
  end
end

実行する

❯ ber db:migrate  
== 20241201061948 AddMemoToEmployeeSurveySubmissions: migrating ===============
-- add_column(:employee_survey_submissions, :memo, :text, {:comment=>"メモ"})
   -> 0.0033s
== 20241201061948 AddMemoToEmployeeSurveySubmissions: migrated (0.0033s) ======

== 20241201062814 CreatePosts: migrating ======================================
-- create_table(:posts, {:id=>:uuid})
   -> 0.0120s
== 20241201062814 CreatePosts: migrated (0.0120s) =============================

Annotating models
Annotated (3): app/models/post.rb, spec/models/post_spec.rb, spec/factories/posts.rb

動いている。

生成されたPostモデル

# == Schema Information
#
# Table name: posts
#
#  id              :uuid             not null, primary key
#  title(タイトル) :string           not null
#  content(内容)   :text             not null
#  created_at      :datetime         not null
#  updated_at      :datetime         not null
#
class Post < ApplicationRecord
end
hatsuhatsu

動いていない例

先のPostモデルが生成されている状態で以下のようなmigrationファイルを実行してみる

class AddMemoToPost < ActiveRecord::Migration[7.2]
  def change
    add_column :posts, :memo, :text, comment: "メモ"
  end
end

postsテーブルに、memoカラムを挿入する。

実行結果

❯ ber db:migrate
== 20241201063248 AddMemoToPost: migrating ====================================
-- add_column(:posts, :memo, :text, {:comment=>"メモ"})
   -> 0.0159s
== 20241201063248 AddMemoToPost: migrated (0.0161s) ===========================

Annotating models
Model files unchanged.

Model files unchanged. になっている。

生成されたPostモデル

# == Schema Information
#
# Table name: posts
#
#  id              :uuid             not null, primary key
#  title(タイトル) :string           not null
#  content(内容)   :text             not null
#  created_at      :datetime         not null
#  updated_at      :datetime         not null
#
class Post < ApplicationRecord
end

→ memoカラムがない。

hatsuhatsu

annotaterb gemの動作の調査

migrateしたときに、annotaterbのgemで何が起きているか?

lib/annotate_rb/run.rb が ある。名前的にこれが動いていそうだ。

# frozen_string_literal: true

module AnnotateRb
  class Runner
    class << self
      def run(args)
        new.run(args)
      end
    end

    def run(args)
      # 中略

      if @options[:command]
        @options[:command].call(@options)
      else
        # TODO
        raise "Didn't specify a command"
      end
    end
  end
end

命名的には 具体的な実行の中身は @options[:command].call(@options) が何かやっているのだろう。

@optionsとは何か、@options[:command] とは何か、をデバッグしてみる。

@options is 何?

予想的には、.annotaterb.yml が クラスになったものだろう。
デバッグしてみる。

      # デバッグ
      pp "===@options==="
      pp @options
      pp "~~~@options[:command]~~~"
      pp @options[:command]

      if @options[:command]
        @options[:command].call(@options)
      else
        # TODO
        raise "Didn't specify a command"
      end

この状態で ber db:migrateを実行すると以下が出力された。

"===@options==="
#<AnnotateRb::Options:0x0000000138574728
 @options=
  {:position=>"before",
   :position_in_additional_file_patterns=>"before",
   :position_in_class=>"before",
   :position_in_factory=>"before",
   :position_in_fixture=>"before",
   :position_in_routes=>"before",
   :position_in_serializer=>"before",
   :position_in_test=>"before",
   :classified_sort=>false,
   :exclude_controllers=>true,
   :exclude_factories=>false,
   :exclude_fixtures=>false,
   :exclude_helpers=>true,
   :exclude_scaffolds=>true,
   :exclude_serializers=>false,
   :exclude_sti_subclasses=>false,
   :exclude_tests=>false,
   :force=>false,
   :format_markdown=>false,
   :format_rdoc=>false,
   :format_yard=>false,
   :frozen=>false,
   :ignore_model_sub_dir=>false,
   :ignore_unknown_models=>false,
   :include_version=>false,
   :show_check_constraints=>false,
   :show_complete_foreign_keys=>false,
   :show_foreign_keys=>true,
   :show_indexes=>true,
   :simple_indexes=>false,
   :sort=>false,
   :timestamp=>false,
   :trace=>false,
   :with_comment=>true,
   :with_column_comments=>true,
   :with_table_comments=>true,
   :active_admin=>false,
   :command=>#<AnnotateRb::Commands::AnnotateModels:0x0000000138576f28>,
   :debug=>false,
   :hide_default_column_types=>"",
   :hide_limit_column_types=>"",
   :ignore_columns=>nil,
   :ignore_routes=>nil,
   :models=>true,
   :routes=>false,
   :skip_on_db_migrate=>false,
   :target_action=>:do_annotations,
   :wrapper=>nil,
   :wrapper_close=>nil,
   :wrapper_open=>nil,
   :classes_default_to_s=>[],
   :additional_file_patterns=>[],
   :model_dir=>["app/models"],
   :require=>[],
   :root_dir=>[""],
   :exit=>false,
   :original_args=>["models"]},
 @state={:working_args=>[]}>

"~~~@options[:command]~~~"
#<AnnotateRb::Commands::AnnotateModels:0x0000000138576f28>

@optionsはやはり .anotaterb.yml が AnnotateRb::Options Classになったものだ。

@options[:command]AnnotateRb::Commands::AnnotateModels Class のことのようだ。

hatsuhatsu

先ほど ber db:migrate を動かしたときに、

  1. lib/annotate_rb/run.rb が動いてそのファイルで AnnotateRb::Commands::AnnotateModelsが 呼び出されている

ところまでわかった。

AnnotateRb::Commands::AnnotateModelsを見てみる

lib/annotate_rb/commands/annotate_models.rb のファイル。

# frozen_string_literal: true

module AnnotateRb
  module Commands
    class AnnotateModels
      def call(options)
        puts "Annotating models"

        if options[:debug]
          puts "Running with debug mode, options:"
          pp options.to_h
        end

        # Eager load Models when we're annotating models
        AnnotateRb::EagerLoader.call(options)

        AnnotateRb::ModelAnnotator::Annotator.send(options[:target_action], options)
      end
    end
  end
end

重要そうなのは、最後の AnnotateRb::ModelAnnotator::Annotator.send(options[:target_action], options) だろう。
options[:target] は 先ほど @options の デバッグをしたときに含まれているキーだ。

:target_action=>:do_annotations,

この値が、 AnnotateRb::ModelAnnotator::Annotator に渡されているらしい。
sendで呼ばれているので、
AnnotateRb::ModelAnnotator::Annotator.new(options).do_annotations というコードが実行されている。

hatsuhatsu

先ほど ber db:migrate を動かしたときに、

  1. lib/annotate_rb/run.rb が動いてそのファイルで
  2. AnnotateRb::Commands::AnnotateModelsが 呼び出され
  3. AnnotateRb::ModelAnnotator::Annotator.new(options).do_annotationsが実行されている

ところまでわかった。

AnnotateRb::ModelAnnotator::Annotator の中身を見てみる

# frozen_string_literal: true

module AnnotateRb
  module ModelAnnotator
    class Annotator
      class << self
        def do_annotations(options)
          new(options).do_annotations
        end

        def remove_annotations(options)
          new(options).remove_annotations
        end
      end

      def initialize(options)
        @options = options
      end

      def do_annotations
        ProjectAnnotator.new(@options).annotate
      end

      def remove_annotations
        ProjectAnnotationRemover.new(@options).remove_annotations
      end
    end
  end
end

do_annotationsメソッドがある。これが呼ばれていると言うことだ。

つまりProjectAnnotator.new(@options).annotate が動いている。

hatsuhatsu

先ほど ber db:migrate を動かしたときに、

  1. lib/annotate_rb/run.rb が動いてそのファイルで
  2. AnnotateRb::Commands::AnnotateModelsが 呼び出され
  3. AnnotateRb::ModelAnnotator::Annotator.new(options).do_annotationsが実行され
  4. ProjectAnnotator.new(@options).annotate が動いている

ところまでわかった。

ProjectAnnotatorは lib/annotate_rb/model_annotator/project_annotator.rb で定義されている。

AnnotateRb::ModelAnnotator::ProjectAnnotator の中身を見てみる

# frozen_string_literal: true

module AnnotateRb
  module ModelAnnotator
    class ProjectAnnotator
      def initialize(options)
        @options = options
      end

      def annotate
        project_model_files = model_files

        annotation_instructions = project_model_files.map do |path, filename|
          file = File.join(path, filename)

          if AnnotationDecider.new(file, @options).annotate?
            _instructions = build_instructions_for_file(file)
          end
        end.flatten.compact

        annotated = annotation_instructions.map do |instruction|
          if SingleFileAnnotator.call_with_instructions(instruction)
            instruction.file
          end
        end.compact

        if annotated.empty?
          puts "Model files unchanged."
        else
          puts "Annotated (#{annotated.length}): #{annotated.join(", ")}"
        end
      end
      # 中略
end

annotateメソッドがある。ProjectAnnotator.new(@options).annotateで動かされているメソッドだ。
puts "Model files unchanged." もある。「Model files unchanged.」 は見覚えがある。

始めの方のmigrate実行結果を貼ったときに出てきたコメントだ。↓これ。

❯ ber db:migrate
== 20241201063248 AddMemoToPost: migrating ====================================
-- add_column(:posts, :memo, :text, {:comment=>"メモ"})
   -> 0.0159s
== 20241201063248 AddMemoToPost: migrated (0.0161s) ===========================

Annotating models
Model files unchanged.

「Model files unchanged.」コメントは以下のコードを見ると

        if annotated.empty?
          puts "Model files unchanged."
        else
          puts "Annotated (#{annotated.length}): #{annotated.join(", ")}"
        end

annotated 変数が empty? だと Model files unchanged. と出力されるようだ。

hatsuhatsu

先ほど ber db:migrate を動かしたときに、

  1. lib/annotate_rb/run.rb が動いてそのファイルで
  2. AnnotateRb::Commands::AnnotateModelsが 呼び出され
  3. AnnotateRb::ModelAnnotator::Annotator.new(options).do_annotationsが実行され
  4. ProjectAnnotator.new(@options).annotate が動いている
  5. テーブルに変更あるのに ^ の中で 定義されている annotated変数がemptyになるのは怪しい

ところまでわかった

annotated変数がemptyになる理由を探りたい

テーブルに変更があるのに、annotated 変数がempty?になっているのは期待通りではない。
annotated変数がempty?になっている理由を探っていく。

annotated 変数の定義は2ステップある。

        annotation_instructions = project_model_files.map do |path, filename|
          file = File.join(path, filename)

          if AnnotationDecider.new(file, @options).annotate?
            _instructions = build_instructions_for_file(file)
          end
        end.flatten.compact
        annotated = annotation_instructions.map do |instruction|
          if SingleFileAnnotator.call_with_instructions(instruction)
            instruction.file
          end
        end.compact

の2つ。

どっちのステップがおかしくて、annotatedがemptyになるのか調べる。

annotation_instructions is 何?

まずは、Step2の最初に呼ばれている変数 annotation_instructions が空配列か見てみよう。
これが空配列ならば、Step1の定義がおかしい。空配列でないならば、Step2の定義が怪しいかもしれない。
annotation_instructions を出力してみる。

pp annotation_instructions

たくさん出力されたので一部の情報だけ抜粋して記載しておく。

 #<AnnotateRb::ModelAnnotator::SingleFileAnnotatorInstruction:0x000000012cf5f9b0
  @annotation=
   "# == Schema Information\n" +
   "#\n" +
   "# Table name: versions(変更履歴)\n" +
   "#\n" +
   "#  id                            :uuid             not null, primary key\n" +
   "#  item_type(変更したモデル)     :string           not null\n" +
   "#  item_id(変更したレコードのID) :string           not null\n" +
   "#  event(変更の種類)             :string           not null\n" +
   "#  whodunnit(変更したユーザー)   :jsonb\n" +
   "#  object(変更前のデータ)        :jsonb\n" +
   "#  object_changes(変更内容)      :jsonb\n" +
   "#  request_info(リクエスト情報)  :jsonb\n" +
   "#  created_at                    :datetime         not null\n" +
   "#  updated_at                    :datetime         not null\n" +
   "#\n" +
   "# Indexes\n" +
   "#\n" +
   "#  index_versions_on_item_type_and_item_id  (item_type,item_id)\n" +
   "#\n",
  @file="spec/factories/versions.rb",
  @options=
   #<AnnotateRb::Options:0x000000012aa57cf8
    @options=
     {:position=>"before",
      :position_in_additional_file_patterns=>"before",
      :position_in_class=>"before",
      :position_in_factory=>"before",
      :position_in_fixture=>"before",
      :position_in_routes=>"before",
      :position_in_serializer=>"before",
      :position_in_test=>"before",
      :classified_sort=>false,
      :exclude_controllers=>true,
      :exclude_factories=>false,
      :exclude_fixtures=>false,
      :exclude_helpers=>true,
      :exclude_scaffolds=>true,
      :exclude_serializers=>false,
      :exclude_sti_subclasses=>false,
      :exclude_tests=>false,
      :force=>false,
      :format_markdown=>false,
      :format_rdoc=>false,
      :format_yard=>false,
      :frozen=>false,
      :ignore_model_sub_dir=>false,
      :ignore_unknown_models=>false,
      :include_version=>false,
      :show_check_constraints=>false,
      :show_complete_foreign_keys=>false,
      :show_foreign_keys=>true,
      :show_indexes=>true,
      :simple_indexes=>false,
      :sort=>false,
      :timestamp=>false,
      :trace=>false,
      :with_comment=>true,
      :with_column_comments=>true,
      :with_table_comments=>true,
      :active_admin=>false,
      :command=>#<AnnotateRb::Commands::AnnotateModels:0x000000012aa58d60>,
      :debug=>false,
      :hide_default_column_types=>"",
      :hide_limit_column_types=>"",
      :ignore_columns=>nil,
      :ignore_routes=>nil,
      :models=>true,
      :routes=>false,
      :skip_on_db_migrate=>false,
      :target_action=>:do_annotations,
      :wrapper=>nil,
      :wrapper_close=>nil,
      :wrapper_open=>nil,
      :classes_default_to_s=>[],
      :additional_file_patterns=>[],
      :model_dir=>["app/models"],
      :require=>[],
      :root_dir=>[""],
      :exit=>false,
      :original_args=>["models"]},
    @state={:working_args=>[], :current_version=>20241201062814}>,
  @position=:position_in_factory>]

annotation_instructionsには全モデルの情報が詰め込まれていた。
そしてデバッグ結果を見るとAnnotateRb::ModelAnnotator::SingleFileAnnotatorInstruction Class@annotation, @options, @position というインスタンス変数があってそれらが出力されていたことがわかる。

つまりannotation_instructionsが空配列でないことが分かった。

annotation_instructions から annotatedを定義するときに、annotated がemptyになっていると言うことだ。要はこのStep2が怪しい、と。

        annotated = annotation_instructions.map do |instruction|
          if SingleFileAnnotator.call_with_instructions(instruction)
            instruction.file
          end
        end.compact

このStepでは

  1. annotation_instructions の配列から
  2. SingleFileAnnotator.call_with_instructions(instruction)がTrueになるものを取り出し
  3. instruction.fileの結果を返した配列を作っている

ってことは SingleFileAnnotator.call_with_instructions(instruction) が全部 False になっていて annotation_instructions から annotated 変数を作るときに 空配列になっているのか...?

hatsuhatsu

先ほど ber db:migrate を動かしたときに、

  1. lib/annotate_rb/run.rb が動いてそのファイルで
  2. AnnotateRb::Commands::AnnotateModelsが 呼び出され
  3. AnnotateRb::ModelAnnotator::Annotator.new(options).do_annotationsが実行され
  4. ProjectAnnotator.new(@options).annotate が動いている
  5. テーブルに変更あるのに ^ の中で 定義されている annotated変数がemptyになるのは怪しい
  6. annotated変数がemptyになっているのは SingleFileAnnotator.call_with_instructions(instruction) が全て Falseになるためでは?

というところまでわかった

SingleFileAnnotator.call_with_instructions(instruction) の中身を見ていく

lib/annotate_rb/model_annotator/single_file_annotator.rbのファイルで定義されている。
中身はこんな感じだ。

module AnnotateRb
  module ModelAnnotator
    class SingleFileAnnotator
      class << self
        def call_with_instructions(instruction)
          call(instruction.file, instruction.annotation, instruction.position, instruction.options)
        end
        # 中略
        def call(file_name, annotation, annotation_position, options)
        # 中略
        end
end

call_with_instructionsはcallメソッドを呼んでいるようだ。

call メソッドの説明として、コード内に以下のようなコメントが書かれていた

        # Add a schema block to a file. If the file already contains
        # a schema info block (a comment starting with "== Schema Information"),
        # check if it matches the block that is already there. If so, leave it be.
        # If not, remove the old info block and write a new one.
        #
        # == Returns:
        # true or false depending on whether the file was modified.
        #
        # === Options (opts)
        #  :force<Symbol>:: whether to update the file even if it doesn't seem to need it.
        #  :position_in_*<Symbol>:: where to place the annotated section in fixture or model file,
        #                           :before, :top, :after or :bottom. Default is :before.
        #

日本語に訳すと以下のような感じだ(DeepL訳)

スキーマ・ブロックをファイルに追加する。
ファイルにすでにスキーマ情報ブロック(「==スキーマ情報 」で始まるコメント)がある場合は、すでにあるブロックと一致するかどうかをチェックする。
一致する場合はそのままにしておく。そうでない場合は、古い情報ブロックを削除し、新しい情報ブロックを書き込む。

返り値:
  ファイルが変更されたかどうかによって # trueかfalseを返します。

オプション (opts): 
  :force<Symbol>: ファイルを更新する必要がないように見えても更新するかどうか。
  :position_in_*<Symbol>: フィクスチャやモデルファイルのどこにアノテーションセクションを配置するか、before、:top:after:bottom のいずれかです。デフォルトは :before です。

^ 要は、ここでファイルにスキーマ情報の書き込みをしているらしい。

  • 変更があったら、新しいスキーマ情報を書き込む
  • そして、ファイルが変更されたかどうかをTrue/Falseで返す

と書いてある。
元々の課題は
ber db:migrateしたときにテーブル情報に変更があるのに、annotaterbでファイル書き込みがされないことである。
この原因は、「変更があったら、新しいスキーマ情報を書き込む」 の「変更があったら」が正しく検知されていないのだろう。

hatsuhatsu

先ほど ber db:migrate を動かしたときに、

  1. lib/annotate_rb/run.rb が動いてそのファイルで
  2. AnnotateRb::Commands::AnnotateModelsが 呼び出され
  3. AnnotateRb::ModelAnnotator::Annotator.new(options).do_annotationsが実行され
  4. ProjectAnnotator.new(@options).annotate が動いている
  5. テーブルに変更あるのに ^ の中で 定義されている annotated変数がemptyになるのは怪しい
  6. annotated変数がemptyになっているのは SingleFileAnnotator.call_with_instructions(instruction) が全て Falseになるため
  7. SingleFileAnnotator.call_with_instructions(instruction) では 記載されているテーブル情報 の変更があったら追記するメソッドだが、「変更があったら」がうまく検知されていなそう

というところまでわかった。

変更の検知はどのように行われている?callメソッドを見る

SingleFileAnnotator.call_with_instructions(instruction)callメソッドを見てみると

def call(file_name, annotation, annotation_position, options)
  # 中略
  parsed_file = FileParser::ParsedFile.new(old_content, annotation, parser_klass, options).parse
  # 中略
  return false if parsed_file.has_skip_string?
  return false if !parsed_file.annotations_changed? && !options[:force]
  # 中略
end

とある。

つまり

  • parsed_file.has_skip_string? がTrueの時にcallはFalseを返す
  • parsed_file.annotations_changed?がFalse かつ options[:force] が Falseのとき callはFalseを返す

options[:force] は READMEには「変更がなくても強制的に注釈を記載し直す」のように書いてある。
何はともあれこの辺りをデバッグしてみよう。

def call(file_name, annotation, annotation_position, options)
  # 中略
  parsed_file = FileParser::ParsedFile.new(old_content, annotation, parser_klass, options).parse
  # 中略
  if file_name == "app/models/post.rb"
    pp "="*30
    puts "===parsed_file.has_skip_string?: #{parsed_file.has_skip_string?}==="
    puts "~~~parsed_file.annotations_changed?: #{parsed_file.annotations_changed?}~~~"
  end
  return false if parsed_file.has_skip_string?
  return false if !parsed_file.annotations_changed? && !options[:force]
  # 中略
end

すると以下のようになった。

❯ ber db:migrate
== 20241201063248 AddMemoToPost: migrating ====================================
-- add_column(:posts, :memo, :text, {:comment=>"メモ"})
   -> 0.0030s
== 20241201063248 AddMemoToPost: migrated (0.0031s) ===========================

Annotating models
"=============================="
===parsed_file.has_skip_string?: false===
~~~parsed_file.annotations_changed?: false~~~
Model files unchanged.

parsed_file.has_skip_string? の結果は False。
parsed_file.annotations_changed? の結果もFalse。
memoカラムを追加しているので、parsed_file.annotations_changed?がFalseになっているのは期待通りではない。
parsed_file.annotations_changed?がおかしそうだ。

hatsuhatsu

先ほど ber db:migrate を動かしたときに、

  1. lib/annotate_rb/run.rb が動いてそのファイルで
  2. AnnotateRb::Commands::AnnotateModelsが 呼び出され
  3. AnnotateRb::ModelAnnotator::Annotator.new(options).do_annotationsが実行され
  4. ProjectAnnotator.new(@options).annotate が動いている
  5. テーブルに変更あるのに ^ の中で 定義されている annotated変数がemptyになるのは怪しい
  6. annotated変数がemptyになっているのは SingleFileAnnotator.call_with_instructions(instruction) が全て Falseになるため
  7. SingleFileAnnotator.call_with_instructions(instruction) では 記載されているテーブル情報 の変更があったら追記するメソッドだが、「変更があったら」がうまく検知されてい
  8. より具体的に言うと parsed_file.annotations_changed?falseを返しているのが期待通りではない

ところまでわかった。

parsed_file.annotations_changed?を調査

parsed_fileは以下のように定義されている。

          begin
            parsed_file = FileParser::ParsedFile.new(old_content, annotation, parser_klass, options).parse
          rescue FileParser::AnnotationFinder::MalformedAnnotation => e
            warn "Unable to process #{file_name}: #{e.message}"
            warn "\t" + e.backtrace.join("\n\t") if @options[:trace]
            return false
          end

つまり FileParser::ParsedFile Classです。FileParser::ParsedFileをnewしてparseメソッドを呼んだ返り値がparsed_fileである。

FileParser::ParsedFileを調べる

中身は以下のようになっていた。

# frozen_string_literal: true

module AnnotateRb
  module ModelAnnotator
    module FileParser
      class ParsedFile
        # 中略
        def parse
           # 中略
            _result = ParsedFileResult.new(
            has_annotations: has_annotations,
            has_skip_string: has_skip_string,
            annotations_changed: annotations_changed,
            annotations: annotations,
            annotations_with_whitespace: annotations_with_whitespace,
            has_leading_whitespace: has_leading_whitespace,
            has_trailing_whitespace: has_trailing_whitespace,
            annotation_position: annotation_position,
            starts: @file_parser.starts,
            ends: @file_parser.ends
          )
        end
      end
    end
  end
end

annotations_changed がある。
annotations_changedがFalseだと、parsed_file.annotations_changed?ももちろんFalseだろう。

annotations_changedの定義を見てみよう。

          @diff = AnnotationDiffGenerator.new(annotations, @new_annotations).generate

          has_skip_string = @file_parser.comments.any? { |comment, _lineno| comment.include?(SKIP_ANNOTATION_STRING) }
          annotations_changed = @diff.changed?

AnnotationDiffGenerator Classのインスタンスから定義されている。

hatsuhatsu

先ほど ber db:migrate を動かしたときに、

  1. lib/annotate_rb/run.rb が動いてそのファイルで
  2. AnnotateRb::Commands::AnnotateModelsが 呼び出され
  3. AnnotateRb::ModelAnnotator::Annotator.new(options).do_annotationsが実行され
  4. ProjectAnnotator.new(@options).annotate が動いている
  5. テーブルに変更あるのに ^ の中で 定義されている annotated変数がemptyになるのは怪しい
  6. annotated変数がemptyになっているのは SingleFileAnnotator.call_with_instructions(instruction) が全て Falseになるため
  7. SingleFileAnnotator.call_with_instructions(instruction) では 記載されているテーブル情報 の変更があったら追記するメソッドだが、「変更があったら」がうまく検知されていない
  8. より具体的に言うと parsed_file.annotations_changed?falseを返しているのが期待通りではない
  9. それはAnnotationDiffGeneratorクラスの インスタンスで 定義された @diff.changed?の結果かが期待通りでない

ところまでわかった。

AnnotateRb::ModelAnnotator::AnnotationDiffGenerator の中身を見てみる

@diff = AnnotationDiffGenerator.new(annotations, @new_annotations).generate
@diff.changed?

で何が起きているか調べていきたい。AnnotationDiffGeneratorのコードは以下だ。

# frozen_string_literal: true

module AnnotateRb
  module ModelAnnotator
    # Compares the current file content and new annotation block and generates the column annotation differences
    class AnnotationDiffGenerator
      def generate
        # 中略
        _result = AnnotationDiff.new(current_annotation_columns, new_annotation_columns)
      end
    end
  end
end

AnnotationDiffGeneratorが返しているのは _result = AnnotationDiff.new(current_annotation_columns, new_annotation_columns)である。

AnnotationDiffクラスは以下のように定義されている。

# frozen_string_literal: true

module AnnotateRb
  module ModelAnnotator
    # Plain old Ruby object for holding the differences
    class AnnotationDiff
      attr_reader :current_columns, :new_columns

      def initialize(current_columns, new_columns)
        @current_columns = current_columns.dup.freeze
        @new_columns = new_columns.dup.freeze
      end

      def changed?
        @changed ||= @current_columns != @new_columns
      end
    end
  end
end

changed?の定義は @current_columns != @new_columns である。
これは、initializeの二つの引数def initialize(current_columns, new_columns) が一緒か異なるかを管理している。

current_columnsnew_columnsの定義はどのようになっているかというと、以下に書かれている current_annotation_columnsnew_annotation_columnsのことである

      def generate
        # Ignore the Schema version line because it changes with each migration
        current_annotations = @file_content.match(HEADER_PATTERN).to_s
        new_annotations = @annotation_block.match(HEADER_PATTERN).to_s

        current_annotation_columns = if current_annotations.present?
          current_annotations.scan(COLUMN_PATTERN).sort
        else
          []
        end

        new_annotation_columns = if new_annotations.present?
          new_annotations.scan(COLUMN_PATTERN).sort
        else
          []
        end

        _result = AnnotationDiff.new(current_annotation_columns, new_annotation_columns)
      end

具体的に言うとcurrent_annotation_columnsは以下で

        current_annotation_columns = if current_annotations.present?
          current_annotations.scan(COLUMN_PATTERN).sort
        else
          []
        end

new_annotation_columnsは以下である。

        new_annotation_columns = if new_annotations.present?
          new_annotations.scan(COLUMN_PATTERN).sort
        else
          []
        end

それぞれをデバッグして出力してみる。

❯ ber db:migrate
== 20241201063248 AddMemoToPost: migrating ====================================
-- add_column(:posts, :memo, :text, {:comment=>"メモ"})
   -> 0.0043s
== 20241201063248 AddMemoToPost: migrated (0.0044s) ===========================

Annotating models
"------current_annotation_columns------"
["#  created_at      :datetime         not null", "#  id              :uuid             not null, primary key", "#  updated_at      :datetime         not null", "# Table name: posts"]

"++++++new_annotation_columns++++++"
["#  created_at      :datetime         not null", "#  id              :uuid             not null, primary key", "#  updated_at      :datetime         not null", "# Table name: posts"]
Model files unchanged.

current_annotation_columnsnew_annotation_columnsは同じ値を出力している。
new_annotation_columnsmemo カラムの情報がないのがおかしそうだ。

current_annotation_columnsnew_annotation_columnsnew_annotationscurrent_annotationsから作られている変数だ。

new_annotationscurrent_annotations はどんな値になっているかデバッグしてみる。

❯ ber db:migrate
== 20241201063248 AddMemoToPost: migrating ====================================
-- add_column(:posts, :memo, :text, {:comment=>"メモ"})
   -> 0.0032s
== 20241201063248 AddMemoToPost: migrated (0.0032s) ===========================

Annotating models
"== current_annotations: # Table name: posts\n" +
"#\n" +
"#  id              :uuid             not null, primary key\n" +
"#  title(タイトル) :string           not null\n" +
"#  content(内容)   :text             not null\n" +
"#  created_at      :datetime         not null\n" +
"#  updated_at      :datetime         not null\n" +
"#\n"

"== new_annotations: # Table name: posts\n" +
"#\n" +
"#  id              :uuid             not null, primary key\n" +
"#  title(タイトル) :string           not null\n" +
"#  content(内容)   :text             not null\n" +
"#  created_at      :datetime         not null\n" +
"#  updated_at      :datetime         not null\n" +
"#  memo(メモ)      :text\n" +
"#\n"
Model files unchanged.

new_annotationsにはmemoカラムがある。

よって以下のnew_annotationsにはmemoカラムがあるが、new_annotation_columnsの時点ではmemoカラムがなくなっているとわかる。

        new_annotation_columns = if new_annotations.present?
          new_annotations.scan(COLUMN_PATTERN).sort
        else
          []
        end

つまりnew_annotations.scan(COLUMN_PATTERN) がおかしい、と。

new_annotations.scan(COLUMN_PATTERN).sortみる

COLUMN_PATTERN

COLUMN_PATTERN = /^#[\t ]+[\w*.`\[\]():]+[\t ]+.+$/

と定義されている。
実際に正規表現を試してみると、以下のようなことが分かった。リンク
正規表現

つまり、日本語のコメントがあるとうまく動かないようだ!!

hatsuhatsu

先ほど ber db:migrate を動かしたときに、

  1. lib/annotate_rb/run.rb が動いてそのファイルで
  2. AnnotateRb::Commands::AnnotateModelsが 呼び出され
  3. AnnotateRb::ModelAnnotator::Annotator.new(options).do_annotationsが実行され
  4. ProjectAnnotator.new(@options).annotate が動いている
  5. テーブルに変更あるのに ^ の中で 定義されている annotated変数がemptyになるのは怪しい
  6. annotated変数がemptyになっているのは SingleFileAnnotator.call_with_instructions(instruction) が全て Falseになるため
  7. SingleFileAnnotator.call_with_instructions(instruction) では 記載されているテーブル情報 の変更があったら追記するメソッドだが、「変更があったら」がうまく検知されていない
  8. より具体的に言うと parsed_file.annotations_changed?falseを返しているのが期待通りではない
  9. それはAnnotationDiffGeneratorクラスの インスタンスで 定義された @diff.changed?の結果かが期待通りでない
  10. @diff.changed?の結果かが期待通りでないのは、正規表現で日本語を考慮されていないのでは!!?

と言うところまでわかった。

日本語が含まれているときに正規表現が正しく動かない!!?を検証

調べたいコードは以下だ。

# frozen_string_literal: true

module AnnotateRb
  module ModelAnnotator
    # Compares the current file content and new annotation block and generates the column annotation differences
    class AnnotationDiffGenerator
        # 中略
        COLUMN_PATTERN = /^#[\t ]+[\w*.`\[\]():]+[\t ]+.+$/
         # 中略
        new_annotation_columns = if new_annotations.present?
          new_annotations.scan(COLUMN_PATTERN).sort
        else
          []
        end
# 中略
end

もっと抜粋すると以下を調べたい。

new_annotations.scan(COLUMN_PATTERN).sort

ちなみに、この時点での new_annotationsは以下である。

# Table name: posts\n +
#\n +
#  id              :uuid             not null, primary key\n +
#  title(タイトル) :string           not null\n +
#  content(内容)   :text             not null\n +
#  created_at      :datetime         not null\n +
#  updated_at      :datetime         not null\n +
#  memo(メモ)      :text\n +
#\n

例えばカラムのコメントを日本語で書いているが、英語にしてみると動くか検証してみよう。

class AddMemoToPost < ActiveRecord::Migration[7.2]
  def change
-    add_column :posts, :memo, :text, comment: "メモ"
+    add_column :posts, :memo, :text, comment: "memo"
  end
end

comment: "メモ" -> comment: "memo" にしてmigrateしてみる

実行結果は以下だ。

❯ ber db:migrate
== 20241201063248 AddMemoToPost: migrating ====================================
-- add_column(:posts, :memo, :text, {:comment=>"memo"})
   -> 0.0039s
== 20241201063248 AddMemoToPost: migrated (0.0039s) ===========================

Annotating models
Annotated (3): app/models/post.rb, spec/models/post_spec.rb, spec/factories/posts.rb

動いている!!

やはり、正規表現で日本語が含まれていると動かないようだ。

COLUMN_PATTERN = /^#[\t ]+[\w*.`\[\]():]+[\t ]+.+$/

この正規表現を\w だけでなくさまざまなUnicodeに対応したものに変えられたら動くのかもしれない。

このスクラップは3ヶ月前にクローズされました
ログインするとコメントできます