diff --git a/lib/annotate_rb/model_annotator/model_wrapper.rb b/lib/annotate_rb/model_annotator/model_wrapper.rb index 73ad4831..c2d2dab7 100644 --- a/lib/annotate_rb/model_annotator/model_wrapper.rb +++ b/lib/annotate_rb/model_annotator/model_wrapper.rb @@ -68,8 +68,20 @@ def has_table_comments? connection.table_comment(@klass.table_name).present? end + # Returns column defaults for annotations. + # + # `Model#column_defaults` reflects `attribute :foo, default: X` overrides, + # which would incorrectly show the Ruby-side default in annotations + # instead of the DB schema default. To preserve model-level decorations + # such as `TimeZoneConverter` on datetime columns, we start from + # `column_defaults` and only substitute the DB schema value when a + # difference indicates an attribute-level override. def column_defaults - @klass.column_defaults + @column_defaults ||= @klass.column_defaults.each_with_object({}) do |(name, value), result| + column = @klass.columns_hash[name] + schema_value = schema_default_for(column) + result[name] = (value == schema_value) ? value : schema_value + end end # Add columns managed by the globalize gem if this gem is being used. @@ -263,6 +275,26 @@ def migration_version @options.get_state(cache_key) end + + private + + def schema_default_for(column) + return nil if column.nil? || column.default.nil? || column.default_function + cast_type_for(column).deserialize(column.default) + end + + # Rails post-8.1 exposes `Column#cast_type` directly; Rails 8.1 introduced + # the transitional `Column#fetch_cast_type(connection)`; older versions + # required `connection.lookup_cast_type_from_column(column)`. + def cast_type_for(column) + if column.respond_to?(:cast_type) + column.cast_type + elsif column.respond_to?(:fetch_cast_type) + column.fetch_cast_type(connection) + else + connection.lookup_cast_type_from_column(column) + end + end end end end diff --git a/spec/lib/annotate_rb/model_annotator/model_wrapper_spec.rb b/spec/lib/annotate_rb/model_annotator/model_wrapper_spec.rb index 59c40de3..df73e1c0 100644 --- a/spec/lib/annotate_rb/model_annotator/model_wrapper_spec.rb +++ b/spec/lib/annotate_rb/model_annotator/model_wrapper_spec.rb @@ -27,6 +27,53 @@ end end + describe "#column_defaults" do + subject { described_class.new(klass, AnnotateRb::Options.new).column_defaults } + + let(:klass) do + mock_class(:users, :id, [id_column, count_column, name_column]) + end + let(:id_column) { mock_column("id", :integer) } + let(:count_column) { mock_column("count", :integer, default: 0) } + let(:name_column) { mock_column("name", :string, default: "guest") } + + it "returns defaults matching the DB schema" do + is_expected.to eq("id" => nil, "count" => 0, "name" => "guest") + end + + context "when the model overrides defaults via `attribute :foo, default: X`" do + before do + # `Model#column_defaults` is affected by attribute overrides. We detect + # the mismatch against the schema and fall back to the DB schema value. + allow(klass).to receive(:column_defaults).and_return( + "id" => nil, "count" => 999, "name" => "overridden" + ) + end + + it "returns the DB schema defaults, not the attribute overrides" do + is_expected.to eq("id" => nil, "count" => 0, "name" => "guest") + end + end + + context "when a column has a default function" do + let(:name_column) do + mock_column("name", :string, default: "gen_random_uuid()", default_function: "gen_random_uuid()") + end + + before do + # Rails would populate `column_defaults` with `nil` for default_function + # columns; simulate that so schema comparison succeeds. + allow(klass).to receive(:column_defaults).and_return( + "id" => nil, "count" => 0, "name" => nil + ) + end + + it "returns nil for that column" do + expect(subject["name"]).to be_nil + end + end + end + describe "#max_schema_info_width" do subject { described_class.new(*args).max_schema_info_width } diff --git a/spec/support/annotate_test_helpers.rb b/spec/support/annotate_test_helpers.rb index c086de01..dc896839 100644 --- a/spec/support/annotate_test_helpers.rb +++ b/spec/support/annotate_test_helpers.rb @@ -39,12 +39,16 @@ def mock_foreign_key(name, from_column, to_table, to_column = "id", constraints end def mock_connection(indexes = [], foreign_keys = [], check_constraints = [], options = {}) + identity_type = double("IdentityCastType") + allow(identity_type).to receive(:deserialize) { |v| v } + double_options = { indexes: indexes, check_constraints: check_constraints, foreign_keys: foreign_keys, supports_foreign_keys?: true, - supports_check_constraints?: true + supports_check_constraints?: true, + lookup_cast_type_from_column: identity_type }.merge(options) double("Conn", double_options) @@ -67,6 +71,7 @@ def mock_class(table_name, primary_key, columns, indexes = [], foreign_keys = [] primary_key: primary_key, column_names: columns.map { |col| col.name.to_s }, columns: columns, + columns_hash: columns.each_with_object({}) { |col, hash| hash[col.name.to_s] = col }, column_defaults: columns.map { |col| [col.name, col.default] }.to_h, table_name_prefix: "" } @@ -82,6 +87,7 @@ def mock_class_with_custom_connection(table_name, primary_key, columns, connecti primary_key: primary_key, column_names: columns.map { |col| col.name.to_s }, columns: columns, + columns_hash: columns.each_with_object({}) { |col, hash| hash[col.name.to_s] = col }, column_defaults: columns.map { |col| [col.name, col.default] }.to_h, table_name_prefix: "" } @@ -94,6 +100,7 @@ def mock_column(name, type, options = {}) limit: nil, null: false, default: nil, + default_function: nil, sql_type: type }