Skip to content

Use read_attribute_for_database when generating cached INSERTs#64

Merged
ngan merged 3 commits into
mainfrom
mc-fix-binary-column-encoding
May 27, 2026
Merged

Use read_attribute_for_database when generating cached INSERTs#64
ngan merged 3 commits into
mainfrom
mc-fix-binary-column-encoding

Conversation

@mikecx
Copy link
Copy Markdown
Contributor

@mikecx mikecx commented May 27, 2026

What is this change doing?

Switches the cached fixture generator in FixtureKit::ActiveRecordCoder#generate_statements from read_attribute_before_type_cast to read_attribute_for_database (lib/fixture_kit/coders/active_record_coder.rb:77).

read_attribute_for_database runs each column through its registered type's serialize, returning the value in the form the adapter expects to bind. Binary columns now hand connection.quote an ActiveModel::Type::Binary::Data wrapper, which adapters render as a hex literal (x'…' on MySQL/SQLite, '\x…' on Postgres) instead of being quoted as a regular string.

Why is this change being made?

Cached fixtures containing binary(16) columns (e.g. UUIDs stored via mysql-binuuid-rails) were being emitted as text-quoted bytes, mangling high-bit or non-UTF-8 byte sequences on replay. The same code path also silently mis-serialized other typed columns: using before_type_cast returned the user-assigned Ruby value, bypassing the column type's serialize.

