Skip to content

Let the drift guard check column types, opt-in - #34

Open
VSN2015 wants to merge 1 commit into
masterfrom
feature/drift-guard-checks-types
Open

Let the drift guard check column types, opt-in#34
VSN2015 wants to merge 1 commit into
masterfrom
feature/drift-guard-checks-types

Conversation

@VSN2015

@VSN2015 VSN2015 commented Sep 5, 2026

Copy link
Copy Markdown
Owner

An enhancement, resuming feature work. It's the one I flagged last tick as the best remaining candidate.

The gap

The drift guard's premise is that a schema change which invalidates a contract should fail the deploy, not the request. A column dropped by a migration does. A column retyped by one did not — so a contract could go on declaring :datetime long after the column became a string.

The addition

# config/initializers/permittable.rb
Permittable.check_column_types = true
Permittable: 'placed_at' is declared :string but the column is :datetime (table: orders).
Change the contract to match the column, migrate the column to match the contract,
or declare the field virtual: true if it is not backed by this column.
  contract matches the schema                          loads
  someone migrated placed_at to a string               'placed_at' is declared :string but the column is :datetime
  :date on a datetime column (narrowing)               loads
  same mismatch, with the check off                    loads

The decision I'd most like reviewed: it's off by default

The existing guard is aggressive by default, and I nearly matched that. I didn't, because every cross-type declaration has some legitimate use:

  • a :string contract on a date column, letting ActiveRecord do the casting
  • a :boolean contract on a legacy integer 0/1 column
  • a :string contract on a json column that the app serialises itself

A missing column is unambiguous, so failing the deploy for it is fair. A differing type is not, and breaking those apps on a patch upgrade would cost more than the drift it catches. Off by default, turn it on, fix what it finds.

If you'd rather it were on by default for 1.0, that's a one-line change to the reader — I'd just want it to be a deliberate major-version decision rather than a side effect of this PR.

Groups, not exact types

That's the other thing keeping false positives down:

Group Column types
text string, text, citext, uuid, enum, char
numeric integer, bigint, float, decimal, boolean
temporal date, datetime, time, timestamp, timestamptz

boolean sits with the numerics on purpose — a boolean stored as integer 0/1 is a real legacy pattern and AR casts cleanly. The temporal types are one group because :date on a datetime column is a narrowing, not drift.

Any column type not in that table is never checkedjson, jsonb, binary, an adapter's own inet or money. The gem has no faithful contract type for those, so whatever an app improvised is left alone rather than guessed about. (I verified against ActiveRecord's own normalisation: it reports bigint columns as :integer, so the map keys on what AR actually returns, not on SQL types.)

Also

guard_contract_columns! now appends the virtual: true hint only to the missing-column error — the type error carries its own three-way guidance, and "declare it virtual" was odd advice for a type mismatch.

Verification

  • 208 examples, 0 failures (9 new, written before the implementation), including the off-by-default case, both legitimate-mix cases, the never-checked column types, and virtual:/missing-column still behaving
  • RuboCop clean

@VSN2015 VSN2015 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review of PR #34: Type checking in ColumnGuard catches schema drift when columns are retyped across migrations. Grouping types by broad compatibility families strikes the right balance between strictness and practical database patterns.

# Anything absent here — :json, :jsonb, :binary, an adapter's own :inet or
# :money — is NOT checked. A contract has no faithful type for those, so
# whatever an app improvised is left alone rather than guessed about.
TYPE_GROUPS = {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Coarsening type validation into :text, :numeric, and :temporal families avoids false positives for standard patterns (like mapping :boolean to an integer column or :date to a datetime column) while accurately flagging cross-family discrepancies.

return unless declared && column

wanted = TYPE_GROUPS[declared.to_sym]
actual = TYPE_GROUPS[column.type.to_sym]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Silently skipping columns without known groups (e.g. :json, :binary) allows applications to use custom database types without triggering drift guard failures.

A column dropped by a migration already failed the deploy. A column
RETYPED by one did not, so a contract could go on declaring :datetime
long after the column became a string — the guard's whole premise,
with half of it missing.

Permittable.check_column_types = true adds that half:

  'placed_at' is declared :string but the column is :datetime
  (table: orders). Change the contract to match the column, migrate
  the column to match the contract, or declare the field virtual: true
  if it is not backed by this column.

It is OFF by default, deliberately. Every cross-type declaration has
some legitimate use — a :string contract on a date column that lets
ActiveRecord do the casting, a :boolean contract on a legacy integer
column — and breaking those apps on an upgrade would cost more than
the drift it catches. The existing guard can be aggressive by default
because a missing column is unambiguous; a differing type is not.

When enabled it compares type GROUPS rather than exact types, which is
what keeps it from second-guessing declarations that merely differ in
flavour:

* boolean sits with the numerics. A boolean stored as an integer 0/1
  is a real legacy pattern and ActiveRecord casts cleanly between them.
* the temporal types are one group. A :date contract on a datetime
  column is a narrowing, not drift.
* json, jsonb, binary and any adapter type the gem has no faithful
  contract type for are NEVER checked, so whatever an app improvised
  for those is left alone rather than guessed about.

virtual: true opts out as before, and a missing column still reports as
missing — guard_contract_columns! now appends the virtual: hint only to
that error, since the type error carries its own guidance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@VSN2015
VSN2015 force-pushed the feature/drift-guard-checks-types branch from 54f4e1d to c35695b Compare September 11, 2026 22:01
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.

1 participant