Store booleans as tinyint(1), not enum('0','1') - #1364
Merged
Conversation
FOG has spelled its two-state columns enum('0','1') since the beginning,
and that encoding carries a trap: an integer written to an ENUM is a
member INDEX, not a value. For enum('0','1') that makes 1 mean the member
'0' -- FALSE -- and 0 the error value, which STRICT_TRANS_TABLES refuses.
The tree only survived nine years of it because PDODB binds everything as
PDO::PARAM_STR, and #1245 (removing the sql_mode clear) plus #1361 (bool
-> '0'/'1' at the bind seam) made the cost visible.
Converts the 26 core two-state columns in 15 tables to
TINYINT(1) NOT NULL DEFAULT 0/1 via schema step 368, and regenerates the
manifest. ADR 0028 records the reasoning, the four pre-existing genuine
tinyint(1) booleans this aligns with, and why the char(1)/varchar(1)
tri-state flags (hostUseAD, taskShutdown, sReboot) are deliberately left
alone.
The migration is three statements per column and that is not optional:
a direct ALTER ... MODIFY TINYINT(1) converts an ENUM BY INDEX, so every
'0' becomes 1 and every '1' becomes 2 -- silently, on every upgrading
server. Going enum -> VARCHAR(1) -> UPDATE stragglers -> TINYINT(1)
converts by label. Measured on MariaDB 11.8; tests/booleans-are-tinyint
pins the ordering so the step cannot be "simplified" later.
The REST payload changes deliberately: mysqlnd returns a native int for
tinyint, so these fields go out as 0/1 rather than "0"/"1". working-1.6
only -- dev-branch keeps enum, since 1.5.x is a patches line and #1362
already closed the bug that prompted this.
The 10 bundled-plugin columns (LDAPServers 4, OIDCProviders 5,
location 1) convert in fog-plugins, since each plugin owns its schema.
Co-Authored-By: Claude <noreply@anthropic.com>
The three bundled plugins that own boolean columns -- ldap, oidc and
location -- convert their own from their own schema() (ADR 0009), so the
three-statement rule was about to be written four times. It is exactly
the rule that must not be re-derived per caller: a direct
ALTER ... MODIFY TINYINT(1) converts an ENUM by INDEX, so one caller
getting it wrong silently switches on every flag in its table.
Lifts the conversion out of schema step 368 into Schema::enumToTinyint(),
which step 368 now calls with the core map. Two behaviour changes over
the inline version, both needed by the plugin columns:
- nullability and default are read from information_schema and carried
across rather than assumed NOT NULL. LDAPServers.lsAllowAPI is
nullable and lsUseGroupMatch has no default at all; forcing NOT NULL
DEFAULT 0 on either would be a behaviour change smuggled in by a
type change.
- the straggler UPDATE skips NULLs, so a nullable column keeps them.
tests/booleans-are-tinyint now pins the shared helper rather than the
step, plus a check that step 368 still goes through it.
Measured again on MariaDB 11.8.8, all 36 columns including the plugin
tables: naive ALTER gives 1,2,1,2 from 0,1,0,1; via VARCHAR gives
0,1,0,1. Every value, default and nullability preserved, re-run a no-op.
background_scripts/prove_enum_to_tinyint_conversion.{sh,php} is the
harness.
Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
FOG spells its two-state columns
enum('0','1'). That encoding has a trap in it:an integer written to an ENUM is a member index, not a value. For
enum('0','1')the mapping is'0'(string)'0''1'(string)'1'0(int)''STRICT_TRANS_TABLES1(int)'0'The tree only survived nine years of that because
PDODBbinds every parameteras
PDO::PARAM_STR. #1245 (removing the nine-year-oldSET SESSION sql_mode='')and #1361 (normalising bools to
'0'/'1'at the bind seam) are both patches ontop of an encoding that should not be there. FOG also already has four genuine
tinyint(1)booleans —sites.siteCatchAll,auditLog.alRenderable,auditChange.acRedacted,hosts.hostInfoLock— so there are two conventionsfor one idea with no rule saying which to reach for.
What this does
Converts the 26 core two-state columns in 15 tables to
TINYINT(1) NOT NULL DEFAULT 0(or1where that was the existing default),via schema step 368, and regenerates
commons/schema-expected.php.docs/adr/0028-booleans-are-tinyint-not-enum.mdcarries the full reasoning,including why the ENUM domain constraint does not outweigh the index trap and
what a
CHECK (col IN (0,1))would buy if it is ever wanted back.The migration is three statements per column, and that is not optional
ALTER TABLE t MODIFY c TINYINT(1)on an ENUM converts by index, so every'0'becomes1and every'1'becomes2— both truthy, silently, on everyupgrading server. Measured on MariaDB 11.8:
So the step goes
enum→VARCHAR(1)(converts by label) →UPDATEany rowstill holding the ENUM error value →
TINYINT(1). The middleUPDATEis notcosmetic: a row carrying the
''error value from before #1245 arrives at thevarchar stage as
'', whichtinyintrefuses, so dropping it turns the upgradeinto a hard failure on exactly the servers that most need it.
The conversion lives in
Schema::enumToTinyint(), not inline in the step:the three bundled plugins that own boolean columns convert their own from their
own
schema(), and this is exactly the rule that must not be re-derived percaller. It reads nullability and default out of
information_schemaand carriesthem across rather than assuming
NOT NULL—LDAPServers.lsAllowAPIisnullable and
lsUseGroupMatchhas no default at all.tests/booleans-are-tinyint.test.phppins that ordering, so the conversion cannotbe "simplified" into one
ALTERlater. Mutation-verified: removing the VARCHAR hop,removing the
UPDATE, or reintroducing anenum('0','1')column to the manifesteach fail the gate.
The step reads
information_schemaper column and skips anything not stillexactly
enum('0','1'), so a re-run is a read and a database already convertedis left alone.
Wire format changes, deliberately
ATTR_EMULATE_PREPARESis off, so mysqlnd returns native types:enumcame backas the string
"1",tinyintcomes back as the integer1. These fields nowserialise as
0/1rather than"0"/"1"in REST responses. That is the rightrepresentation and this is a beta line, which is why it lands here rather than on
a patches branch. Every in-tree reader was audited — all use truthiness or cast
(string)first — andMIN(COALESCE(hostEnforce,''))still yields0/1.OpenAPI::_columnSchema()already mappedtinyint→integer, which is correctfor a 0/1 payload; only its docblock changed, since it claimed FOG spelled
booleans as enums.
Downstream:
darksidemilk/FogApiconsumes these payloads and is the knowndownstream reader of the type change.
Not included, deliberately
dev-branch. 1.5.x is the patches line with the largest installed base FOGhas; the payload change is not worth it there, and Store a PHP boolean as '0'/'1', never as '' #1362 already closed the bug
that prompted this.
LDAPServers(4),OIDCProviders(5),location(1). Each plugin owns its schema (ADR 0009), so those convert infix(schema): store two-state columns as tinyint(1), not enum('0','1') fog-plugins#27, and reach servers through a release and a pin
bump. That PR calls
Schema::enumToTinyint()from here, so this one mergesfirst.
char(1)/varchar(1)flags —tasks.taskShutdown,snapins.sReboot,hosts.hostUseAD. They look like the same family and are not:hostUseADistri-state,
''meaning "inherit". That family needs a per-column reading, not asweep.
Verification
tables included: every value, default and nullability preserved, re-run a
no-op. Harness:
background_scripts/prove_enum_to_tinyint_conversion.{sh,php}.ALTERgives1,2,1,2from
0,1,0,1; viaVARCHAR(1)gives0,1,0,1.bash tests/run-all.sh— 151 passed, 0 failed.