Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion lib/annotate_rb/model_annotator/model_wrapper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
47 changes: 47 additions & 0 deletions spec/lib/annotate_rb/model_annotator/model_wrapper_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
9 changes: 8 additions & 1 deletion spec/support/annotate_test_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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: ""
}
Expand All @@ -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: ""
}
Expand All @@ -94,6 +100,7 @@ def mock_column(name, type, options = {})
limit: nil,
null: false,
default: nil,
default_function: nil,
sql_type: type
}

Expand Down
Loading