ADR 0005: PHP-Side Schema Single Source¶
Status¶
Accepted
Context¶
NeNe's bundled sample app (users, todos) historically had its schema written in three independent places:
docker/mysql/init/001_schema.sql— the file MySQL's docker-entrypoint runs on a fresh data volume.class/xion/DatabaseInstaller.php::createMySQLTables()— a heredoc-stringCREATE TABLEblock called bycli/setupDatabase.phpwhen the operator wants the framework to install MySQL tables outside the docker-entrypoint path.class/xion/DatabaseInstaller.php::createSQLiteTables()— a parallel heredoc for the SQLite fallback runtime.
PR #295 (Field Trial 6 follow-up) documented the duplication but accepted it as deferred friction. The re-evaluation trigger was "when a future trial actually trips on the drift, or a new bundled sample adopts a unique column that needs the same workaround." Field Trial 10 (docs/field-trials/2026-05-field-trial-10.md, finding F-3) tripped on it: round-tripping a Memo entity through the documented OpenAPI authoring workflow required touching all three sites for one schema change.
The three sites are not interchangeable:
- Site (1) is consumed by an external process (MySQL container init) — it has to ship as a
.sqlfile. - Site (2) and (3) are runtime PHP — they have to execute through PDO.
So the question is not "delete two of them" but "which artifact is the source of truth, and how do the other two stay in sync."
Considered options¶
| Option | Source of truth | Generated artifacts | External tooling |
|---|---|---|---|
| A | PHP description (class/xion/SchemaDefinition.php) |
MySQL DDL (in-process) + SQLite DDL (in-process) + 001_schema.sql |
None (composer only) |
| B | 001_schema.sql (canonical) |
SQLite DDL via transpile; PHP runtime via parse | sqlite-tools or similar SQL→SQL transpiler |
| C | 001_schema.sql (canonical) + hand-written DatabaseInstaller |
DatabaseInstaller blocks regenerated at build time |
Some kind of SQL→PHP code generator |
Option B introduces an external SQL transpiler dependency that NeNe's "small framework that runs on php + composer alone" character explicitly avoids. Option C still requires a generator and inverts the natural direction (a PHP framework whose source of truth is a .sql file is awkward to extend with new sample entities at the PHP layer).
Option A fits NeNe's shape: the framework already describes tables in PHP (DataModelBase::$schema arrays). Extending that pattern to a single SchemaDefinition::tables() array plus a small SchemaCompiler that emits each dialect is direct PHP, composes with the rest of class/xion/, and ships with the framework — no new build step, no new toolchain.
Decision¶
- Add
class/xion/SchemaDefinition.phpdescribing every bundled sample table as a PHP array using a small, explicit type vocabulary (pk-bigint,bigint,varchar:NN,text,bool,datetime-now,datetime-touch). - Add
class/xion/SchemaCompiler.phpexposing four pure static methods: mysqlStatements(): array<int,string>— oneCREATE TABLEper table, MySQL dialect.sqliteStatements(): array<int,string>— same, SQLite dialect.sqliteIndexStatements(): array<int,string>—CREATE INDEXstatements (SQLite requires them outsideCREATE TABLE).sqliteTriggerStatements(): array<int,string>— triggers that simulate MySQL'sON UPDATE CURRENT_TIMESTAMPondatetime-touchcolumns.- Refactor
DatabaseInstaller'screateMySQLTables/createSQLiteTables/createSQLiteTimestampTriggersto iterate the compiler output instead of carrying their own heredocs. - Keep
docker/mysql/init/001_schema.sql(MySQL's docker-entrypoint cannot run PHP), but treat its DDL section as a generated artifact whose content must equalSchemaCompiler::mysqlStatements()concatenated. A unit test asserts the equality on every CI run; the seedINSERTblock remains hand-written below the DDL section. - Document the regeneration command (
composer schema:generate) and the drift test indocs/development/cli.mdand in the new section ofdocs/development/error-codes.mdcross-references (TODO: actual placement decided at PR time).
Consequences¶
Positive:
- Adding a column or table is a single PHP edit. Trial 10's friction (touching three files for a Memo entity) becomes one edit +
composer schema:generate. - The MySQL
001_schema.sqlfile stays valid and bytewise-stable for the docker-entrypoint, so existing volumes initialize identically. - The new
SchemaCompilerTest::testDockerInitSqlMatchesCompiledOutputcatches drift on every PR. - The dialect mapping for each column type is one
matcharm inSchemaCompiler— auditable on a small surface.
Negative / accepted trade-offs:
SchemaCompilernow owns the full DDL contract. Adding a column type (e.g.decimal,enum) is one PR that updatesSchemaDefinition's type vocabulary, themysqlColumn/sqliteColumnmatch arms, and the regenerated001_schema.sql. The price is paid once per new type, not once per new entity.- Migrations (
ALTER TABLE) are out of scope for this ADR. The currentCREATE TABLE IF NOT EXISTSflow assumes destructive recreation in dev and operator-managed migrations in prod. The migration story is addressed by ADR-0009 (docs/adr/0009-schema-migration-story.md, derived fromREPORT_commercial_feasibility.md/ Issue #409): a futurecli/schemaDiff.phpreads the sameSchemaDefinitionsource and emits operator-appliedALTER TABLEstatements. ADR-0005's single source is preserved by ADR-0009. - A few PHP edge cases (column-level
COMMENT, table-level partitioning, MySQL-specific options likeROW_FORMAT=DYNAMIC) are not supported and would need vocabulary extensions if a future bundled table requires them. None of the current schema needs them.
Neutral:
- Backwards compatibility: external apps that build on NeNe and have their own
DataMapperBasesubclasses see no change — the framework still loads the same tables in the same shape. Only the source moves from three files to one.