Let the drift guard check column types, opt-in - #34
Conversation
VSN2015
left a comment
There was a problem hiding this comment.
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 = { |
There was a problem hiding this comment.
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] |
There was a problem hiding this comment.
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>
54f4e1d to
c35695b
Compare
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
:datetimelong after the column became a string.The addition
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:
:stringcontract on adatecolumn, letting ActiveRecord do the casting:booleancontract on a legacy integer0/1column:stringcontract on ajsoncolumn that the app serialises itselfA 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:
string,text,citext,uuid,enum,charinteger,bigint,float,decimal,booleandate,datetime,time,timestamp,timestamptzbooleansits with the numerics on purpose — a boolean stored as integer0/1is a real legacy pattern and AR casts cleanly. The temporal types are one group because:dateon adatetimecolumn is a narrowing, not drift.Any column type not in that table is never checked —
json,jsonb,binary, an adapter's owninetormoney. 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 reportsbigintcolumns as:integer, so the map keys on what AR actually returns, not on SQL types.)Also
guard_contract_columns!now appends thevirtual: truehint 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
virtual:/missing-column still behaving