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:
@@ -130,34 +130,66 @@ management"); don't extend this class toward multi-user/session-based
|
|||||||
auth — that's a separate, larger feature that
|
auth — that's a separate, larger feature that
|
||||||
will replace it.
|
will replace it.
|
||||||
|
|
||||||
`Lib\Db` (`novaconium/lib/Db.php`) is the SQLite groundwork tracked in
|
`Lib\Db` (`novaconium/lib/Db.php`) is the SQLite/MySQL groundwork tracked
|
||||||
`novaconium/ISSUES.md` — a thin, no-ORM PDO wrapper, `Lib\` (not `App\`) so
|
in `novaconium/ISSUES.md` — a thin, no-ORM PDO wrapper, `Lib\` (not `App\`)
|
||||||
a project can override it via `App/lib/Db.php` like any other `Lib\` class.
|
so a project can override it via `App/lib/Db.php` like any other `Lib\`
|
||||||
`Db::query(string $sql, array $params = [])` (prepare+execute) is the only
|
class. It supports multiple, independently-configured, **simultaneously
|
||||||
query-running helper — never add a string-interpolation shortcut; see
|
open** named connections (`config['db_connections']`, keyed by name) rather
|
||||||
|
than one global connection — because sidecars are plain PHP with full
|
||||||
|
access to any `Lib\` 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 database). `Db::query(string $sql, array
|
||||||
|
$params = [], string $connection = 'default')` (prepare+execute) is the
|
||||||
|
only query-running helper — never add a string-interpolation shortcut; see
|
||||||
`Lib\Input`'s doc-comment, which already commits this project to
|
`Lib\Input`'s doc-comment, which already commits this project to
|
||||||
parameterized queries as the sole SQL-injection defense. Connection is
|
parameterized queries as the sole SQL-injection defense. Each connection is
|
||||||
lazy (opened on first `Db::query()`/`Db::connection()` call, same shape as
|
lazy and independent (opened on first `Db::query()`/`Db::connection()` call
|
||||||
`Lib\Csrf`'s lazy session start), and migrates automatically at that point:
|
naming it, same shape as `Lib\Csrf`'s lazy session start), and migrates
|
||||||
plain `.sql` files under `App/migrations/` (`config['db_migrations_dir']`),
|
automatically at that point: plain `.sql` files under that connection's own
|
||||||
filename-ordered, tracked in an auto-created `schema_migrations` table, run
|
`migrations_dir`, filename-ordered, tracked in that connection's own
|
||||||
once each. `novaconium/bin/migrate.php` runs the same migration step
|
auto-created `schema_migrations` table (each connection's applied
|
||||||
explicitly (e.g. from a deploy script) without serving a request first.
|
migrations are independent of any other connection's), run once each.
|
||||||
**`config['db_path']` must stay outside both `public/` (would be
|
`novaconium/bin/migrate.php` loops every configured connection and runs the
|
||||||
web-accessible) and `novaconium/`** — unlike `cache_dir`/`contact-log.txt`,
|
same migration step explicitly (e.g. from a deploy script) without serving
|
||||||
which are disposable, a SQLite file is data a project can't afford to lose,
|
a request first. Only `'sqlite'` and `'mysql'` drivers are implemented; a
|
||||||
and `novaconium/` gets wholesale-replaced by the "Updating the framework"
|
connection's `migrations_dir` is optional — omit it to never run migrations
|
||||||
workflow (`/admin/docs/getting-started`: `rm -rf novaconium && cp -r
|
against that connection (e.g. a legacy database this project shouldn't
|
||||||
<new-novaconium>`). The default (`data/novaconium.sqlite`) lives in a new
|
manage schema for).
|
||||||
top-level `data/` directory instead — project-owned like `App/`, gitignored
|
|
||||||
per-file (`*.sqlite`/`-journal`/`-wal`/`-shm`, with a tracked `.gitkeep` so
|
**`db_connections` is the one config key in the project that isn't plain
|
||||||
the directory exists in a fresh clone) rather than wholesale like
|
shallow-merge** — `bootstrap.php`/`bin/*.php`'s usual `array_merge($config,
|
||||||
|
$appConfig)` would let a project's `App/config.php` silently delete the
|
||||||
|
framework's `default` connection just by adding a second named connection
|
||||||
|
(a shallow merge replaces the whole key, it doesn't merge inside it). So
|
||||||
|
`Lib\Db::config()` (and the copy of this logic duplicated in
|
||||||
|
`bin/migrate.php`, same duplication precedent as the two-step config load
|
||||||
|
already duplicated across `bootstrap.php`/`bin/clear-cache.php`) merges
|
||||||
|
`db_connections` one level deeper, by connection name, **after** capturing
|
||||||
|
the framework defaults — capture the defaults *before* the top-level
|
||||||
|
`array_merge()` overwrites `$config['db_connections']`, not after, or the
|
||||||
|
deeper merge silently operates on the already-overwritten value and the
|
||||||
|
`default` connection vanishes anyway. (This exact bug was hit once while
|
||||||
|
building this feature — verified by testing a real `App/config.php`
|
||||||
|
override end-to-end, not just reading the code — so it's worth re-checking
|
||||||
|
by hand if this logic is ever touched again.) See
|
||||||
|
`/admin/docs/database` for the worked example.
|
||||||
|
|
||||||
|
**`config['db_connections']['default']['path']` must stay outside both
|
||||||
|
`public/` (would be web-accessible) and `novaconium/`** — unlike
|
||||||
|
`cache_dir`/`contact-log.txt`, which are disposable, a SQLite file is data a
|
||||||
|
project can't afford to lose, and `novaconium/` gets wholesale-replaced by
|
||||||
|
the "Updating the framework" workflow (`/admin/docs/getting-started`: `rm
|
||||||
|
-rf novaconium && cp -r <new-novaconium>`). The default
|
||||||
|
(`data/novaconium.sqlite`) lives in a new top-level `data/` directory
|
||||||
|
instead — project-owned like `App/`, gitignored per-file
|
||||||
|
(`*.sqlite`/`-journal`/`-wal`/`-shm`, with a tracked `.gitkeep` so the
|
||||||
|
directory exists in a fresh clone) rather than wholesale like
|
||||||
`public/cache/`, since a project might reasonably want other non-DB files
|
`public/cache/`, since a project might reasonably want other non-DB files
|
||||||
there later. Only `App/migrations/` is scanned for now (the framework ships
|
there later. Only `App/migrations/` (the framework default connection's
|
||||||
no core tables of its own yet) — if a future framework feature needs a
|
`migrations_dir`) is scanned by default — the framework ships no core
|
||||||
shipped migration, extend this to the same App-over-novaconium two-root
|
tables of its own yet — if a future framework feature needs a shipped
|
||||||
scan `Overlay.php` already does for pages/lib, don't invent a second
|
migration, extend this to the same App-over-novaconium two-root scan
|
||||||
mechanism.
|
`Overlay.php` already does for pages/lib, don't invent a second mechanism.
|
||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
|
|||||||
+16
-4
@@ -21,8 +21,20 @@ return [
|
|||||||
// 'admin_username' => 'admin',
|
// 'admin_username' => 'admin',
|
||||||
// 'admin_password_hash' => '$2y$10$...',
|
// 'admin_password_hash' => '$2y$10$...',
|
||||||
|
|
||||||
// Docs: /admin/docs/database
|
// Docs: /admin/docs/database — adds (or overrides) named Lib\Db
|
||||||
// 'db_driver' => 'sqlite',
|
// connections. This merges into db_connections by name rather than
|
||||||
// 'db_path' => __DIR__ . '/../data/novaconium.sqlite',
|
// replacing the whole map, so adding 'legacy' here doesn't require
|
||||||
// 'db_migrations_dir' => __DIR__ . '/migrations',
|
// repeating 'default' — see Lib\Db::config().
|
||||||
|
// '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
|
||||||
|
// ],
|
||||||
|
// ],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ A tiny, Hugo-flavored PHP micro-framework. Routes are directories on disk, pages
|
|||||||
- **Dark/light theme toggle** — a nav button flips a `data-theme` attribute (persisted to `localStorage`) that swaps every color via CSS custom properties; both palettes live in `App/sass/_colors.sass`, same override mechanism as everything else.
|
- **Dark/light theme toggle** — a nav button flips a `data-theme` attribute (persisted to `localStorage`) that swaps every color via CSS custom properties; both palettes live in `App/sass/_colors.sass`, same override mechanism as everything else.
|
||||||
- **Self-hosted spam prevention & form validation** — `Lib\SpamGuard`, a reusable class for any form: a CSS-hidden honeypot field plus a submission-timing check, no external CAPTCHA service, site key, or outbound API call. Pairs with `Lib\FormValidator` (accumulating required-field/email/length checks) and `Lib\Validate` (the underlying validation primitives — email, length, phone, postal/zip, spam-word checks). All three ship in `novaconium/lib/`, demonstrated on the contact form.
|
- **Self-hosted spam prevention & form validation** — `Lib\SpamGuard`, a reusable class for any form: a CSS-hidden honeypot field plus a submission-timing check, no external CAPTCHA service, site key, or outbound API call. Pairs with `Lib\FormValidator` (accumulating required-field/email/length checks) and `Lib\Validate` (the underlying validation primitives — email, length, phone, postal/zip, spam-word checks). All three ship in `novaconium/lib/`, demonstrated on the contact form.
|
||||||
- **Form security by default** — `Lib\Input`, a cleaning accessor for `$_POST`/`$_GET` (defense-in-depth against HTML/script injection, not a substitute for parameterized queries), and `Lib\Csrf`, standalone session-token CSRF protection called directly from a sidecar. Both ship in `novaconium/lib/`, wired into the contact form, `/admin/clear-cache`, and `/admin/password-hash`.
|
- **Form security by default** — `Lib\Input`, a cleaning accessor for `$_POST`/`$_GET` (defense-in-depth against HTML/script injection, not a substitute for parameterized queries), and `Lib\Csrf`, standalone session-token CSRF protection called directly from a sidecar. Both ship in `novaconium/lib/`, wired into the contact form, `/admin/clear-cache`, and `/admin/password-hash`.
|
||||||
- **SQLite database, zero setup** — `Lib\Db`, a thin PDO wrapper (no ORM) with a plain-SQL migration convention (`App/migrations/*.sql`, applied automatically on first use, or via `php novaconium/bin/migrate.php`). Data lives in a project-owned top-level `data/` directory, outside both `public/` and `novaconium/`. See `/admin/docs/database`.
|
- **SQLite/MySQL database, zero setup** — `Lib\Db`, a thin PDO wrapper (no ORM) supporting multiple named connections open at once — e.g. this site's own SQLite data plus a MySQL connection to a legacy database, usable in the same sidecar — each with its own plain-SQL migration convention, applied automatically on first use or via `php novaconium/bin/migrate.php`. SQLite data lives in a project-owned top-level `data/` directory, outside both `public/` and `novaconium/`. See `/admin/docs/database`.
|
||||||
- **No build step, no Composer** — clone it, point Apache (or `php -S`) at `public/`, and it runs. Twig is vendored as source; see `/admin/docs/upgrading-twig` for upgrading it.
|
- **No build step, no Composer** — clone it, point Apache (or `php -S`) at `public/`, and it runs. Twig is vendored as source; see `/admin/docs/upgrading-twig` for upgrading it.
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
**Requirements:** PHP 8.1+ (uses `readonly` constructor-promoted properties) with the `pdo_sqlite` extension (bundled with PHP, just needs to be enabled — no separate install), and, for production, Apache with `mod_rewrite` and `AllowOverride All`.
|
**Requirements:** PHP 8.1+ (uses `readonly` constructor-promoted properties) with the `pdo_sqlite` extension (bundled with PHP, just needs to be enabled — no separate install; add `pdo_mysql` too if using a MySQL connection), and, for production, Apache with `mod_rewrite` and `AllowOverride All`.
|
||||||
|
|
||||||
### Run it locally (no Apache needed)
|
### Run it locally (no Apache needed)
|
||||||
|
|
||||||
|
|||||||
+63
-52
@@ -54,58 +54,37 @@ expected vs. actual behavior. For features, include the motivating use case.>
|
|||||||
Suggested build order (foundations first, since admin login builds on two
|
Suggested build order (foundations first, since admin login builds on two
|
||||||
of the others):
|
of the others):
|
||||||
|
|
||||||
1. **MySQL support** — builds directly on SQLite groundwork's `Db`
|
1. **Session handling** — no dependencies.
|
||||||
abstraction (see Done — shipped 2026-07-14); do it soon since the
|
2. **Blog tags/categories** — no dependencies, but now needs a metadata
|
||||||
abstraction was designed driver-agnostic from the start specifically to
|
|
||||||
avoid a retrofit here.
|
|
||||||
2. **Session handling** — no dependencies.
|
|
||||||
3. **Blog tags/categories** — no dependencies, but now needs a metadata
|
|
||||||
source design decision first (`PostRepository` was removed when
|
source design decision first (`PostRepository` was removed when
|
||||||
`hello-world`/`second-post` became plain Twig pages — see the entry
|
`hello-world`/`second-post` became plain Twig pages — see the entry
|
||||||
below), so worth doing first or together with Blog RSS feed.
|
below), so worth doing first or together with Blog RSS feed.
|
||||||
4. **Blog RSS feed** — no hard dependency, but a per-tag feed is easiest
|
3. **Blog RSS feed** — no hard dependency, but a per-tag feed is easiest
|
||||||
once tags/categories exist.
|
once tags/categories exist.
|
||||||
5. **Internal search** — SQLite groundwork it needed is done; ready to
|
4. **Internal search** — SQLite groundwork it needed is done; ready to
|
||||||
build.
|
build.
|
||||||
6. **XML sitemap** — no hard dependency, but shares crawling logic with
|
5. **XML sitemap** — no hard dependency, but shares crawling logic with
|
||||||
Internal search, so easiest right after (or alongside) it.
|
Internal search, so easiest right after (or alongside) it.
|
||||||
7. **Media/file manager** — no hard dependency; usable standalone, though
|
6. **Media/file manager** — no hard dependency; usable standalone, though
|
||||||
best gated behind admin login once that exists.
|
best gated behind admin login once that exists.
|
||||||
8. **Draft pages (admin-only preview)** — no hard dependency; the admin
|
7. **Draft pages (admin-only preview)** — no hard dependency; the admin
|
||||||
authentication it reuses already shipped (see `/admin/docs/admin-auth`).
|
authentication it reuses already shipped (see `/admin/docs/admin-auth`).
|
||||||
9. **Syntax highlighting on code blocks** — no hard dependency on the
|
8. **Syntax highlighting on code blocks** — no hard dependency on the
|
||||||
copy button (see Done — shipped 2026-07-14), but touches the same
|
copy button (see Done — shipped 2026-07-14), but touches the same
|
||||||
`<pre><code>` markup it did.
|
`<pre><code>` markup it did.
|
||||||
10. **Admin login & user management** — SQLite groundwork it needed is
|
9. **Admin login & user management** — SQLite groundwork it needed is
|
||||||
done; still needs session handling (logged-in state).
|
done; still needs session handling (logged-in state).
|
||||||
11. **Ecommerce functionality** — needs session handling (cart) and admin
|
10. **Ecommerce functionality** — needs session handling (cart) and admin
|
||||||
login & user management (order/product admin, and customer accounts);
|
login & user management (order/product admin, and customer accounts);
|
||||||
SQLite groundwork it needed is done.
|
SQLite groundwork it needed is done.
|
||||||
12. **Paywall functionality** — needs everything Ecommerce needs, plus
|
11. **Paywall functionality** — needs everything Ecommerce needs, plus
|
||||||
Ecommerce itself for the recurring-billing/payment-gateway plumbing;
|
Ecommerce itself for the recurring-billing/payment-gateway plumbing;
|
||||||
build after it rather than in parallel.
|
build after it rather than in parallel.
|
||||||
|
|
||||||
|
MySQL support shipped 2026-07-14 — see Done.
|
||||||
|
|
||||||
See **Won't Do** below for 404 tracking, dropped in favor of Matomo.
|
See **Won't Do** below for 404 tracking, dropped in favor of Matomo.
|
||||||
|
|
||||||
### MySQL support
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Backlog
|
|
||||||
- **Priority:** Medium
|
|
||||||
- **Depends on:** SQLite groundwork (Done)
|
|
||||||
- **Added:** 2026-07-12
|
|
||||||
|
|
||||||
Let a project point the `Db` wrapper at MySQL instead of SQLite — via a
|
|
||||||
`db_driver` (or similar) `App/config.php` key plus connection settings
|
|
||||||
(host/user/password/database) — for projects that want a real MySQL
|
|
||||||
server rather than an embedded file, without maintaining two separate data
|
|
||||||
layers. PDO already supports both drivers under one API, so this should be
|
|
||||||
mostly a matter of: (1) not writing SQLite-only SQL in the groundwork
|
|
||||||
above, (2) a config-driven DSN builder, (3) a migration convention that
|
|
||||||
works on both (or per-driver migration files if syntax genuinely diverges).
|
|
||||||
No new persistence features depend on this — it's an alternate backend for
|
|
||||||
the same `Db` abstraction, not a separate feature surface.
|
|
||||||
|
|
||||||
### Session handling (with flash sessions)
|
### Session handling (with flash sessions)
|
||||||
|
|
||||||
- **Type:** Feature
|
- **Type:** Feature
|
||||||
@@ -370,6 +349,44 @@ _Nothing yet._
|
|||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
|
||||||
|
### MySQL support
|
||||||
|
|
||||||
|
- **Type:** Feature
|
||||||
|
- **Status:** Done
|
||||||
|
- **Priority:** Medium
|
||||||
|
- **Depends on:** SQLite groundwork (Done)
|
||||||
|
- **Added:** 2026-07-12
|
||||||
|
- **Shipped:** 2026-07-14
|
||||||
|
|
||||||
|
Let a project point `Lib\Db` at MySQL — but went further than the original
|
||||||
|
spec ("point the `Db` wrapper at MySQL instead of SQLite"): a real
|
||||||
|
requirement surfaced during implementation that a single request may need
|
||||||
|
**both** at once (sidecars have full access to any `Lib\` class, so nothing
|
||||||
|
stops one from querying this site's own SQLite data and a legacy MySQL
|
||||||
|
database in the same request). So `Lib\Db` was redesigned around multiple,
|
||||||
|
independently-configured, simultaneously-open named connections
|
||||||
|
(`config['db_connections']`, keyed by name — `'default'` is the only
|
||||||
|
required one) rather than one global connection switched by a single
|
||||||
|
`db_driver` key. This superseded the flat `db_driver`/`db_path`/
|
||||||
|
`db_migrations_dir` keys the SQLite groundwork entry above originally
|
||||||
|
shipped with (which had no downstream consumers yet, so no migration path
|
||||||
|
was needed). `Db::query(string $sql, array $params = [], string
|
||||||
|
$connection = 'default')` and `Db::connection(string $name = 'default')`
|
||||||
|
both default to `'default'` so the common single-database case reads the
|
||||||
|
same as before; a third/first argument targets any other configured
|
||||||
|
connection. Each connection has its own lazy PDO connect (`'sqlite'` and
|
||||||
|
`'mysql'` drivers implemented), its own optional `migrations_dir`, and its
|
||||||
|
own independent `schema_migrations` table — verified for real (not just by
|
||||||
|
inspection) by running a local MariaDB instance alongside the existing
|
||||||
|
SQLite connection and executing queries against both from the same script.
|
||||||
|
`db_connections` needed one deliberate exception to the project's usual
|
||||||
|
shallow config-merge rule — merged one level deeper, by connection name, so
|
||||||
|
an `App/config.php` adding a `legacy` connection doesn't silently delete
|
||||||
|
the framework's `default` one — documented in `AGENTS.md` and
|
||||||
|
`/admin/docs/database`, including the exact "capture defaults before the
|
||||||
|
top-level `array_merge()` overwrites them" ordering bug hit once while
|
||||||
|
building this (caught by the end-to-end MySQL test, not by review).
|
||||||
|
|
||||||
### SQLite groundwork
|
### SQLite groundwork
|
||||||
|
|
||||||
- **Type:** Feature
|
- **Type:** Feature
|
||||||
@@ -379,28 +396,22 @@ _Nothing yet._
|
|||||||
- **Shipped:** 2026-07-14
|
- **Shipped:** 2026-07-14
|
||||||
|
|
||||||
Laid the groundwork for optional SQLite storage: `Lib\Db`
|
Laid the groundwork for optional SQLite storage: `Lib\Db`
|
||||||
(`novaconium/lib/Db.php`) is a thin, no-ORM PDO wrapper (`Db::query(string
|
(`novaconium/lib/Db.php`) is a thin, no-ORM PDO wrapper (prepared
|
||||||
$sql, array $params = [])` — prepared statements only, no
|
statements only, no string-interpolation helper ever, per `Lib\Input`'s
|
||||||
string-interpolation helper ever, per `Lib\Input`'s existing documented
|
existing documented security stance) so features that need persistence —
|
||||||
security stance) so features that need persistence — 404 tracking (see
|
404 tracking (see Won't Do; superseded by Matomo before this shipped),
|
||||||
Won't Do; superseded by Matomo before this shipped), admin login, blog
|
admin login, blog tags, internal search, and anything future — have a
|
||||||
tags, internal search, and anything future — have a common place to store
|
common place to store data instead of ad hoc flat files. Data lives in a
|
||||||
data instead of ad hoc flat files. Connection is lazy (opens on first real
|
new top-level `data/` directory — deliberately outside both `public/`
|
||||||
call, same shape as `Lib\Csrf`'s lazy session start). Migration convention:
|
|
||||||
plain numbered `.sql` files under `App/migrations/`
|
|
||||||
(`config['db_migrations_dir']`), applied in filename order and tracked in
|
|
||||||
an auto-created `schema_migrations` table, run automatically on first
|
|
||||||
connection or explicitly via `php novaconium/bin/migrate.php`. Data lives
|
|
||||||
in a new top-level `data/` directory — deliberately outside both `public/`
|
|
||||||
(would be web-accessible) and `novaconium/` (gets wholesale-replaced by the
|
(would be web-accessible) and `novaconium/` (gets wholesale-replaced by the
|
||||||
"Updating the framework" workflow documented at
|
"Updating the framework" workflow documented at
|
||||||
`/admin/docs/getting-started`, so anything persisted there would be
|
`/admin/docs/getting-started`, so anything persisted there would be
|
||||||
destroyed by the next update) — gitignored per-file, with a tracked
|
destroyed by the next update) — gitignored per-file, with a tracked
|
||||||
`.gitkeep`. New config keys: `db_driver` (only `'sqlite'` implemented),
|
`.gitkeep`. Designed driver-agnostic (no SQLite-only SQL in the mechanism
|
||||||
`db_path`, `db_migrations_dir`. Documented at `/admin/docs/database` and in
|
itself) specifically so MySQL support wouldn't need a retrofit — see that
|
||||||
`AGENTS.md` next to the two-root-split section. Designed driver-agnostic
|
entry below (shipped 2026-07-14) for the connection/config/migration API,
|
||||||
(no SQLite-only SQL in the mechanism itself) so MySQL support, next up in
|
which superseded the single-connection shape (`db_driver`/`db_path`/
|
||||||
the build order, doesn't need a retrofit.
|
`db_migrations_dir` config keys) this entry originally shipped with.
|
||||||
|
|
||||||
### Copy-to-clipboard button on code blocks
|
### Copy-to-clipboard button on code blocks
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,22 @@ use Lib\Db;
|
|||||||
|
|
||||||
require __DIR__ . '/../autoload.php';
|
require __DIR__ . '/../autoload.php';
|
||||||
|
|
||||||
// Db::connection() applies any pending App/migrations/*.sql as a side
|
// Db::connection() applies any pending migrations for that connection as a
|
||||||
// effect of opening the connection (see Lib\Db::migrate()) — this script
|
// side effect of opening it (see Lib\Db::migrate()) — this script triggers
|
||||||
// just triggers that explicitly, e.g. from a deploy script, without
|
// that explicitly for every configured connection, e.g. from a deploy
|
||||||
// serving a request first.
|
// script, without serving a request first.
|
||||||
Db::connection();
|
$config = require __DIR__ . '/../config.php';
|
||||||
|
|
||||||
echo "Migrations applied.\n";
|
$appConfigFile = __DIR__ . '/../../App/config.php';
|
||||||
|
if (is_file($appConfigFile)) {
|
||||||
|
$appConfig = require $appConfigFile;
|
||||||
|
$defaultConnections = $config['db_connections'];
|
||||||
|
$appConnections = $appConfig['db_connections'] ?? [];
|
||||||
|
$config = array_merge($config, $appConfig);
|
||||||
|
$config['db_connections'] = array_merge($defaultConnections, $appConnections);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_keys($config['db_connections']) as $name) {
|
||||||
|
Db::connection($name);
|
||||||
|
echo "Migrated connection '{$name}'.\n";
|
||||||
|
}
|
||||||
|
|||||||
+22
-10
@@ -39,14 +39,26 @@ return [
|
|||||||
'admin_username' => 'admin',
|
'admin_username' => 'admin',
|
||||||
'admin_password_hash' => '',
|
'admin_password_hash' => '',
|
||||||
|
|
||||||
// Lib\Db (see /admin/docs/database). Only 'sqlite' is implemented today
|
// Lib\Db (see /admin/docs/database) — named, simultaneously-usable
|
||||||
// — MySQL support is tracked as a separate Backlog item in
|
// connections, keyed by name; 'default' is the only one required. A
|
||||||
// novaconium/ISSUES.md. db_path deliberately lives outside both
|
// sidecar can use more than one at once, e.g. Db::query(...) (default)
|
||||||
// public/ (must never be web-accessible) and novaconium/ (gets wholly
|
// alongside Db::query(..., 'legacy'). Supported drivers: 'sqlite',
|
||||||
// replaced on a framework update — see /admin/docs/getting-started's
|
// 'mysql'. The default connection's path deliberately lives outside
|
||||||
// "Updating the framework" section) — a top-level data/ directory,
|
// both public/ (must never be web-accessible) and novaconium/ (gets
|
||||||
// project-owned like App/, is the only safe place for it.
|
// wholly replaced on a framework update — see
|
||||||
'db_driver' => 'sqlite',
|
// /admin/docs/getting-started's "Updating the framework" section) — a
|
||||||
'db_path' => __DIR__ . '/../data/novaconium.sqlite',
|
// top-level data/ directory, project-owned like App/, is the only safe
|
||||||
'db_migrations_dir' => __DIR__ . '/../App/migrations',
|
// place for it. migrations_dir is optional per connection; omit it to
|
||||||
|
// never run migrations against that connection (e.g. a read-only
|
||||||
|
// legacy database). NOTE: unlike every other key here, App/config.php
|
||||||
|
// merges into db_connections one level deeper than a normal shallow
|
||||||
|
// override — see the comment on Lib\Db::config() — so adding a second
|
||||||
|
// connection there doesn't require repeating 'default'.
|
||||||
|
'db_connections' => [
|
||||||
|
'default' => [
|
||||||
|
'driver' => 'sqlite',
|
||||||
|
'path' => __DIR__ . '/../data/novaconium.sqlite',
|
||||||
|
'migrations_dir' => __DIR__ . '/../App/migrations',
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
+115
-41
@@ -6,10 +6,20 @@ use PDO;
|
|||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A thin PDO wrapper — the SQLite groundwork tracked in novaconium/ISSUES.md.
|
* A thin PDO wrapper — the SQLite/MySQL groundwork tracked in
|
||||||
* No ORM, no query builder, consistent with this project's no-Composer,
|
* novaconium/ISSUES.md. No ORM, no query builder, consistent with this
|
||||||
* no-build-step philosophy: just a lazily-opened PDO connection plus a
|
* project's no-Composer, no-build-step philosophy: just lazily-opened PDO
|
||||||
* minimal migration runner.
|
* connections plus a minimal per-connection migration runner.
|
||||||
|
*
|
||||||
|
* Supports multiple, simultaneously-open, independently-configured named
|
||||||
|
* connections (config['db_connections'], keyed by name) rather than one
|
||||||
|
* global connection — because sidecars are plain PHP with full access to
|
||||||
|
* any Lib\ class, a single request may legitimately need more than one
|
||||||
|
* database at once (e.g. this site's own SQLite data plus a MySQL
|
||||||
|
* connection to a legacy/external database). The common single-database
|
||||||
|
* case still reads the same as a single-connection API would:
|
||||||
|
* Db::query('SELECT ...', [...]) always targets the 'default' connection
|
||||||
|
* unless a different connection name is passed explicitly.
|
||||||
*
|
*
|
||||||
* Db::query() is the only query-running helper, and it only ever accepts a
|
* Db::query() is the only query-running helper, and it only ever accepts a
|
||||||
* SQL string plus a params array for PDO to bind — there is deliberately no
|
* SQL string plus a params array for PDO to bind — there is deliberately no
|
||||||
@@ -18,76 +28,125 @@ use RuntimeException;
|
|||||||
* string concatenation or sanitize-then-interpolate, however "cleaned" input
|
* string concatenation or sanitize-then-interpolate, however "cleaned" input
|
||||||
* looks. Call Db::connection() directly for anything Db::query() doesn't
|
* looks. Call Db::connection() directly for anything Db::query() doesn't
|
||||||
* cover (transactions, lastInsertId(), etc.) — it returns the raw PDO
|
* cover (transactions, lastInsertId(), etc.) — it returns the raw PDO
|
||||||
* instance.
|
* instance for the named connection.
|
||||||
*
|
*
|
||||||
* Lazy-connect, same shape as Lib\Csrf's lazy session start: nothing opens
|
* Lazy-connect, same shape as Lib\Csrf's lazy session start: nothing opens
|
||||||
* a database file or runs a migration until the first real call, so a
|
* a database connection or runs a migration until the first real call to a
|
||||||
* request that never touches the database never pays for it.
|
* given connection name, so a request that never touches a particular
|
||||||
|
* database never pays for it.
|
||||||
*/
|
*/
|
||||||
final class Db
|
final class Db
|
||||||
{
|
{
|
||||||
private static ?PDO $connection = null;
|
/** @var array<string, PDO> */
|
||||||
|
private static array $connections = [];
|
||||||
|
|
||||||
public static function connection(): PDO
|
public static function connection(string $name = 'default'): PDO
|
||||||
{
|
{
|
||||||
return self::$connection ??= self::connect();
|
return self::$connections[$name] ??= self::connect($name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int|string,mixed> $params
|
* @param array<int|string,mixed> $params
|
||||||
*/
|
*/
|
||||||
public static function query(string $sql, array $params = []): \PDOStatement
|
public static function query(string $sql, array $params = [], string $connection = 'default'): \PDOStatement
|
||||||
{
|
{
|
||||||
$statement = self::connection()->prepare($sql);
|
$statement = self::connection($connection)->prepare($sql);
|
||||||
$statement->execute($params);
|
$statement->execute($params);
|
||||||
|
|
||||||
return $statement;
|
return $statement;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function connect(): PDO
|
private static function connect(string $name): PDO
|
||||||
{
|
{
|
||||||
$config = self::config();
|
$connections = self::config()['db_connections'];
|
||||||
|
|
||||||
if ($config['db_driver'] !== 'sqlite') {
|
if (!isset($connections[$name])) {
|
||||||
throw new RuntimeException(
|
throw new RuntimeException(
|
||||||
"Unsupported db_driver '{$config['db_driver']}' — only 'sqlite' is implemented so far " .
|
"No db_connections entry named '{$name}' in config — configured connections: " .
|
||||||
'(MySQL support is tracked separately in novaconium/ISSUES.md).'
|
(empty($connections) ? '(none)' : implode(', ', array_keys($connections)))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$path = $config['db_path'];
|
$connectionConfig = $connections[$name];
|
||||||
$dir = dirname($path);
|
$driver = $connectionConfig['driver'] ?? null;
|
||||||
if (!is_dir($dir)) {
|
|
||||||
mkdir($dir, 0775, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo = new PDO('sqlite:' . $path);
|
$pdo = match ($driver) {
|
||||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
'sqlite' => self::connectSqlite($connectionConfig),
|
||||||
$pdo->exec('PRAGMA foreign_keys = ON');
|
'mysql' => self::connectMysql($connectionConfig),
|
||||||
|
default => throw new RuntimeException(
|
||||||
|
"Connection '{$name}' has unsupported driver " .
|
||||||
|
(is_string($driver) ? "'{$driver}'" : 'null') . " — only 'sqlite' and 'mysql' are implemented."
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
self::migrate($pdo, $config['db_migrations_dir']);
|
self::migrate($pdo, $connectionConfig['migrations_dir'] ?? null);
|
||||||
|
|
||||||
return $pdo;
|
return $pdo;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies any *.sql file under $migrationsDir not yet recorded in
|
* @param array<string,mixed> $config
|
||||||
* schema_migrations, in filename order — so migrations are named with a
|
|
||||||
* numeric prefix (0001_create_x.sql, 0002_add_y.sql, ...) to control
|
|
||||||
* apply order. Each file is tracked by filename once applied and never
|
|
||||||
* re-run. Runs automatically on every first connection() call per
|
|
||||||
* process — cheap (one query plus a directory glob), so no separate
|
|
||||||
* "migrate" step is required, matching the framework's zero-config
|
|
||||||
* philosophy elsewhere (e.g. static caching). novaconium/bin/migrate.php
|
|
||||||
* exists for running it explicitly (e.g. from a deploy script) without
|
|
||||||
* serving a request first.
|
|
||||||
*/
|
*/
|
||||||
private static function migrate(PDO $pdo, string $migrationsDir): void
|
private static function connectSqlite(array $config): PDO
|
||||||
{
|
{
|
||||||
|
$path = $config['path'];
|
||||||
|
$dir = dirname($path);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
mkdir($dir, 0775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = new PDO('sqlite:' . $path, options: [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
]);
|
||||||
|
$pdo->exec('PRAGMA foreign_keys = ON');
|
||||||
|
|
||||||
|
return $pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $config
|
||||||
|
*/
|
||||||
|
private static function connectMysql(array $config): PDO
|
||||||
|
{
|
||||||
|
$charset = $config['charset'] ?? 'utf8mb4';
|
||||||
|
$dsn = "mysql:host={$config['host']};port={$config['port']};dbname={$config['database']};charset={$charset}";
|
||||||
|
|
||||||
|
return new PDO($dsn, $config['username'], $config['password'], [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies any *.sql file under $migrationsDir not yet recorded in this
|
||||||
|
* connection's own schema_migrations table, in filename order — so
|
||||||
|
* migrations are named with a numeric prefix (0001_create_x.sql,
|
||||||
|
* 0002_add_y.sql, ...) to control apply order. Each file is tracked by
|
||||||
|
* filename once applied and never re-run. Each connection tracks its
|
||||||
|
* own schema_migrations table in its own database, independent of any
|
||||||
|
* other configured connection. A connection with no migrations_dir set
|
||||||
|
* skips this entirely — e.g. a connection to a legacy database this
|
||||||
|
* project shouldn't manage schema for.
|
||||||
|
*
|
||||||
|
* Runs automatically on every first connection() call per process, per
|
||||||
|
* connection name — cheap (one query plus a directory glob), so no
|
||||||
|
* separate "migrate" step is required, matching the framework's
|
||||||
|
* zero-config philosophy elsewhere (e.g. static caching).
|
||||||
|
* novaconium/bin/migrate.php exists to run it explicitly for every
|
||||||
|
* configured connection (e.g. from a deploy script) without serving a
|
||||||
|
* request first.
|
||||||
|
*/
|
||||||
|
private static function migrate(PDO $pdo, ?string $migrationsDir): void
|
||||||
|
{
|
||||||
|
if ($migrationsDir === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$pdo->exec(
|
$pdo->exec(
|
||||||
'CREATE TABLE IF NOT EXISTS schema_migrations (' .
|
'CREATE TABLE IF NOT EXISTS schema_migrations (' .
|
||||||
'filename TEXT PRIMARY KEY, ' .
|
'filename VARCHAR(255) PRIMARY KEY, ' .
|
||||||
'applied_at TEXT NOT NULL' .
|
'applied_at VARCHAR(32) NOT NULL' .
|
||||||
')'
|
')'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -115,7 +174,18 @@ final class Db
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{db_driver: string, db_path: string, db_migrations_dir: string}
|
* Loads config the same way bootstrap.php/bin scripts do (framework
|
||||||
|
* defaults shallow-merged with App/config.php), except for
|
||||||
|
* db_connections specifically: a shallow array_merge would let a
|
||||||
|
* project's App/config.php silently drop the framework's 'default'
|
||||||
|
* connection just by adding a second named connection (array_merge
|
||||||
|
* replaces the whole key, it doesn't merge inside it). db_connections
|
||||||
|
* is therefore merged one level deeper, by connection name, so adding
|
||||||
|
* e.g. 'legacy' in App/config.php doesn't require repeating 'default'.
|
||||||
|
* This is the one config key in the project that isn't plain
|
||||||
|
* shallow-merge — see AGENTS.md.
|
||||||
|
*
|
||||||
|
* @return array{db_connections: array<string, array<string, mixed>>}
|
||||||
*/
|
*/
|
||||||
private static function config(): array
|
private static function config(): array
|
||||||
{
|
{
|
||||||
@@ -123,7 +193,11 @@ final class Db
|
|||||||
|
|
||||||
$appConfigFile = __DIR__ . '/../../App/config.php';
|
$appConfigFile = __DIR__ . '/../../App/config.php';
|
||||||
if (is_file($appConfigFile)) {
|
if (is_file($appConfigFile)) {
|
||||||
$config = array_merge($config, require $appConfigFile);
|
$appConfig = require $appConfigFile;
|
||||||
|
$defaultConnections = $config['db_connections'];
|
||||||
|
$appConnections = $appConfig['db_connections'] ?? [];
|
||||||
|
$config = array_merge($config, $appConfig);
|
||||||
|
$config['db_connections'] = array_merge($defaultConnections, $appConnections);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $config;
|
return $config;
|
||||||
|
|||||||
@@ -4,44 +4,75 @@
|
|||||||
|
|
||||||
{% block title %}Database{% endblock %}
|
{% 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 robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
{% block docs_content %}
|
{% block docs_content %}
|
||||||
<h1>Database</h1>
|
<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>
|
<h2>Using it</h2>
|
||||||
|
|
||||||
<pre><code>use Lib\Db;
|
<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><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>
|
<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
|
<pre><code><?php
|
||||||
// App/config.php
|
// App/config.php
|
||||||
return [
|
return [
|
||||||
'db_driver' => 'sqlite',
|
'db_connections' => [
|
||||||
'db_path' => __DIR__ . '/../data/novaconium.sqlite',
|
'legacy' => [
|
||||||
'db_migrations_dir' => __DIR__ . '/migrations',
|
'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>
|
];</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>
|
<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
|
<pre><code>-- App/migrations/0001_create_posts.sql
|
||||||
CREATE TABLE posts (
|
CREATE TABLE posts (
|
||||||
@@ -50,9 +81,9 @@ CREATE TABLE posts (
|
|||||||
body TEXT NOT NULL
|
body TEXT NOT NULL
|
||||||
);</code></pre>
|
);</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>
|
<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 %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user