Add MySQL support to Lib\Db via multiple simultaneous named connections
Redesigns Lib\Db from a single global connection to a config-driven map
of named connections (config['db_connections']), each independently
lazy-connected, each with its own optional migrations_dir and its own
schema_migrations table. A sidecar can use more than one connection in
the same request (e.g. Db::query(...) against SQLite alongside
Db::query(..., 'legacy') against MySQL) — sidecars have full access to
any Lib\ class, so nothing stops a request from needing two databases
at once, which the original single-driver spec didn't account for.
Db::query()/Db::connection() both default to the 'default' connection
name so the common single-database case is unchanged at the call site.
Supersedes the flat db_driver/db_path/db_migrations_dir keys shipped in
the SQLite groundwork commit (a3b9967) — no downstream consumers yet,
so no migration path needed.
db_connections needed one deliberate exception to the project's usual
shallow config-merge rule: merged one level deeper, by connection name,
so App/config.php adding a connection doesn't delete 'default'. Verified
end-to-end against a real local MariaDB instance running alongside the
existing SQLite connection, which caught a real ordering bug in the
initial merge implementation (capturing defaults after they'd already
been overwritten) before it shipped.
Closes the "MySQL support" backlog item in novaconium/ISSUES.md.
This commit is contained in:
@@ -4,44 +4,75 @@
|
||||
|
||||
{% block title %}Database{% endblock %}
|
||||
|
||||
{% block description %}Lib\Db — a thin PDO/SQLite wrapper with a plain-SQL migration convention.{% endblock %}
|
||||
{% block description %}Lib\Db — a thin PDO wrapper (SQLite and MySQL) with named, simultaneous connections and a plain-SQL migration convention.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Database</h1>
|
||||
|
||||
<p><code>Lib\Db</code> (<code>novaconium/lib/Db.php</code>) is the SQLite groundwork tracked in <code>novaconium/ISSUES.md</code> — a thin PDO wrapper plus a minimal migration runner, no ORM and no query builder, consistent with this project's no-Composer, no-build-step philosophy. It's a <a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Lib\</a> class like <code>Input</code>/<code>Csrf</code>/<code>Mailer</code>, so a project can override it entirely by dropping its own <code>App/lib/Db.php</code>.</p>
|
||||
<p><code>Lib\Db</code> (<code>novaconium/lib/Db.php</code>) is the SQLite/MySQL groundwork tracked in <code>novaconium/ISSUES.md</code> — a thin PDO wrapper plus a minimal migration runner, no ORM and no query builder, consistent with this project's no-Composer, no-build-step philosophy. It's a <a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Lib\</a> class like <code>Input</code>/<code>Csrf</code>/<code>Mailer</code>, so a project can override it entirely by dropping its own <code>App/lib/Db.php</code>.</p>
|
||||
|
||||
<p>It supports multiple, independently-configured, <strong>simultaneously open</strong> named connections rather than a single global one — because a sidecar is plain PHP with full access to any <code>Lib\</code> class, a single request can legitimately need more than one database at once, e.g. this site's own SQLite data alongside a MySQL connection to a legacy or external database.</p>
|
||||
|
||||
<h2>Using it</h2>
|
||||
|
||||
<pre><code>use Lib\Db;
|
||||
|
||||
$rows = Db::query('SELECT * FROM posts WHERE published = ?', [1])->fetchAll();</code></pre>
|
||||
// Targets the 'default' connection — reads exactly like a single-database API.
|
||||
$rows = Db::query('SELECT * FROM posts WHERE published = ?', [1])->fetchAll();
|
||||
|
||||
<p><code>Db::query(string $sql, array $params = [])</code> prepares and executes in one call, returning the <code>PDOStatement</code>. It's the only query-running helper this class exposes — there is deliberately no string-interpolation convenience method. <code>Db::connection()</code> returns the raw <code>PDO</code> instance for anything <code>query()</code> doesn't cover (transactions, <code>lastInsertId()</code>, etc.).</p>
|
||||
// A third argument targets any other configured connection by name, and can
|
||||
// be used in the same request/script as the default connection above.
|
||||
$legacyRows = Db::query('SELECT * FROM widgets', [], 'legacy')->fetchAll();</code></pre>
|
||||
|
||||
<p><code>Db::query(string $sql, array $params = [], string $connection = 'default')</code> prepares and executes against the named connection in one call, returning the <code>PDOStatement</code>. It's the only query-running helper this class exposes — there is deliberately no string-interpolation convenience method. <code>Db::connection(string $name = 'default')</code> returns the raw <code>PDO</code> instance for anything <code>query()</code> doesn't cover (transactions, <code>lastInsertId()</code>, etc.).</p>
|
||||
|
||||
<p><strong>Always use parameter binding, never string-concatenate values into SQL</strong> — the same rule <a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Lib\Input</a>'s own documentation already commits to: cleaning input is defense-in-depth against HTML/script injection, not SQL injection, and no string transform makes arbitrary input safe to concatenate into a query. Parameterized queries are the only real defense, so <code>Db</code> never grows an <code>sqlSafe()</code>-style shortcut.</p>
|
||||
|
||||
<p>The connection is opened lazily — nothing touches the database file or runs a migration until the first real call to <code>Db::query()</code> or <code>Db::connection()</code>, so a request that never needs the database never pays for it.</p>
|
||||
<p>Each connection is opened lazily and independently — nothing touches a given database or runs its migrations until the first real call naming that connection, so a request that only ever uses <code>default</code> never pays to open <code>legacy</code>.</p>
|
||||
|
||||
<h2>Configuration</h2>
|
||||
|
||||
<p>Connections are a named map under a single <code>db_connections</code> key. The framework default defines only <code>default</code> (SQLite):</p>
|
||||
|
||||
<pre><code>// novaconium/config.php (framework default)
|
||||
'db_connections' => [
|
||||
'default' => [
|
||||
'driver' => 'sqlite',
|
||||
'path' => __DIR__ . '/../data/novaconium.sqlite',
|
||||
'migrations_dir' => __DIR__ . '/../App/migrations',
|
||||
],
|
||||
],</code></pre>
|
||||
|
||||
<p>Add a MySQL connection alongside it from <code>App/config.php</code>:</p>
|
||||
|
||||
<pre><code><?php
|
||||
// App/config.php
|
||||
return [
|
||||
'db_driver' => 'sqlite',
|
||||
'db_path' => __DIR__ . '/../data/novaconium.sqlite',
|
||||
'db_migrations_dir' => __DIR__ . '/migrations',
|
||||
'db_connections' => [
|
||||
'legacy' => [
|
||||
'driver' => 'mysql',
|
||||
'host' => 'localhost',
|
||||
'port' => 3306,
|
||||
'database' => 'legacy_app',
|
||||
'username' => 'root',
|
||||
'password' => '...',
|
||||
'charset' => 'utf8mb4', // optional, defaults to utf8mb4
|
||||
'migrations_dir' => __DIR__ . '/migrations/legacy', // optional
|
||||
],
|
||||
],
|
||||
];</code></pre>
|
||||
|
||||
<p>Only <code>'sqlite'</code> is implemented for <code>db_driver</code> today — MySQL support is tracked as a separate item in <code>novaconium/ISSUES.md</code>, and the wrapper avoids SQLite-only SQL where a MySQL-compatible equivalent exists so that lands without a retrofit.</p>
|
||||
<p><strong>This is the one config key in the project that doesn't follow the usual shallow-merge rule.</strong> Every other <code>App/config.php</code> key replaces the framework default outright (see <a class="icon-link" href="/admin/docs/config">{{ icons.book() }}Configuration</a>) — but a plain shallow merge on <code>db_connections</code> would let the snippet above silently delete the framework's <code>default</code> connection just by adding <code>legacy</code>. So <code>Lib\Db</code> merges <code>db_connections</code> one level deeper, by connection name: the example above ends up with both <code>default</code> (SQLite, from the framework) and <code>legacy</code> (MySQL, from <code>App/config.php</code>) configured at once. To actually replace <code>default</code>, redeclare a <code>default</code> key yourself.</p>
|
||||
|
||||
<p><code>db_path</code> defaults to a top-level <code>data/</code> directory — a sibling of <code>App/</code>, <code>novaconium/</code>, and <code>public/</code>, not nested inside any of them. This is deliberate: it can't live under <code>public/</code> (would be directly web-accessible), and it can't live under <code>novaconium/</code> either, since <a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}updating the framework</a> means overwriting that whole directory — anything persisted there would be destroyed by the next update. <code>data/</code> is project-owned, like <code>App/</code>, and untouched by a framework update. Its contents (<code>*.sqlite</code> and the SQLite journal/WAL/SHM sidecar files) are gitignored; only a <code>.gitkeep</code> is tracked so the directory exists in a fresh clone.</p>
|
||||
<p>Only <code>'sqlite'</code> and <code>'mysql'</code> are implemented as <code>driver</code> values. <code>migrations_dir</code> is optional per connection — omit it to never run migrations against that connection (e.g. a legacy database this project shouldn't manage schema for).</p>
|
||||
|
||||
<p>The default connection's <code>path</code> lives in a top-level <code>data/</code> directory — a sibling of <code>App/</code>, <code>novaconium/</code>, and <code>public/</code>, not nested inside any of them. This is deliberate: it can't live under <code>public/</code> (would be directly web-accessible), and it can't live under <code>novaconium/</code> either, since <a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}updating the framework</a> means overwriting that whole directory — anything persisted there would be destroyed by the next update. <code>data/</code> is project-owned, like <code>App/</code>, and untouched by a framework update. Its contents (<code>*.sqlite</code> and the SQLite journal/WAL/SHM sidecar files) are gitignored; only a <code>.gitkeep</code> is tracked so the directory exists in a fresh clone.</p>
|
||||
|
||||
<h2>Migrations</h2>
|
||||
|
||||
<p>Plain <code>.sql</code> files under <code>App/migrations/</code> (<code>db_migrations_dir</code>), applied in filename order — name them with a numeric prefix to control ordering:</p>
|
||||
<p>Plain <code>.sql</code> files under each connection's own <code>migrations_dir</code>, applied in filename order — name them with a numeric prefix to control ordering:</p>
|
||||
|
||||
<pre><code>-- App/migrations/0001_create_posts.sql
|
||||
CREATE TABLE posts (
|
||||
@@ -50,9 +81,9 @@ CREATE TABLE posts (
|
||||
body TEXT NOT NULL
|
||||
);</code></pre>
|
||||
|
||||
<p>Each file is tracked by filename in a <code>schema_migrations</code> table (created automatically) and only ever run once. Migrations apply automatically the first time <code>Db::connection()</code> is called in a process — zero-config, the same "just works" philosophy as static caching — or explicitly, without serving a request first:</p>
|
||||
<p>Each file is tracked by filename in that connection's own <code>schema_migrations</code> table (created automatically in that connection's database) and only ever run once — <code>default</code> and <code>legacy</code> each track their own applied migrations independently. Migrations for a given connection apply automatically the first time it's used in a process — zero-config, the same "just works" philosophy as static caching — or explicitly for every configured connection at once, without serving a request first:</p>
|
||||
|
||||
<pre><code>php novaconium/bin/migrate.php</code></pre>
|
||||
|
||||
<p>Only <code>App/migrations/</code> is scanned — the framework itself ships no core tables yet, so there's no second <code>novaconium/migrations/</code> root to merge in. If a future framework feature needs a shipped migration (e.g. <a href="https://git.4lt.ca/4lt/novaconium/issues">admin login's user table</a>), this can extend to the same App-over-novaconium two-root scan used for pages and lib.</p>
|
||||
<p>Point two connections' <code>migrations_dir</code> at different directories (e.g. <code>App/migrations/</code> for <code>default</code>, <code>App/migrations/legacy/</code> for <code>legacy</code>) if their SQL genuinely diverges between drivers; otherwise the same directory works for both as long as the SQL in it is portable. The framework itself ships no core tables yet, so there's no second <code>novaconium/migrations/</code> root to merge in — if a future framework feature needs a shipped migration (e.g. <a href="https://git.4lt.ca/4lt/novaconium/issues">admin login's user table</a>), this can extend to the same App-over-novaconium two-root scan used for pages and lib.</p>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user