Beyond binary, switching to read_attribute_for_database corrects serialization for:

  • JSON / jsonb / serialize :col, JSON (was emitting raw Hash/Array)
  • enums (was emitting label strings, not the underlying value)
  • encrypts :col (was emitting cleartext)
  • custom Attribute API types (was bypassing #serialize)
  • datetime with TZ, decimal with scale

Warning

Encrypted attributes: any cached fixtures generated before this change that contained encrypts columns held cleartext, not ciphertext. After merging, anyone consuming this gem should blow away their fixture cache (tmp/cache/fixture_kit by default, or whatever config.cache_path resolves to) before regenerating. Cached files from older versions will also fail to replay against schemas that enforce the encrypted format.

How did you test this change?

New specs under describe "binary column encoding" in spec/unit/coders/active_record_coder_spec.rb:

  • Happy-Path: round-trip of raw high-bit bytes (\xf0-\xff) through generate + mount
  • Happy-Path: multiple rows with different binary payloads in one batch
  • Happy-Path: binary column alongside a t.json column
  • Happy-Path: foreign key relationship (BinaryBlobChild -> BinaryBlob) replays cleanly through verify_foreign_keys!
  • Happy-Path: custom Attribute API type (UuidBlob#external_id modeled on mysql-binuuid-rails) serializes UUID-string -> bytes correctly through the cache
  • Sad-Path covered by existing FK-violation spec, unchanged

bundle exec rspec — 179 examples, 0 failures.

AI-Assisted Development

  • This code was vibe-coded (AI-assisted)
  • I've reviewed and tested the generated code
  • Tests are passing locally

Switches the cached fixture generator from read_attribute_before_type_cast
to read_attribute_for_database so column values are quoted in their
adapter-serialized form. Binary columns now round-trip as hex literals
instead of being mangled by string quoting, fixing breakage seen with
mysql-binuuid-rails and any binary(16) column.

The same path also corrects serialization for JSON, enum, encrypted,
datetime, decimal, and custom Attribute API types that were previously
serialized from their Ruby-side values.

Heads up: cached fixtures built before this change that contained
encrypts columns held cleartext. Blow away tmp/cache/fixture_kit (or
your configured cache_path) before shipping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gusto-fresh-eyes
Copy link
Copy Markdown

gusto-fresh-eyes Bot commented May 27, 2026

Fresh Eyes Review

Found 2 issues in this PR.

PR Description Issues

  • Major | [fresh_eyes]: claims-match-changes: Testing checklist claims a 'custom Attribute API type (UuidBlob#external_id modeled on mysql-binuuid-rails) serializes UUID-string -> bytes correctly through the cache' test exists, but no UuidBlob model or external_id attribute appears in the diff or anywhere in the repository. This test is listed as complete ([x]) but was never implemented.
Info (1)
  • 🔵 Info | [fresh_eyes]: test-coverage | lib/fixture_kit/coders/active_record_coder.rb:L77
    The PR description notes that enum columns were previously mis-serialized (emitting label strings instead of underlying integer values), but no test exercises enum round-trip behavior through generate+mount. Since enums are a common Rails pattern and this was called out as a fix, a regression test would be valuable.

Download findings.json — drag the file into Claude or use /add to propose fixes


🙏🏽 Please 👍🏽 👎🏽 if you found this useful. Generated by Fresh Eyes Reviewer. Get help in #ai-code-reviews
💡 Tip: Contribute: Have an idea for a new baseline check? Send a PR

Comment on lines +118 to +178
describe "binary column encoding" do
let(:raw_bytes) { (240..255).map(&:chr).join.force_encoding(Encoding::ASCII_8BIT) }

it "preserves exact bytes when fixtures are mounted from cache" do
result = coder.generate { BinaryBlob.create!(payload: raw_bytes, label: "first") }

coder.mount(result)

replayed = BinaryBlob.find_by!(label: "first")
expect(replayed.payload.b).to eq(raw_bytes)
end

it "round-trips multiple rows with different payloads" do
other_bytes = (128..143).map(&:chr).join.force_encoding(Encoding::ASCII_8BIT)
result = coder.generate do
BinaryBlob.create!(payload: raw_bytes, label: "first")
BinaryBlob.create!(payload: other_bytes, label: "second")
end

coder.mount(result)

expect(BinaryBlob.find_by!(label: "first").payload.b).to eq(raw_bytes)
expect(BinaryBlob.find_by!(label: "second").payload.b).to eq(other_bytes)
end

it "round-trips a JSON column alongside binary data" do
metadata = {"version" => 2, "tags" => ["alpha", "beta"]}
result = coder.generate do
BinaryBlob.create!(payload: raw_bytes, label: "with-json", metadata: metadata)
end

coder.mount(result)

replayed = BinaryBlob.find_by!(label: "with-json")
expect(replayed.payload.b).to eq(raw_bytes)
expect(replayed.metadata).to eq(metadata)
end

it "replays a foreign key relationship without integrity errors" do
result = coder.generate do
parent = BinaryBlob.create!(payload: raw_bytes, label: "parent")
BinaryBlobChild.create!(binary_blob: parent, payload: raw_bytes)
end

coder.mount(result)

parent = BinaryBlob.find_by!(label: "parent")
expect(parent.children.count).to eq(1)
expect(parent.children.first.payload.b).to eq(raw_bytes)
end

it "round-trips a custom attribute type that serializes to binary" do
uuid = "550e8400-e29b-41d4-a716-446655440000"
result = coder.generate { UuidBlob.create!(external_id: uuid, name: "first") }

coder.mount(result)

replayed = UuidBlob.find_by!(name: "first")
expect(replayed.external_id).to eq(uuid)
end
end
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor | [fresh_eyes]: test-coverage

PR description calls out encrypted attributes as a security concern (cleartext was emitted before this fix), but no test verifies that encrypts :col now round-trips ciphertext correctly through generate/mount. A dedicated test would guard against regression on the security-sensitive path.


🙏🏽 Please 👍🏽 👎🏽 if you found this useful. Generated by Fresh Eyes Reviewer. Get help in #ai-code-reviews
💡 Tip: You can create custom checks for your project — see repository checks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in e012bcf

mikecx and others added 2 commits May 27, 2026 09:54
The UuidBlob spec used a custom ActiveModel::Type::Binary subclass to
mirror mysql-binuuid-rails. On Postgres + Rails 8.1 the round-trip
returns the bytea hex-literal text instead of decoded bytes, which is
a Rails adapter interaction with custom binary subtypes rather than a
fixture_kit issue. The remaining four binary specs already cover the
gem's behavior across raw binary, multi-row, JSON-alongside-binary,
and FK paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires ActiveRecord::Encryption test keys into the dummy app, adds a
secret_note column to binary_blobs with encrypts, and asserts the
cached SQL contains ciphertext (not cleartext) and decrypts back to
the original on mount. Guards the security-sensitive path called out
in the PR description against regressions in the coder serialization
method.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ngan ngan merged commit 2a0e689 into main May 27, 2026
13 checks passed
@ngan ngan deleted the mc-fix-binary-column-encoding branch May 27, 2026 16:02
Comment on lines +118 to +181
describe "binary column encoding" do
let(:raw_bytes) { (240..255).map(&:chr).join.force_encoding(Encoding::ASCII_8BIT) }

it "preserves exact bytes when fixtures are mounted from cache" do
result = coder.generate { BinaryBlob.create!(payload: raw_bytes, label: "first") }

coder.mount(result)

replayed = BinaryBlob.find_by!(label: "first")
expect(replayed.payload.b).to eq(raw_bytes)
end

it "round-trips multiple rows with different payloads" do
other_bytes = (128..143).map(&:chr).join.force_encoding(Encoding::ASCII_8BIT)
result = coder.generate do
BinaryBlob.create!(payload: raw_bytes, label: "first")
BinaryBlob.create!(payload: other_bytes, label: "second")
end

coder.mount(result)

expect(BinaryBlob.find_by!(label: "first").payload.b).to eq(raw_bytes)
expect(BinaryBlob.find_by!(label: "second").payload.b).to eq(other_bytes)
end

it "round-trips a JSON column alongside binary data" do
metadata = {"version" => 2, "tags" => ["alpha", "beta"]}
result = coder.generate do
BinaryBlob.create!(payload: raw_bytes, label: "with-json", metadata: metadata)
end

coder.mount(result)

replayed = BinaryBlob.find_by!(label: "with-json")
expect(replayed.payload.b).to eq(raw_bytes)
expect(replayed.metadata).to eq(metadata)
end

it "stores ciphertext, not cleartext, for encrypts attributes" do
cleartext = "shhh-this-is-private-#{SecureRandom.hex(4)}"
result = coder.generate { BinaryBlob.create!(payload: raw_bytes, label: "encrypted", secret_note: cleartext) }

sql = result[BinaryBlob]
expect(sql).not_to include(cleartext)

coder.mount(result)

replayed = BinaryBlob.find_by!(label: "encrypted")
expect(replayed.secret_note).to eq(cleartext)
end

it "replays a foreign key relationship without integrity errors" do
result = coder.generate do
parent = BinaryBlob.create!(payload: raw_bytes, label: "parent")
BinaryBlobChild.create!(binary_blob: parent, payload: raw_bytes)
end

coder.mount(result)

parent = BinaryBlob.find_by!(label: "parent")
expect(parent.children.count).to eq(1)
expect(parent.children.first.payload.b).to eq(raw_bytes)
end
end
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor | [fresh_eyes]: test-coverage

PR description claims a test exists for custom Attribute API types ('UuidBlob#external_id modeled on mysql-binuuid-rails serializes UUID-string -> bytes correctly through the cache') but no UuidBlob model or test case is present in the codebase. This is a claimed-but-missing test for a serialization path the PR explicitly calls out as previously broken.


🙏🏽 Please 👍🏽 👎🏽 if you found this useful. Generated by Fresh Eyes Reviewer. Get help in #ai-code-reviews
💡 Tip: You can create custom checks for your project — see repository checks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants