Skip to content

Store booleans as tinyint(1), not enum('0','1') - #1364

Merged
mastacontrola merged 2 commits into
working-1.6from
enum-booleans-to-tinyint
Aug 25, 2026
Merged

Store booleans as tinyint(1), not enum('0','1')#1364
mastacontrola merged 2 commits into
working-1.6from
enum-booleans-to-tinyint

Conversation

@mastacontrola

@mastacontrola mastacontrola commented Aug 25, 2026

Copy link
Copy Markdown
Member

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

written MariaDB stores means
'0' (string) '0' FALSE — correct
'1' (string) '1' TRUE — correct
0 (int) error value '' rejected under STRICT_TRANS_TABLES
1 (int) '0' FALSE — silently inverted

The tree only survived nine years of that because PDODB binds every parameter
as PDO::PARAM_STR. #1245 (removing the nine-year-old SET SESSION sql_mode='')
and #1361 (normalising bools to '0'/'1' at the bind seam) are both patches on
top 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 conventions
for 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 (or 1 where that was the existing default),
via schema step 368, and regenerates commons/schema-expected.php.

docs/adr/0028-booleans-are-tinyint-not-enum.md carries 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' becomes 1 and every '1' becomes 2 — both truthy, silently, on every
upgrading server. Measured on MariaDB 11.8:

before  0 1 0 1
after   1 2 1 2      <- naive ALTER
after   0 1 0 1      <- via VARCHAR(1)

So the step goes enumVARCHAR(1) (converts by label) → UPDATE any row
still holding the ENUM error value → TINYINT(1). The middle UPDATE is not
cosmetic: a row carrying the '' error value from before #1245 arrives at the
varchar stage as '', which tinyint refuses, so dropping it turns the upgrade
into 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 per
caller. It reads nullability and default out of information_schema and carries
them across rather than assuming NOT NULLLDAPServers.lsAllowAPI is
nullable and lsUseGroupMatch has no default at all.

tests/booleans-are-tinyint.test.php pins that ordering, so the conversion cannot
be "simplified" into one ALTER later. Mutation-verified: removing the VARCHAR hop,
removing the UPDATE, or reintroducing an enum('0','1') column to the manifest
each fail the gate.

The step reads information_schema per column and skips anything not still
exactly enum('0','1'), so a re-run is a read and a database already converted
is left alone.

Wire format changes, deliberately

ATTR_EMULATE_PREPARES is off, so mysqlnd returns native types: enum came back
as the string "1", tinyint comes back as the integer 1. These fields now
serialise as 0/1 rather than "0"/"1" in REST responses. That is the right
representation 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 — and MIN(COALESCE(hostEnforce,'')) still yields 0/1.

OpenAPI::_columnSchema() already mapped tinyintinteger, which is correct
for a 0/1 payload; only its docblock changed, since it claimed FOG spelled
booleans as enums.

Downstream: darksidemilk/FogApi consumes these payloads and is the known
downstream reader of the type change.

Not included, deliberately

  • dev-branch. 1.5.x is the patches line with the largest installed base FOG
    has; 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.
  • The 10 bundled-plugin columnsLDAPServers (4), OIDCProviders (5),
    location (1). Each plugin owns its schema (ADR 0009), so those convert in
    fix(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 merges
    first
    .
  • The char(1)/varchar(1) flagstasks.taskShutdown, snapins.sReboot,
    hosts.hostUseAD. They look like the same family and are not: hostUseAD is
    tri-state, '' meaning "inherit". That family needs a per-column reading, not a
    sweep.

Verification

  • Converted a throwaway copy of a live 1.6 database — all 36 columns, plugin
    tables included: every value, default and nullability preserved, re-run a
    no-op. Harness: background_scripts/prove_enum_to_tinyint_conversion.{sh,php}.
  • Re-measured the index trap on MariaDB 11.8.8: naive ALTER gives 1,2,1,2
    from 0,1,0,1; via VARCHAR(1) gives 0,1,0,1.
  • bash tests/run-all.sh151 passed, 0 failed.
  • Gate mutation-verified four ways (above).

mastacontrola and others added 2 commits August 25, 2026 06:35
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>
@mastacontrola
mastacontrola merged commit 4c7cf59 into working-1.6 Aug 25, 2026
8 checks passed
@mastacontrola
mastacontrola deleted the enum-booleans-to-tinyint branch August 25, 2026 11:47
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