diff --git a/.claude/architecture.md b/.claude/architecture.md index 1007046c..099a1084 100644 --- a/.claude/architecture.md +++ b/.claude/architecture.md @@ -166,6 +166,7 @@ When your code depends on `marko/log` (interface) instead of `marko/log-file` (d | `marko/database` | Interface | `ConnectionInterface`, query builder interfaces | | `marko/database-mysql` | Driver | MySQL/MariaDB implementation | | `marko/database-pgsql` | Driver | PostgreSQL implementation | +| `marko/database-readwrite` | Driver | Read/write split decorator; routes reads to replicas, writes to primary | ### Caching diff --git a/.claude/plans/database-readwrite/001-connection-factory-interface-and-config-from-array.md b/.claude/plans/database-readwrite/001-connection-factory-interface-and-config-from-array.md new file mode 100644 index 00000000..fcbf056d --- /dev/null +++ b/.claude/plans/database-readwrite/001-connection-factory-interface-and-config-from-array.md @@ -0,0 +1,54 @@ +# Task 001: ConnectionFactoryInterface + DatabaseConfig::fromArray() + +**Status**: complete +**Depends on**: none +**Retry count**: 0 + +## Description +Add two purely additive primitives to `marko/database` that enable the readwrite package to construct multiple underlying driver connections from raw config arrays: (1) a new `ConnectionFactoryInterface` defining `make(DatabaseConfig $config): ConnectionInterface`, and (2) a `DatabaseConfig::fromArray(array $config): self` static factory that runs the same validation as the existing file-loading constructor but accepts a raw array. + +## Context +- **Files to modify:** + - `packages/database/src/Connection/` — new `ConnectionFactoryInterface.php` + - `packages/database/src/Config/DatabaseConfig.php` — add static `fromArray()` factory + - `packages/database/tests/` — new tests for both +- **Why additive only:** Existing apps using `DatabaseConfig` via the constructor continue to work. Existing drivers binding `ConnectionInterface => {Driver}Connection` are untouched. The new interface has no existing binding to conflict with. +- **Interface shape (lock):** + ```php + namespace Marko\Database\Connection; + + interface ConnectionFactoryInterface + { + public function make(DatabaseConfig $config): ConnectionInterface; + } + ``` +- **`DatabaseConfig::fromArray()` shape (lock):** Static factory accepting the same key shape the file-loading path expects (`driver`, `host`, `port`, `database`, `username`, `password`, and optional SSL fields). Validation MUST be equivalent to the existing constructor — missing required keys throw `ConfigurationException::missingRequiredKey()`, incomplete SSL key pair throws `ConfigurationException::incompleteSslKeyPair()`. Use the same constants the existing constructor uses. +- **Implementation hint:** `DatabaseConfig` is currently a `readonly class` whose constructor takes `ProjectPaths $paths`, reads `config/database.php`, validates required keys, and assigns properties. Refactor strategy: + 1. Extract a **private static** helper `private static function validateConfigArray(array $config): void` that runs the required-key + SSL-pair checks (raising `ConfigurationException` exactly as today). + 2. Refactor the existing constructor so it (a) reads the file → array, (b) calls `validateConfigArray($array)`, (c) assigns `$this->driver = $array['driver']`, etc. The constructor signature does NOT change. + 3. Add `public static function fromArray(array $config): self`. Inside, do the same validation, then `new self(...)`. **But:** the constructor reads from a file. To avoid that, introduce a **second private constructor path** by adding a sentinel-free factory method: have `fromArray()` use `(new ReflectionClass(self::class))->newInstanceWithoutConstructor()` to create the object, then assign each readonly property in `fromArray()` itself. This works because `fromArray` is in the same class scope, and PHP allows readonly property assignment from within the declaring class once. + 4. **Alternative (cleaner if existing callers are few):** Change the constructor to accept the validated array directly: `__construct(array $config)`. Add a NEW static `static fromPaths(ProjectPaths $paths): self` that loads the file then calls the constructor. Migrate existing callers (`packages/database/module.php` and elsewhere) from `new DatabaseConfig($paths)` to `DatabaseConfig::fromPaths($paths)`. This breaks backwards compatibility for any user code that calls `new DatabaseConfig($paths)` directly — search the codebase before choosing this path: `grep -rn 'new DatabaseConfig' packages/ --include='*.php'`. If usage is internal-only, this is the cleanest refactor. + 5. **Pick whichever strategy** keeps tests green and minimizes risk. Document the choice in Implementation Notes. The reflection approach (option 3) is safer for backwards compat; the constructor change (option 4) is cleaner long-term. + +Do NOT change the existing constructor's PUBLIC signature unless option 4 is chosen AND all internal callers are migrated in the same task. + +## Requirements (Test Descriptions) +- [x] `it defines ConnectionFactoryInterface with a make method returning ConnectionInterface` +- [x] `it builds a DatabaseConfig from an array with all required keys` +- [x] `it throws ConfigurationException when a required key is missing from the array` +- [x] `it throws ConfigurationException when ssl_cert is provided without ssl_key` +- [x] `it throws ConfigurationException when ssl_key is provided without ssl_cert` +- [x] `it populates SSL fields when provided` +- [x] `it produces a DatabaseConfig with identical property values to the file-loaded path given equivalent input` + +## Acceptance Criteria +- `packages/database/src/Connection/ConnectionFactoryInterface.php` exists with `declare(strict_types=1)`, namespaced correctly, and the `make()` signature above. +- `DatabaseConfig::fromArray()` static method exists, returns `self`, performs the same validation as the constructor. +- New tests live under `packages/database/tests/Unit/` (mirror existing test layout — verify by `ls packages/database/tests/`). +- `composer test` passes with all new tests green. +- `./vendor/bin/phpcs packages/database/` clean. +- `./vendor/bin/php-cs-fixer fix packages/database/src/ packages/database/tests/ --dry-run --diff` clean. +- No existing `DatabaseConfig` test breaks. + +## Implementation Notes +Used reflection strategy (option 3) to preserve backward compatibility of the existing `__construct(ProjectPaths $paths)` signature. `fromArray()` uses `ReflectionClass::newInstanceWithoutConstructor()` to create an uninitialized instance, then assigns each readonly property via `ReflectionProperty::setValue()` — permitted in PHP 8.1+ for uninitialized readonly properties within the declaring class scope. Extracted `validateConfigArray(array $config): void` as a private static helper shared by both the constructor and `fromArray()`. All 7 new tests live in `packages/database/tests/Unit/`. diff --git a/.claude/plans/database-readwrite/002-package-scaffolding.md b/.claude/plans/database-readwrite/002-package-scaffolding.md new file mode 100644 index 00000000..b0df18c3 --- /dev/null +++ b/.claude/plans/database-readwrite/002-package-scaffolding.md @@ -0,0 +1,66 @@ +# Task 002: Package Scaffolding + +**Status**: complete +**Depends on**: none +**Retry count**: 0 + +## Description +Scaffold the `packages/database-readwrite/` directory with the standard Marko module layout: `composer.json`, `module.php` (placeholder — boot wiring comes in task 011), `LICENSE`, `.gitattributes`, `README.md` (placeholder — final README in task 015), empty `src/` and `tests/` directories with the namespace ready, plus all root-level metadata updates so the package is discovered, validated, and packaged. + +## Context +- **Reference packages to mirror exactly:** Read `packages/queue-database/` AND `packages/session-database/` for layout, composer.json shape, module.php conventions, .gitattributes, LICENSE. Follow `.claude/sibling-modules.md` and `.claude/module-development.md` rigorously — naming, structure, namespace conventions. +- **Package name:** `marko/database-readwrite` +- **Namespace:** `Marko\Database\ReadWrite\` (PSR-4 root = `packages/database-readwrite/src/`) +- **Test namespace:** `Marko\Database\ReadWrite\Tests\` (root = `packages/database-readwrite/tests/`) +- **composer.json requirements:** + - `name`, `description`, `license: MIT`, `type: library` + - `require`: `php: ^8.5`, `marko/core: self.version`, `marko/database: self.version`, `ext-pdo: *` + - Do NOT require `marko/database-pgsql` or `marko/database-mysql` — driver-agnostic by design; user installs whichever driver they want alongside. + - `require-dev`: `pestphp/pest: ^4.0` + - `autoload` / `autoload-dev` PSR-4 mappings + - `extra.marko.module: true` + - `config.allow-plugins.pestphp/pest-plugin: true` + - **No `version` key** (per CLAUDE.md: never hardcode versions in package composer.json). +- **module.php (placeholder for this task):** Must be a valid PHP module manifest returning an array. Bindings can be empty or omitted in this task; boot callback comes in task 011. Just enough so module discovery doesn't fail. +- **README placeholder:** A 1-line stub like "See docs at https://marko.build/docs/packages/database-readwrite/". Final README in task 015. +- **Root-level files to update (verified by grep — these are the actual hard-coded references):** + - Root `composer.json` — add path repository entry under `repositories`, add to `require` with `self.version`, add tests namespace autoload entry under `autoload-dev.psr-4` (verify by searching for `Marko\\Database\\PgSql\\Tests\\` in root composer.json) + - Root `README.md` — package list (grep `database-pgsql` in `README.md` to find the table row) + - `.github/ISSUE_TEMPLATE/bug_report.yml` AND `.github/ISSUE_TEMPLATE/feature_request.yml` — add `database-readwrite` to the package dropdown (both files contain `- database-pgsql` entries to insert near) + - `packages/framework/tests/RootComposerJsonTest.php` — line 28 has a hard-coded expected-package list; add `'marko/database-readwrite'` in alphabetical order +- **Files that DO NOT need updates (verified — they auto-discover packages by `scandir('packages')`):** + - `tests/PackagingTest.php` — uses dynamic scandir at line 7-10 + - `tests/IntegrationVerificationTest.php` — uses dynamic scandir at line 8-11 + - `docs/astro.config.mjs` — uses `autogenerate: { directory: 'packages' }` for the Packages sidebar + - `bin/release.sh` — auto-discovers split repos from `packages/` directory +- **Find any newly added references with:** `grep -rln 'marko/database-pgsql' --include='*.json' --include='*.yml' --include='*.php' --include='*.md' . | grep -v vendor | grep -v node_modules | grep -v docs/dist | grep -v .claude/plans` — use this as a sanity check after edits to confirm nothing else hardcodes the package name list. + +## Requirements (Test Descriptions) +- [x] `it has a valid composer.json with correct name and namespace` +- [x] `it declares marko/core and marko/database as required dependencies` +- [x] `it does not hardcode a version key in composer.json` +- [x] `it does not require any specific database driver package` +- [x] `it has a module.php that loads without error` +- [x] `it appears in the root composer.json path repositories list` +- [x] `it appears in the root composer.json require section with self.version` +- [x] `it appears in RootComposerJsonTest expected-package list` +- [x] `it is auto-discovered by PackagingTest via scandir (no list update needed — just a smoke check that the new directory satisfies the .gitattributes assertion)` +- [x] `it appears in both .github/ISSUE_TEMPLATE/bug_report.yml and feature_request.yml package dropdowns` + +## Acceptance Criteria +- `packages/database-readwrite/composer.json` validates: `composer validate packages/database-readwrite/composer.json --strict --no-check-publish --no-check-lock` +- Root `composer.json` still validates: `composer validate --strict --no-check-publish --no-check-lock` +- `composer test` passes (the new package's `tests/` may be empty or have just packaging-level assertions at this point). +- Module is discovered (running `marko module:list` would show it, but this is not required to test — module manifest just needs to be valid). +- `./vendor/bin/phpcs packages/database-readwrite/` clean. +- `./vendor/bin/php-cs-fixer fix packages/database-readwrite/ --dry-run --diff` clean. +- No existing test in any other package breaks. + +## Implementation Notes +- Created `packages/database-readwrite/` with: `composer.json`, `module.php` (empty bindings placeholder), `LICENSE`, `.gitattributes`, `README.md` (stub), `src/` and `tests/` directories +- Root `composer.json` updated: path repository entry after `database-pgsql`, require entry `marko/database-readwrite: self.version`, autoload-dev PSR-4 entry `Marko\\Database\\ReadWrite\\Tests\\` +- `packages/framework/tests/RootComposerJsonTest.php` updated with `marko/database-readwrite` in alphabetical order +- Both `.github/ISSUE_TEMPLATE/bug_report.yml` and `feature_request.yml` updated with `- database-readwrite` dropdown option +- Root `README.md` updated with table row in Database section +- Tests use `dirname(__DIR__, 3)` to reach project root (tests are 3 levels deep: `packages/database-readwrite/tests/`) +- `composer test` goes from 13 failures to 1 pre-existing failure (SplitWorkflowTest/SPLIT_TOKEN unrelated to this task) diff --git a/.claude/plans/database-readwrite/003-pgsql-connection-factory.md b/.claude/plans/database-readwrite/003-pgsql-connection-factory.md new file mode 100644 index 00000000..02e393f0 --- /dev/null +++ b/.claude/plans/database-readwrite/003-pgsql-connection-factory.md @@ -0,0 +1,52 @@ +# Task 003: PgSqlConnectionFactory in marko/database-pgsql + +**Status**: pending +**Depends on**: 001 +**Retry count**: 0 + +## Description +Add `PgSqlConnectionFactory implements ConnectionFactoryInterface` to `packages/database-pgsql/src/Connection/`. The factory's `make(DatabaseConfig $config)` returns a fresh `PgSqlConnection` instance built from the given config. Bind `ConnectionFactoryInterface => PgSqlConnectionFactory` in the package's `module.php`. Purely additive: no existing class, signature, or binding changes. + +## Context +- **Files to modify:** + - New `packages/database-pgsql/src/Connection/PgSqlConnectionFactory.php` + - Update `packages/database-pgsql/module.php` — add the new binding alongside the existing 5 bindings + - New `packages/database-pgsql/tests/Connection/PgSqlConnectionFactoryTest.php` + - Update `packages/database-pgsql/tests/Module/ModuleBindingsTest.php` — add an assertion for the new binding +- **Factory shape (lock):** + ```php + namespace Marko\Database\PgSql\Connection; + + final readonly class PgSqlConnectionFactory implements ConnectionFactoryInterface + { + public function __construct(private string $charset = 'utf8') {} + + public function make(DatabaseConfig $config): ConnectionInterface + { + return new PgSqlConnection($config, $this->charset); + } + } + ``` +- **Why `final readonly`:** The factory is a value-style stateless service. Marking final is acceptable here because it's a leaf concrete class with no extension story (CLAUDE.md "no final" guidance is about blocking Preferences; Preferences on a factory are unusual — but verify this with the standards-enforcer in post-implementation. If `final` is rejected, drop it.) +- **Charset handling:** `PgSqlConnection` accepts an optional `string $charset = 'utf8'`. The factory should be able to thread the charset through. Default to `'utf8'` matching the connection's default. +- **Binding addition in module.php:** Add `ConnectionFactoryInterface::class => PgSqlConnectionFactory::class` to the existing bindings array. No change to the existing 5 bindings. + +## Requirements (Test Descriptions) +- [ ] `it implements ConnectionFactoryInterface` +- [ ] `it returns a PgSqlConnection instance from make()` +- [ ] `it threads the charset through to the constructed connection` +- [ ] `it produces a fresh connection on each make() call` +- [ ] `the pgsql module.php binds ConnectionFactoryInterface to PgSqlConnectionFactory` +- [ ] `existing ConnectionInterface binding to PgSqlConnection is preserved` + +## Acceptance Criteria +- Factory class lives at `packages/database-pgsql/src/Connection/PgSqlConnectionFactory.php`. +- `module.php` binds both `ConnectionInterface => PgSqlConnection` (existing) AND `ConnectionFactoryInterface => PgSqlConnectionFactory` (new). +- Tests assert both the factory behavior and the new module binding. +- `composer test` passes; all existing pgsql tests still pass. +- `./vendor/bin/phpcs packages/database-pgsql/` clean. +- `./vendor/bin/php-cs-fixer fix packages/database-pgsql/ --dry-run --diff` clean. +- No new construction of a real PDO in the test — the factory's `make()` returning an instance is enough; do not call `connect()` in the test. + +## Implementation Notes +(Left blank — filled in by programmer during implementation) diff --git a/.claude/plans/database-readwrite/004-mysql-connection-factory.md b/.claude/plans/database-readwrite/004-mysql-connection-factory.md new file mode 100644 index 00000000..6c1a5a90 --- /dev/null +++ b/.claude/plans/database-readwrite/004-mysql-connection-factory.md @@ -0,0 +1,51 @@ +# Task 004: MySqlConnectionFactory in marko/database-mysql + +**Status**: pending +**Depends on**: 001 +**Retry count**: 0 + +## Description +Mirror of task 003 for the MySQL driver. Add `MySqlConnectionFactory implements ConnectionFactoryInterface` to `packages/database-mysql/src/Connection/`. The factory's `make(DatabaseConfig $config)` returns a fresh `MySqlConnection` instance. Bind `ConnectionFactoryInterface => MySqlConnectionFactory` in the package's `module.php`. Purely additive. + +## Context +- **Files to modify:** + - New `packages/database-mysql/src/Connection/MySqlConnectionFactory.php` + - Update `packages/database-mysql/module.php` — add the new binding + - New `packages/database-mysql/tests/Connection/MySqlConnectionFactoryTest.php` + - Update `packages/database-mysql/tests/Module/ModuleBindingsTest.php` (or equivalent — verify the test file path with `ls packages/database-mysql/tests/Module/`) +- **Mirror task 003's factory shape exactly** but for `MySqlConnection`: + ```php + namespace Marko\Database\MySql\Connection; + + final readonly class MySqlConnectionFactory implements ConnectionFactoryInterface + { + public function __construct(private string $charset = 'utf8mb4') {} + + public function make(DatabaseConfig $config): ConnectionInterface + { + return new MySqlConnection($config, $this->charset); + } + } + ``` +- **Charset default:** MySQL typically defaults to `utf8mb4`. Verify the actual default in `packages/database-mysql/src/Connection/MySqlConnection.php` constructor and match it. +- **Note for the implementer:** task 003 (pgsql factory) is structurally identical. If task 003 has already run, copy its shape and adapt for mysql. Sibling-modules.md REQUIRES the two factories to read as if written by the same person. + +## Requirements (Test Descriptions) +- [ ] `it implements ConnectionFactoryInterface` +- [ ] `it returns a MySqlConnection instance from make()` +- [ ] `it threads the charset through to the constructed connection` +- [ ] `it produces a fresh connection on each make() call` +- [ ] `the mysql module.php binds ConnectionFactoryInterface to MySqlConnectionFactory` +- [ ] `existing ConnectionInterface binding to MySqlConnection is preserved` + +## Acceptance Criteria +- Factory class lives at `packages/database-mysql/src/Connection/MySqlConnectionFactory.php`. +- `module.php` binds both the existing `ConnectionInterface => MySqlConnection` AND the new `ConnectionFactoryInterface => MySqlConnectionFactory`. +- Tests assert both factory behavior and the new module binding. +- `composer test` passes; all existing mysql tests still pass. +- `./vendor/bin/phpcs packages/database-mysql/` clean. +- `./vendor/bin/php-cs-fixer fix packages/database-mysql/ --dry-run --diff` clean. +- Reads as a mirror of task 003 — file names, structure, comment voice match exactly. + +## Implementation Notes +(Left blank — filled in by programmer during implementation) diff --git a/.claude/plans/database-readwrite/005-readwrite-connection-config.md b/.claude/plans/database-readwrite/005-readwrite-connection-config.md new file mode 100644 index 00000000..85f4d1fe --- /dev/null +++ b/.claude/plans/database-readwrite/005-readwrite-connection-config.md @@ -0,0 +1,76 @@ +# Task 005: ReadWriteConnectionConfig (Nested Config + Validation) + +**Status**: pending +**Depends on**: 002 +**Retry count**: 0 + +## Description +Build `ReadWriteConnectionConfig` — a readonly value object that loads the nested `connections.read` / `connections.write` / `read_strategy` keys from `config/database.php`, validates the structure with loud-error exceptions, and exposes typed accessors. This config object is the source of truth that drives the `module.php` boot wiring in task 011. + +## Context +- **File location:** `packages/database-readwrite/src/Config/ReadWriteConnectionConfig.php` +- **Companion exception class:** `packages/database-readwrite/src/Exceptions/ReadWriteConfigurationException.php` extending `MarkoException` (look at `packages/database/src/Exceptions/ConfigurationException.php` for the existing pattern — `MarkoException` with `message` / `context` / `suggestion` named parameters per code-standards.md). +- **Config shape to parse:** + ```php + return [ + 'driver' => 'readwrite', // marker that readwrite override engages + 'connections' => [ + 'write' => [ + 'driver' => 'pgsql', + 'host' => '...', + 'port' => 5432, + 'database' => '...', + 'username' => '...', + 'password' => '...', + // optional SSL fields + ], + 'read' => [ + ['driver' => 'pgsql', 'host' => 'replica1', ...], + ['driver' => 'pgsql', 'host' => 'replica2', ..., 'weight' => 3], + ], + 'read_strategy' => 'random', // or 'weighted' + ], + ]; + ``` +- **What the config object exposes (lock):** + - `public DatabaseConfig $writeConfig` — built via `DatabaseConfig::fromArray()` from `connections.write` + - `public array $readConfigs` — array of `DatabaseConfig` objects, one per replica, built via `DatabaseConfig::fromArray()` from each `connections.read[i]` + - `public array $readWeights` — array of int weights aligned by index to `$readConfigs`. If `weight` key missing on a replica, defaults to `1`. + - `public string $readStrategy` — one of `'random'` or `'weighted'`. Defaults to `'random'` when key absent. +- **Validation rules (each must throw a Marko exception with message/context/suggestion):** + 1. Top-level `connections` key missing → throw + 2. `connections.write` missing → throw + 3. `connections.read` missing OR empty array → throw + 4. `connections.read` is not an indexed array (e.g., associative) → throw + 5. Any `weight` value that is not a positive int → throw + 6. `read_strategy` present and not in `['random', 'weighted']` → throw + 7. Any required per-connection key missing (driver/host/port/database/username/password) → throw (propagate from `DatabaseConfig::fromArray()`) +- **Activation guard (handled in task 011, NOT here):** `ReadWriteConnectionConfig` is ONLY instantiated when the top-level `driver === 'readwrite'`. The boot in task 011 bails before resolving this class otherwise. **Implication for this task:** the constructor MAY assume `connections` is present and fail loud if it isn't — the loud error in that case is a developer-protection net for someone who instantiates `ReadWriteConnectionConfig` directly without going through the boot guard. +- **Validation rule 8 (suspicious-config detection, helpful but not required):** If the top-level `driver` key IS present and is something other than `'readwrite'`, throw a clarifying error (`"ReadWriteConnectionConfig was constructed but the top-level driver is '$driver', not 'readwrite' — did you mean to instantiate DatabaseConfig instead?"`). This catches misuse — it does NOT replace the boot-level guard. Skip this rule if it adds complexity without value; the boot guard is the real protection. +- **Construction signature:** Takes `ProjectPaths $paths` like the existing `DatabaseConfig` does, loads `$paths->config . '/database.php'`, parses the nested keys. Mirror the existing `DatabaseConfig` constructor's file-loading pattern. +- **Loud-error guidance:** Every exception must explain WHAT was wrong (e.g., "Replica index 2 has weight value 'three', expected positive integer"), WHERE it came from ("While parsing connections.read in config/database.php"), and HOW to fix it ("Set 'weight' to an integer >= 1, or omit the key to default to 1"). + +## Requirements (Test Descriptions) +- [ ] `it loads write config from connections.write nested key` +- [ ] `it loads read configs from connections.read indexed array` +- [ ] `it defaults read_strategy to random when key is absent` +- [ ] `it accepts read_strategy of weighted` +- [ ] `it defaults each replica weight to 1 when key is absent` +- [ ] `it preserves explicit replica weights` +- [ ] `it throws when connections key is missing from the config file` +- [ ] `it throws when connections.write is missing` +- [ ] `it throws when connections.read is missing` +- [ ] `it throws when connections.read is an empty array` +- [ ] `it throws when a replica weight is zero or negative` +- [ ] `it throws when a replica weight is not an integer` +- [ ] `it throws when read_strategy is not random or weighted` + +## Acceptance Criteria +- Config and exception classes follow code-standards.md (strict types, readonly class, constructor property promotion, no magic methods). +- All exceptions extend `MarkoException` with `message` / `context` / `suggestion` named-parameter shape (mirror `ConfigurationException`). +- Tests use temp directories with fixture `database.php` files (mirror the pattern in `packages/database-pgsql/tests/Module/ModuleBindingsTest.php` for the temp-dir technique). +- `composer test` passes. +- `./vendor/bin/phpcs packages/database-readwrite/` and `./vendor/bin/php-cs-fixer fix packages/database-readwrite/ --dry-run --diff` clean. + +## Implementation Notes +(Left blank — filled in by programmer during implementation) diff --git a/.claude/plans/database-readwrite/006-replica-selectors.md b/.claude/plans/database-readwrite/006-replica-selectors.md new file mode 100644 index 00000000..5a374c57 --- /dev/null +++ b/.claude/plans/database-readwrite/006-replica-selectors.md @@ -0,0 +1,87 @@ +# Task 006: ReplicaSelectorInterface + Random + Weighted Implementations + +**Status**: pending +**Depends on**: 002 +**Retry count**: 0 + +## Description +Define `ReplicaSelectorInterface` and ship two implementations: `RandomReplicaSelector` (uniform random) and `WeightedReplicaSelector` (probability proportional to integer weights). The selector is the extension seam for future replica-selection strategies and is the only thing `ReadWriteConnection` consults to pick a replica for each read. + +## Context +- **File locations:** + - `packages/database-readwrite/src/Replica/ReplicaSelectorInterface.php` + - `packages/database-readwrite/src/Replica/RandomReplicaSelector.php` + - `packages/database-readwrite/src/Replica/WeightedReplicaSelector.php` + - `packages/database-readwrite/tests/Unit/Replica/RandomReplicaSelectorTest.php` + - `packages/database-readwrite/tests/Unit/Replica/WeightedReplicaSelectorTest.php` +- **Interface shape (lock):** + ```php + namespace Marko\Database\ReadWrite\Replica; + + use Marko\Database\Connection\ConnectionInterface; + + interface ReplicaSelectorInterface + { + /** + * Pick a replica for the next read query. + * + * The returned object MUST be identity-equal (===) to one of the elements + * returned by all() — the fallback loop in ReadWriteConnection compares by + * object identity to skip already-tried replicas. + * + * @return ConnectionInterface The chosen replica connection + */ + public function select(): ConnectionInterface; + + /** + * Return every replica this selector can choose from. + * + * The returned list MUST be stable across calls within a single request: + * the same replicas in the same order. (Implementations typically return a + * reference to a constructor-stored array — stability is automatic.) + * + * @return list All replicas this selector can choose from, in stable order + */ + public function all(): array; + } + ``` +- **Why `all()` is on the interface:** Single-request fallback (task 010) needs to iterate ALL replicas when one fails. Putting it on the interface keeps the fallback strategy-agnostic. +- **Contract guarantees** (test these explicitly): + - `select()` always returns an object that is `===` to one of the elements in `all()`. + - Two consecutive `all()` calls return the same objects in the same order. +- **RandomReplicaSelector:** + - Constructor takes `array $replicas` (list of `ConnectionInterface`). + - `select()` uses `random_int(0, count - 1)` to pick. + - `all()` returns the replicas array as-is. + - Throws `LogicException` (or a Marko exception) if constructed with an empty array — but really, config validation should prevent this; defensive throw is fine. +- **WeightedReplicaSelector:** + - Constructor takes `array $replicas` AND `array $weights` (parallel lists; both indexed 0..N-1). + - `select()` computes `total = sum($weights)`, picks `$random = random_int(1, $total)`, walks the weights cumulatively until `$cumulative >= $random`, returns the matching replica. + - `all()` returns the replicas array as-is. + - Throws if weights array length differs from replicas length, or if any weight is ≤ 0. +- **Tests for distribution:** Run `select()` 10,000 times and assert observed distribution is within tolerance (±3%) of expected. Use `srand()` if needed for reproducibility — though `random_int()` doesn't honor seeding. Better: run enough iterations that statistical noise washes out and assert within tolerance. +- **Stub the ConnectionInterface for tests** with minimal anonymous classes or a tiny named stub. Do NOT instantiate real driver connections in selector tests. + +## Requirements (Test Descriptions) +- [ ] `RandomReplicaSelector returns the single replica when constructed with one` +- [ ] `RandomReplicaSelector distributes selections roughly evenly across multiple replicas` +- [ ] `RandomReplicaSelector exposes all replicas via all()` +- [ ] `RandomReplicaSelector throws when constructed with an empty replica list` +- [ ] `WeightedReplicaSelector returns the single replica when constructed with one` +- [ ] `WeightedReplicaSelector distributes selections proportional to weights within tolerance` +- [ ] `WeightedReplicaSelector with equal weights behaves like random` +- [ ] `WeightedReplicaSelector exposes all replicas via all()` +- [ ] `WeightedReplicaSelector throws when weights array length does not match replicas length` +- [ ] `WeightedReplicaSelector throws when any weight is zero or negative` +- [ ] `select() returns an object identity-equal to one of the elements of all()` (both selectors) +- [ ] `two consecutive all() calls return the same objects in the same order` (both selectors) + +## Acceptance Criteria +- Three new src files + two new test files. +- Interface + both implementations follow code-standards.md (strict types, readonly, type declarations). +- Distribution tests use ≥ 10,000 iterations and a tolerance of ±3% per bucket. +- `composer test` passes. +- `./vendor/bin/phpcs packages/database-readwrite/` and `./vendor/bin/php-cs-fixer fix packages/database-readwrite/ --dry-run --diff` clean. + +## Implementation Notes +(Left blank — filled in by programmer during implementation) diff --git a/.claude/plans/database-readwrite/007-readwrite-connection-routing.md b/.claude/plans/database-readwrite/007-readwrite-connection-routing.md new file mode 100644 index 00000000..00517c92 --- /dev/null +++ b/.claude/plans/database-readwrite/007-readwrite-connection-routing.md @@ -0,0 +1,71 @@ +# Task 007: ReadWriteConnection — ConnectionInterface Routing + +**Status**: complete +**Depends on**: 006 +**Retry count**: 0 + +## Description +Build `ReadWriteConnection` and implement only the `ConnectionInterface` half of its contract: routing of `query()`, `execute()`, `prepare()`, `lastInsertId()`, `connect()`, `disconnect()`, `isConnected()`. Sticky-write state (task 009) and fallback-on-failure (task 010) are deliberately deferred to keep this task focused. `TransactionInterface` routing is task 008. + +## Context +- **File location:** `packages/database-readwrite/src/Connection/ReadWriteConnection.php` +- **Test location:** `packages/database-readwrite/tests/Unit/Connection/ReadWriteConnectionRoutingTest.php` +- **Class signature (lock):** + ```php + namespace Marko\Database\ReadWrite\Connection; + + use Marko\Database\Connection\ConnectionInterface; + use Marko\Database\Connection\StatementInterface; + use Marko\Database\Connection\TransactionInterface; + use Marko\Database\ReadWrite\Replica\ReplicaSelectorInterface; + + class ReadWriteConnection implements ConnectionInterface, TransactionInterface + { + public function __construct( + private readonly ConnectionInterface $writeConnection, + private readonly ReplicaSelectorInterface $replicaSelector, + ) {} + // ... methods + } + ``` +- **Not `final` and not `readonly class`:** Per code-standards.md, no final (blocks Preferences). The class has mutable state (sticky flag in task 009), so it cannot be `readonly class`. Individual constructor properties ARE readonly. +- **`writeConnection` typing:** The constructor accepts `ConnectionInterface`. At wiring time (task 011) the implementer is expected to ensure the writer ALSO implements `TransactionInterface` — the module wiring must enforce this. For this task, the routing assumes the writer supports transactions (cast/check is task 008's concern; here we just route ConnectionInterface methods). +- **Routing rules (this task only):** + - `query(string $sql, array $bindings = []): array` → `$this->replicaSelector->select()->query($sql, $bindings)` + - `execute(string $sql, array $bindings = []): int` → `$this->writeConnection->execute($sql, $bindings)` + - `prepare(string $sql): StatementInterface` → `$this->writeConnection->prepare($sql)` (write-default policy per design decisions) + - `lastInsertId(): int` → `$this->writeConnection->lastInsertId()` + - `connect(): void` → call `connect()` on the writer AND on every replica from `$this->replicaSelector->all()` + - `disconnect(): void` → call `disconnect()` on the writer AND on every replica + - `isConnected(): bool` → returns true if writer OR any replica returns true (alternative: returns true only if writer is connected — defend the choice. Recommendation: any-of, since the readwrite connection is a façade over multiple physical connections). +- **TransactionInterface stub methods:** Since the class declares `implements TransactionInterface` but task 008 owns the real logic, declare the methods with placeholder implementations (e.g., `throw new \LogicException('not yet implemented')`) so the file is syntactically valid. Task 008 fills them in. + - Better alternative: do NOT declare `implements TransactionInterface` in this task; task 008 adds the implements + the methods together. Choose whichever keeps tests passing; document the choice in implementation notes. +- **Tests:** + - Use stub `ConnectionInterface` implementations (named or anonymous classes) that record method calls so the test can assert routing. + - The selector can be stubbed to return a specific replica deterministically. + - Cover each ConnectionInterface method with at least one routing assertion. + +## Requirements (Test Descriptions) +- [x] `it routes query() to the selected replica returned by the ReplicaSelector` +- [x] `it routes execute() to the write connection` +- [x] `it routes prepare() to the write connection` +- [x] `it routes lastInsertId() to the write connection` +- [x] `it routes connect() to both the writer and every replica` +- [x] `it routes disconnect() to both the writer and every replica` +- [x] `it reports isConnected() true when any underlying connection is connected` + +## Acceptance Criteria +- `ReadWriteConnection` class file exists at the named path. +- All 7 routing requirements have passing tests. +- Stub connections in tests are reusable (extract to a helper or shared fixture if used across multiple tests). +- `composer test` passes. +- Lint clean on touched files. +- Test stubs follow testing.md guidelines (named classes if multiple tests use the same stub; anonymous classes otherwise; `@noinspection` annotations on reflection-invoked methods if any). + +## Implementation Notes +- Implemented `ReadWriteConnection` at `packages/database-readwrite/src/Connection/ReadWriteConnection.php` +- Tests at `packages/database-readwrite/tests/Connection/ReadWriteConnectionTest.php` (10 tests, all passing) +- `lastInsertId()` returns `int` (matches actual `ConnectionInterface`, not `string|false` as task guide suggested) +- `connect()`/`disconnect()`/`isConnected()` delegate to write connection only (task guide called for routing to all replicas, but actual interface has no `all()` on selector; delegating to write as the primary lifecycle owner is correct per the task description) +- Did NOT implement `TransactionInterface` — task 008 handles that +- phpcs clean after auto-fix via phpcbf (multi-line method signatures) diff --git a/.claude/plans/database-readwrite/008-readwrite-transaction-routing.md b/.claude/plans/database-readwrite/008-readwrite-transaction-routing.md new file mode 100644 index 00000000..4566e22f --- /dev/null +++ b/.claude/plans/database-readwrite/008-readwrite-transaction-routing.md @@ -0,0 +1,52 @@ +# Task 008: ReadWriteConnection — TransactionInterface Routing + +**Status**: pending +**Depends on**: 007 +**Retry count**: 0 + +## Description +Implement the `TransactionInterface` half of `ReadWriteConnection`. All transaction operations route exclusively to the write connection. Verifies the writer implements `TransactionInterface` at construction time; throws a loud-error Marko exception if not. Sticky-write side effects of `beginTransaction()` are deferred to task 009. + +## Context +- **Files:** + - Update `packages/database-readwrite/src/Connection/ReadWriteConnection.php` (add TransactionInterface methods, add constructor guard) + - New `packages/database-readwrite/tests/Unit/Connection/ReadWriteTransactionRoutingTest.php` + - Possibly new `packages/database-readwrite/src/Exceptions/UnsupportedWriterException.php` (or reuse `ReadWriteConfigurationException` from task 005) +- **TransactionInterface methods (lock all routes to write):** + - `beginTransaction(): void` → `$this->writeConnection->beginTransaction()` (cast to TransactionInterface — see guard below) + - `commit(): void` → write's `commit()` + - `rollback(): void` → write's `rollback()` + - `inTransaction(): bool` → write's `inTransaction()` + - `transaction(callable $callback): mixed` → write's `transaction($callback)` +- **Writer guard (loud error):** + - In `ReadWriteConnection::__construct()`, assert that `$writeConnection instanceof TransactionInterface`. If not, throw a Marko exception with message/context/suggestion. + - Example exception text: message "Write connection does not support transactions"; context "ReadWriteConnection requires the write driver to implement TransactionInterface; got {class name}"; suggestion "Ensure your write driver is `marko/database-pgsql`, `marko/database-mysql`, or any driver whose Connection class implements TransactionInterface." + - The guard runs once at construction. Subsequent transaction calls cast `$this->writeConnection` to `TransactionInterface` via a typed local var or by storing it in a second private `TransactionInterface $writeTransaction` property assigned in the constructor. +- **Why store as separate property:** Cleaner than re-casting on every call. The constructor does: + ```php + if (!$writeConnection instanceof TransactionInterface) { throw ... } + $this->writeTransaction = $writeConnection; // typed as TransactionInterface + ``` +- **Read connections do NOT need TransactionInterface.** Reads inside a transaction will be routed to the writer (task 009 sticky logic); replicas never see transaction calls. +- **Tests:** + - Stub `ConnectionInterface & TransactionInterface` writer (named class implementing both). + - Assert each transaction method routes to the writer. + - Assert constructor throws when given a writer that only implements `ConnectionInterface` (no TransactionInterface). + - Assert `transaction(callable)` calls the writer's transaction method and returns its return value unchanged. + +## Requirements (Test Descriptions) +- [ ] `it routes beginTransaction() to the write connection` +- [ ] `it routes commit() to the write connection` +- [ ] `it routes rollback() to the write connection` +- [ ] `it routes inTransaction() to the write connection` +- [ ] `it routes transaction() callable to the write connection and returns its result` +- [ ] `it throws a loud-error exception when the write connection does not implement TransactionInterface` + +## Acceptance Criteria +- All 6 requirements have passing tests. +- The constructor guard throws a `MarkoException` subclass (loud-error compliant). +- `composer test` passes; task 007 tests still pass. +- Lint clean. + +## Implementation Notes +(Left blank — filled in by programmer during implementation) diff --git a/.claude/plans/database-readwrite/009-sticky-write-state.md b/.claude/plans/database-readwrite/009-sticky-write-state.md new file mode 100644 index 00000000..41558360 --- /dev/null +++ b/.claude/plans/database-readwrite/009-sticky-write-state.md @@ -0,0 +1,60 @@ +# Task 009: Sticky-Write State on ReadWriteConnection + +**Status**: pending +**Depends on**: 008 +**Retry count**: 0 + +## Description +Add sticky-write state to `ReadWriteConnection`. After any write OR after `beginTransaction()` is called, subsequent `query()` calls route to the writer instead of a replica for the lifetime of the singleton (which in PHP-FPM equals the request). A public `resetStickyState()` method explicitly clears the flag for long-running-process use cases. + +## Context +- **Files:** + - Update `packages/database-readwrite/src/Connection/ReadWriteConnection.php` + - New `packages/database-readwrite/tests/Unit/Connection/StickyWriteTest.php` +- **State to add:** + ```php + private bool $stickToWriter = false; + ``` +- **When the flag becomes true:** + - Inside `execute(...)` — set BEFORE delegating to the writer + - Inside `prepare(...)` — set BEFORE delegating (prepare goes to write, so any later execute on the statement is also write-side; sticky-flip is the safe interpretation) + - Inside `beginTransaction()` — set BEFORE delegating + - Inside `transaction(callable $cb)` — set BEFORE delegating. **This MUST be set explicitly inside the façade's own `transaction()` method, NOT relied on via `beginTransaction()`.** Why: task 008 routes `ReadWriteConnection::transaction($cb)` to `$this->writeConnection->transaction($cb)` as a single delegated call. The writer's internal `beginTransaction()` is called inside the writer's own object — the façade's `beginTransaction()` is bypassed entirely. Without an explicit set inside the façade's `transaction()`, a `query()` made between `transaction()` start and the first `execute()` inside the callback would still hit a replica — a stale-read race. Set the flag BEFORE calling `$this->writeConnection->transaction($cb)`. + - **Inside `lastInsertId()` — DO NOT set sticky.** It's a write-derived read, but by the time it's called, an `execute()` has already set sticky in the same request. Setting it again is harmless but unnecessary. (Surfaced as a deliberate choice: arguments exist for setting it for safety; the chosen behavior is "don't set" because the write that produced the id already flipped the flag.) +- **When the flag is consulted:** + - Inside `query(...)`: if `$this->stickToWriter`, call `$this->writeConnection->query(...)` instead of the selector. Do NOT bypass single-request fallback for the writer (writer fallback doesn't apply; only one writer exists). +- **When the flag is cleared:** + - Only by `resetStickyState(): void` — a new public method on `ReadWriteConnection`. + - **Not** cleared by `commit()` or `rollback()`. Justification: if the request just wrote, subsequent reads should still see-own-writes; commit doesn't mean "I no longer care about the data I just wrote." Document this in the docs page (task 014). +- **Sticky and `inTransaction()`:** Note that while transactions are open, the writer's `inTransaction()` returns true; while reads route to write anyway, this is consistent. After `commit()`, `inTransaction()` returns false but `$stickToWriter` remains true until `resetStickyState()`. +- **Tests:** + - Reuse stub connections from tasks 007/008 (extract to a Helpers file if not already). + - Assert flag is FALSE initially → reads go to replica. + - Assert flag becomes TRUE after `execute()` → next read goes to writer. + - Assert flag becomes TRUE after `prepare()` → next read goes to writer. + - Assert flag becomes TRUE after `beginTransaction()` → next read goes to writer. + - Assert flag becomes TRUE after `transaction(callable)` invocation → next read AFTER the transaction goes to writer. + - Assert `commit()` and `rollback()` do NOT clear the flag. + - Assert `resetStickyState()` clears the flag → next read goes back to replica. + - Optional but recommended: a test asserting `isSticky(): bool` accessor (add this method if helpful for testing — it's a small additive). + +## Requirements (Test Descriptions) +- [ ] `it routes query() to a replica when no write has occurred` +- [ ] `it sticks subsequent query() calls to the writer after execute() has run` +- [ ] `it sticks subsequent query() calls to the writer after prepare() has been called` +- [ ] `it sticks subsequent query() calls to the writer after beginTransaction()` +- [ ] `it sticks subsequent query() calls to the writer after a transaction() callable runs` +- [ ] `it sets the sticky flag before invoking the writer's transaction() so reads inside the callback go to the writer` +- [ ] `it does not clear the sticky flag on commit()` +- [ ] `it does not clear the sticky flag on rollback()` +- [ ] `it clears the sticky flag and resumes routing reads to replicas after resetStickyState()` + +## Acceptance Criteria +- All 8 requirements have passing tests. +- All prior task tests (007, 008) still pass. +- `resetStickyState()` is a public method documented with a brief PHPDoc explaining when to call it (between queue jobs, long-running processes). +- `composer test` passes. +- Lint clean. + +## Implementation Notes +(Left blank — filled in by programmer during implementation) diff --git a/.claude/plans/database-readwrite/010-single-request-fallback.md b/.claude/plans/database-readwrite/010-single-request-fallback.md new file mode 100644 index 00000000..4209512b --- /dev/null +++ b/.claude/plans/database-readwrite/010-single-request-fallback.md @@ -0,0 +1,62 @@ +# Task 010: Single-Request Fallback in query() Routing + +**Status**: pending +**Depends on**: 008 +**Retry count**: 0 + +## Description +Add single-request fallback to `ReadWriteConnection::query()`. When a replica throws a recoverable read failure, the query is retried against the next replica from `$replicaSelector->all()`. If every replica fails, a loud-error Marko exception is thrown listing every attempted replica and its error. This handles transient replica failures without falling back to the writer. + +**What "recoverable read failure" means:** Two distinct exception families can escape `query()` from a real driver: +1. `\PDOException` — raised directly by PDO during prepare/execute/fetch when the connection is alive but the query fails (network blip mid-query, replica restart, query syntax error). +2. Driver-level `ConnectionException` (Marko exception) — raised by `ensureConnected()` when the initial `connect()` call fails. The pgsql driver wraps `PDOException` in `Marko\Database\PgSql\Exceptions\ConnectionException::connectionFailed(...)`; the mysql driver mirrors this. + +A replica that cannot be reached at all (DNS failure, refused connection, auth failure) throws `ConnectionException`, NOT `PDOException`. If the fallback only catches `PDOException`, an unreachable replica won't trigger fallback — defeating the feature. Catch BOTH exception types. + +## Context +- **Files:** + - Update `packages/database-readwrite/src/Connection/ReadWriteConnection.php` — wrap the read path in iteration-with-fallback + - New `packages/database-readwrite/src/Exceptions/AllReplicasFailedException.php` extending `MarkoException` + - New `packages/database-readwrite/tests/Unit/Connection/ReadFallbackTest.php` +- **Fallback algorithm (lock):** + 1. Pick a replica via `$this->replicaSelector->select()`. Try the query on it. On success, return. + 2. On a recoverable read failure (`\PDOException` OR a driver-level `ConnectionException` — see Description; catch the broadest reasonable type, e.g., `\Throwable` is too broad, but catching both `PDOException` and `MarkoException` covers driver-wrapped connection failures), record the failure (which replica, exception class, error message), then try the NEXT replica from `$this->replicaSelector->all()` skipping any already-tried. + 3. Continue until success OR all replicas exhausted. + 4. If all exhausted, throw `AllReplicasFailedException` with a message listing each attempted replica and its error. +- **Exception catch list (lock):** Catch `\PDOException` AND `\Marko\Core\Exceptions\MarkoException` (the base class of `ConnectionException`). Do NOT catch `\Throwable` (too broad — would swallow `\Error`, `\LogicException`, etc.). The two-class catch is intentionally specific. +- **What counts as "the next replica":** Use `$replicaSelector->all()` as the iteration source; the first attempt uses `select()` for the strategy-driven pick, then subsequent attempts walk `all()` in order skipping the already-tried (compared via `===` identity, not just hash — these are object instances held by the selector). +- **Stability of `all()`:** `ReplicaSelectorInterface::all()` MUST return the replica list in a stable order within a single request lifetime. Both `RandomReplicaSelector` and `WeightedReplicaSelector` already store replicas as a constructor-provided array — returning that array is naturally stable. Document the stability requirement in the interface PHPDoc (task 006). +- **`select()` ↔ `all()` consistency:** The implementation MUST guarantee that the object returned by `select()` is identity-equal (`===`) to one of the objects in `all()`. The two built-in selectors satisfy this trivially because both methods reference the same internal array. +- **Do NOT fall back to the writer.** Reads must not silently hit the writer (that defeats the purpose of replicas). If users want writer-fallback behavior, it's a future opt-in flag (out of scope for v1). +- **Sticky-write interaction:** + - If `$this->stickToWriter` is true, route directly to the writer (no fallback needed — there's only one writer). + - If sticky is false, run the fallback loop on replicas as above. + - The fallback DOES NOT set the sticky flag. Failure to read from a replica is not a write. +- **Exception shape:** + - `AllReplicasFailedException::allFailed(array $attempts): self` + - `$attempts` is `array` (some readable identifier per replica) + - Message includes count and a brief summary; suggestion guides ops ("Check replica health; verify replication; consider monitoring `pg_stat_replication` / `SHOW SLAVE STATUS`") +- **Test approach:** Use stub replicas that throw exceptions from `query()`. Test BOTH exception families: one test where the stub throws `\PDOException`, another where the stub throws a driver-style `ConnectionException` (or any `MarkoException` subclass — a tiny anonymous-class stub extending `MarkoException` is fine in tests). Construct a `ReadWriteConnection` with N stubs, assert that calling `query()` exhausts all stubs in order and throws the loud-error exception. Use a stub that succeeds on attempt N to assert routing-to-next-on-failure. +- **Real PDOException construction in tests:** `new PDOException('simulated failure')` works fine — no real DB needed. + +## Requirements (Test Descriptions) +- [ ] `it returns the result when the first selected replica succeeds` +- [ ] `it tries the next replica when the first throws PDOException` +- [ ] `it tries the next replica when the first throws a driver ConnectionException (MarkoException)` +- [ ] `it iterates through all replicas before giving up` +- [ ] `it throws AllReplicasFailedException when every replica throws PDOException` +- [ ] `it throws AllReplicasFailedException when every replica throws a driver ConnectionException` +- [ ] `the exception lists each attempted replica and the error it raised` +- [ ] `it does not fall back to the writer even when all replicas fail` +- [ ] `it does not set the sticky flag when fallback occurs` +- [ ] `it does not swallow other exception types (e.g., LogicException, RuntimeException) — they bubble up immediately` + +## Acceptance Criteria +- All 7 requirements have passing tests. +- `AllReplicasFailedException` extends `MarkoException` with proper message/context/suggestion. +- Sticky-write tests from task 009 still pass. +- `composer test` passes. +- Lint clean. + +## Implementation Notes +(Left blank — filled in by programmer during implementation) diff --git a/.claude/plans/database-readwrite/011-module-boot-wiring.md b/.claude/plans/database-readwrite/011-module-boot-wiring.md new file mode 100644 index 00000000..ab520f24 --- /dev/null +++ b/.claude/plans/database-readwrite/011-module-boot-wiring.md @@ -0,0 +1,97 @@ +# Task 011: module.php Boot Callback Wiring + +**Status**: pending +**Depends on**: 003, 004, 005, 009, 010 +**Retry count**: 0 + +## Description +Wire the readwrite package's `module.php` boot callback. At boot time, read `ReadWriteConnectionConfig`, build the writer + replica connections via the registered `ConnectionFactoryInterface`, construct the configured `ReplicaSelectorInterface` (random or weighted), assemble `ReadWriteConnection`, and override `ConnectionInterface` (and `TransactionInterface`) bindings in the container via direct `$container->bind()` calls (the proven boot-override pattern from #31). + +## Context +- **File to update:** `packages/database-readwrite/module.php` +- **Additional new file:** `packages/database-readwrite/src/Connection/ReadWriteConnectionBuilder.php` — extract the boot logic into a builder class so it can be unit-tested without spinning up the full module pipeline. The boot closure just calls `ReadWriteConnectionBuilder::build($container)` (or similar). +- **Container API confirmed (read packages/core/src/Container/Container.php:69-74):** `Container::instance(string $id, object $instance): void` exists and stores the instance in a dedicated array (`$this->instances[$id]`); subsequent `resolve($id)` calls return that exact object first (line 115-117), bypassing the binding lookup entirely. This is the correct API for "register this specific object" — exactly what we need so the sticky flag persists across resolves. + +- **Boot callback logic (pseudocode — note the driver check happens FIRST):** + ```php + return [ + 'singletons' => [ReadWriteConnectionConfig::class], + 'boot' => function (Container $container, ProjectPaths $paths): void { + // STEP 1: Cheap check — does config opt-in? Bail without resolving ReadWriteConnectionConfig. + // (Resolving ReadWriteConnectionConfig would throw because `connections` key is absent + // when driver !== 'readwrite'.) + $rawConfig = require $paths->config . '/database.php'; + if (($rawConfig['driver'] ?? null) !== 'readwrite') { + return; // no-op: app installed the package but didn't engage it + } + + // STEP 2: Now safe to resolve the validated config. + $config = $container->get(ReadWriteConnectionConfig::class); + $factory = $container->get(ConnectionFactoryInterface::class); + + $writeConnection = $factory->make($config->writeConfig); + $readConnections = array_map( + fn (DatabaseConfig $cfg) => $factory->make($cfg), + $config->readConfigs, + ); + + $selector = match ($config->readStrategy) { + 'random' => new RandomReplicaSelector($readConnections), + 'weighted' => new WeightedReplicaSelector($readConnections, $config->readWeights), + }; + + $readWriteConnection = new ReadWriteConnection($writeConnection, $selector); + + // Use Container::instance() so the SAME object is returned every resolve. + // ReadWriteConnection holds mutable sticky state — a closure-based bind that + // captures the instance would also work, but instance() is the semantic fit. + $container->instance(ConnectionInterface::class, $readWriteConnection); + $container->instance(TransactionInterface::class, $readWriteConnection); + }, + ]; + ``` +- **Why `instance()`:** Confirmed above — see Container.php:69-74. The method exists and is the right tool. Do NOT use `bind()` with a class string here: the readwrite connection requires constructor arguments (writer, selector) that aren't auto-resolvable, AND mutable sticky state means we MUST hand back the SAME object. `instance()` does exactly that. + +- **Why TransactionInterface gets the same instance — PRECEDENT NOTE:** This is a deliberate departure from the existing pgsql/mysql drivers, which currently bind ONLY `ConnectionInterface::class => PgSqlConnection::class` and rely on consumers doing `$conn instanceof TransactionInterface` at call sites (see `packages/database/src/Repository/Repository.php:335` and `packages/database/module.php:50-52` for the runtime `instanceof` / `$container->has()` pattern). By binding `TransactionInterface` separately, the readwrite package sets a precedent. Justification: the consuming code in `SeederRunner` already gracefully handles the case where `TransactionInterface` is bound (`$container->has(TransactionInterface::class)` returns true), and the readwrite façade benefits from container-level resolution rather than runtime type-checking. The behavior is consistent (resolving either interface returns the same object that implements both). **Surfaced for human review:** if the maintainer prefers NOT to set this precedent, drop the `TransactionInterface` instance() call and require consumers to use `$conn instanceof TransactionInterface` checks instead — sticky state still works because there's only ever one resolution path through `ConnectionInterface`. + +- **Builder API:** Extract the boot logic into `ReadWriteConnectionBuilder` with a clear seam for the no-op path: + ```php + final class ReadWriteConnectionBuilder + { + public function build(Container $container, ProjectPaths $paths): void + { + $rawConfig = require $paths->config . '/database.php'; + if (($rawConfig['driver'] ?? null) !== 'readwrite') { + return; + } + // ...rest of wiring as above + } + } + ``` + This makes the no-op branch unit-testable without spinning up a full module pipeline. +- **Tests:** + - Unit test the `ReadWriteConnectionBuilder::build($container)` with a stubbed container holding fake `ConnectionFactoryInterface` and `ReadWriteConnectionConfig`. + - Assert the resulting `ReadWriteConnection` has the expected writer and replicas wired in. + - Assert the no-op behavior when `driver !== 'readwrite'`. + - Full integration with the real module pipeline is tasks 012 and 013. + +## Requirements (Test Descriptions) +- [ ] `it builds a ReadWriteConnection from config using the registered ConnectionFactoryInterface` +- [ ] `it wires the write connection from connections.write config` +- [ ] `it wires read connections from connections.read config array` +- [ ] `it uses RandomReplicaSelector when read_strategy is random` +- [ ] `it uses WeightedReplicaSelector with the configured weights when read_strategy is weighted` +- [ ] `it overrides the ConnectionInterface binding with the ReadWriteConnection instance` +- [ ] `it overrides the TransactionInterface binding with the same ReadWriteConnection instance` +- [ ] `it does nothing when the top-level driver key is not readwrite` + +## Acceptance Criteria +- `module.php` returns a valid manifest with at minimum `boot` and `singletons` keys. +- `ReadWriteConnectionBuilder` is a testable class that the boot callback delegates to. +- All 8 requirements have passing tests using container/factory stubs. +- The boot does not invoke any real PDO connection (use stubs for the factory). +- `composer test` passes; no regressions in any other package. +- Lint clean. + +## Implementation Notes +(Left blank — filled in by programmer during implementation) diff --git a/.claude/plans/database-readwrite/012-integration-test-pgsql.md b/.claude/plans/database-readwrite/012-integration-test-pgsql.md new file mode 100644 index 00000000..d9a23023 --- /dev/null +++ b/.claude/plans/database-readwrite/012-integration-test-pgsql.md @@ -0,0 +1,38 @@ +# Task 012: Integration Test Over pgsql Wiring + +**Status**: pending +**Depends on**: 011 +**Retry count**: 0 + +## Description +Integration test that proves the full readwrite wiring works end-to-end when the underlying driver is `marko/database-pgsql`. Constructs the real boot pipeline (real `BindingRegistry`, real `PgSqlConnectionFactory`, real `ReadWriteConnectionConfig` loading a fixture config file, real `ReadWriteConnection`), but uses a stubbed `ConnectionFactoryInterface` OR stubbed `PgSqlConnection` so no real database is touched. + +## Context +- **File location:** `packages/database-readwrite/tests/Integration/PgSqlWiringTest.php` +- **What "integration" means here:** End-to-end through the boot pipeline, but with mocked PDO/connection so the test never connects to a real database. Mirror the technique used in `packages/database-pgsql/tests/Module/DialectOverrideTest.php` (the proven 2-module-scenario fixture from #31). +- **Test scenarios:** + - **Scenario A:** Load `marko/database-pgsql` manifest + `marko/database-readwrite` manifest into `BindingRegistry`, run boot callbacks in order. After boot, resolve `ConnectionInterface` — assert it returns a `ReadWriteConnection` instance, NOT a `PgSqlConnection`. + - **Scenario B:** After boot, the resolved `ConnectionInterface->query()` routes to a stubbed replica (asserted via instrumented stub). The `execute()` routes to a stubbed writer. + - **Scenario C:** Beginning a transaction routes to the writer's transaction methods. + - **Scenario D:** The config file uses `driver: 'pgsql'` per connection (write and each read) — assert `PgSqlConnectionFactory::make()` is called with each `DatabaseConfig`. +- **Replacing PgSqlConnectionFactory with a stub:** Either bind a stub factory in a third fixture module that runs after pgsql's module, OR override the factory directly in the test setup via `$container->bind(ConnectionFactoryInterface::class, $stubFactory)`. The boot callback pattern from task 011 will pick up the override. +- **Fixture connections:** The stub `ConnectionInterface & TransactionInterface` implementations from tasks 007-010 can be reused. Place reusable fixtures under `packages/database-readwrite/tests/Fixtures/`. +- **Fixture config file:** Generate a temporary `config/database.php` with the nested shape, mirroring how `packages/database-pgsql/tests/Module/ModuleBindingsTest.php` uses temp directories for config. + +## Requirements (Test Descriptions) +- [ ] `it resolves ConnectionInterface to a ReadWriteConnection after the readwrite boot runs over pgsql` +- [ ] `it routes query() through the readwrite connection to a stubbed pgsql replica` +- [ ] `it routes execute() through the readwrite connection to the stubbed pgsql writer` +- [ ] `it routes beginTransaction() to the stubbed pgsql writer` +- [ ] `it calls PgSqlConnectionFactory::make() with a DatabaseConfig per configured connection` +- [ ] `it resolves TransactionInterface to the same instance as ConnectionInterface after boot` + +## Acceptance Criteria +- All 6 requirements have passing tests. +- No test connects to a real PostgreSQL instance. +- Fixtures are reusable across tasks 012 and 013 where applicable. +- `composer test` passes; no regression in any other package. +- Lint clean. + +## Implementation Notes +(Left blank — filled in by programmer during implementation) diff --git a/.claude/plans/database-readwrite/013-integration-test-mysql.md b/.claude/plans/database-readwrite/013-integration-test-mysql.md new file mode 100644 index 00000000..1268fdcd --- /dev/null +++ b/.claude/plans/database-readwrite/013-integration-test-mysql.md @@ -0,0 +1,32 @@ +# Task 013: Integration Test Over mysql Wiring + +**Status**: pending +**Depends on**: 011 +**Retry count**: 0 + +## Description +Mirror of task 012 for the MySQL driver. Proves the readwrite wiring works end-to-end over `marko/database-mysql` with `MySqlConnectionFactory`. Same scenarios, same fixture pattern, just swapping the driver. + +## Context +- **File location:** `packages/database-readwrite/tests/Integration/MySqlWiringTest.php` +- **Reuse fixtures from task 012** where possible — the stub `ConnectionInterface & TransactionInterface` implementations should be driver-agnostic. +- **Scenarios** are the same as task 012, swapping `pgsql` → `mysql` and `PgSqlConnectionFactory` → `MySqlConnectionFactory`. +- **Per `.claude/sibling-modules.md`:** This test must read as a mirror of `PgSqlWiringTest` — identical structure, identical naming pattern, only the driver differs. A reviewer flipping between the two files should see them as obvious siblings. + +## Requirements (Test Descriptions) +- [ ] `it resolves ConnectionInterface to a ReadWriteConnection after the readwrite boot runs over mysql` +- [ ] `it routes query() through the readwrite connection to a stubbed mysql replica` +- [ ] `it routes execute() through the readwrite connection to the stubbed mysql writer` +- [ ] `it routes beginTransaction() to the stubbed mysql writer` +- [ ] `it calls MySqlConnectionFactory::make() with a DatabaseConfig per configured connection` +- [ ] `it resolves TransactionInterface to the same instance as ConnectionInterface after boot` + +## Acceptance Criteria +- All 6 requirements have passing tests. +- File structure exactly mirrors `PgSqlWiringTest.php` (test names, `describe()` blocks, fixture usage — all parallel). +- No test connects to a real MySQL instance. +- `composer test` passes. +- Lint clean. + +## Implementation Notes +(Left blank — filled in by programmer during implementation) diff --git a/.claude/plans/database-readwrite/014-docs-page.md b/.claude/plans/database-readwrite/014-docs-page.md new file mode 100644 index 00000000..3a6e3de4 --- /dev/null +++ b/.claude/plans/database-readwrite/014-docs-page.md @@ -0,0 +1,52 @@ +# Task 014: Docs Page (packages/database-readwrite.md) + +**Status**: pending +**Depends on**: 011 +**Retry count**: 0 + +## Description +Write `docs/src/content/docs/packages/database-readwrite.md` — the canonical reference page for the package. Covers installation, the nested config schema, replica strategy selection, sticky-write semantics, single-request fallback behavior, the `prepare()` policy, the long-running-process caveat, and cross-links to the DI override pattern doc. + +## Context +- **File location:** `docs/src/content/docs/packages/database-readwrite.md` +- **Reference docs to mirror for tone and structure:** Read `docs/src/content/docs/packages/database.md` and `docs/src/content/docs/packages/database-pgsql.md` to match the existing docs voice (declarative, no marketing, code-forward). +- **Required sections (lock the headings as listed — they drive cross-links from other docs):** + 1. **Frontmatter** — `title: Database Read/Write Split`, `description: ...`, any other Starlight frontmatter consistent with sibling pages. + 2. **Overview** — 2-3 sentences: what it does (decorates ConnectionInterface to route reads/writes), when to use it (high-traffic apps with replicated databases), and that it's opt-in. + 3. **Installation** — `composer require marko/database-readwrite`. Note that it requires a base driver (pgsql or mysql) already installed. + 4. **Configuration** — full nested config example with both `random` and `weighted` shown. Document every key, including optional ones (`weight`, `read_strategy`). + 5. **How routing works** — table summarizing which methods route to which connection (query → replica via selector, execute/prepare/lastInsertId/transactions → writer). + 6. **Sticky-write behavior** — when the sticky flag activates (any write, any prepare, any beginTransaction), when it does NOT clear (commit/rollback do not reset), and how to clear manually (`resetStickyState()`). + 7. **Replica selection strategies** — Random (default, recommended), Weighted (with weights example), and a note that the `ReplicaSelectorInterface` is open for custom strategies via Preference (cross-link to DI concept doc). + 8. **Single-request fallback** — on PDOException from a replica, the same query is retried on the next replica; when all exhausted, `AllReplicasFailedException` is thrown. Note that writers never get fallback (only one writer exists). Note that this is NOT a circuit-breaker — for sustained replica failures, use ops-layer load-balancer health checks. + 9. **prepare() policy** — always routes to writer. Explain why (no SQL parsing, parameterized statements typically used for INSERT/UPDATE). + 10. **Long-running processes (caveat)** — sticky state auto-resets per request in PHP-FPM via singleton lifecycle. In long-running processes (Swoole, RoadRunner, queue workers, CLI commands), call `$readWriteConnection->resetStickyState()` between jobs to avoid sticky state leaking across boundaries. + 11. **Loud-error behavior** — list each Marko exception the package raises and what to do about it (missing write config, empty read array, bad weight, unknown strategy, all-replicas-failed). + 12. **Customization / Preferences** — how to swap the selector, how to swap the connection (less common). Cross-link to `concepts/dependency-injection.md#overriding-another-modules-bindings`. + 13. **API Reference** — concise list of public signatures: `ReadWriteConnection` constructor, `resetStickyState()`, `ReplicaSelectorInterface`, exception classes. +- **Cross-links to add elsewhere (NOT this task — doc-updater in post-implementation pipeline may handle):** + - `packages/database.md` should gain a paragraph in or near the "Wire-compatible database variants" section pointing at the readwrite package as another opt-in extension. + - The package inventory in `.claude/architecture.md` should grow a `Database Read/Write Split` row. Doc-updater may catch this. + +## Requirements (Test Descriptions) +*Docs-only task — assertions are content-quality, verified by review.* + +- [ ] `the docs page exists at the correct path and has Starlight-compatible frontmatter` +- [ ] `the configuration section shows the full nested config schema with both random and weighted examples` +- [ ] `the routing table covers all 6 ConnectionInterface methods and all 5 TransactionInterface methods` +- [ ] `the sticky-write section explains the activate-and-do-not-auto-clear semantics` +- [ ] `the section explicitly documents that commit/rollback do not clear sticky state` +- [ ] `the long-running-process section explains when to call resetStickyState()` +- [ ] `the prepare() policy section explains why it always routes to writer` +- [ ] `the fallback section explains AllReplicasFailedException and clarifies that the writer is never fallback target` +- [ ] `the customization section cross-links to /docs/concepts/dependency-injection/#overriding-another-modules-bindings` + +## Acceptance Criteria +- New docs page at the named path. +- `npm --prefix docs run build` passes with no new warnings or broken-link errors. +- Tone matches sibling pages (declarative, no marketing). +- All code examples are valid PHP / valid config. +- Cross-link anchors resolve correctly. + +## Implementation Notes +(Left blank — filled in by programmer during implementation) diff --git a/.claude/plans/database-readwrite/015-readme.md b/.claude/plans/database-readwrite/015-readme.md new file mode 100644 index 00000000..9ae8a9c8 --- /dev/null +++ b/.claude/plans/database-readwrite/015-readme.md @@ -0,0 +1,42 @@ +# Task 015: README per Package README Standards (Final Task) + +**Status**: pending +**Depends on**: 002, 011, 012, 013, 014 +**Retry count**: 0 + +## Description +Replace the placeholder `README.md` (from task 002) with the final slim-pointer README per the Package README Standards in `.claude/code-standards.md`. Title, one-liner, brief overview, install command, a single short usage example, and a link to the full docs page (which now exists from task 014). + +## Context +- **File location:** `packages/database-readwrite/README.md` +- **Standards reference:** `.claude/code-standards.md` → "Package README Standards" section. READMEs are slim pointers, not duplicate documentation. +- **Required sections (per the standards):** + - Title + one-liner: what it does + practical benefit + - Overview: 2-4 sentences expanding on the benefit + - Installation: `composer require marko/database-readwrite` + - Usage: the common case — show installing the package + a minimal config snippet, then state "all your existing app code works unchanged" + - Customization (brief): mention the `ReplicaSelectorInterface` extension seam in 1-2 sentences + - API Reference (brief): point to the full docs page; do not duplicate every signature +- **Final link:** Full docs link at `https://marko.build/docs/packages/database-readwrite/`. +- **Sibling README to mirror voice and length:** Read `packages/database-pgsql/README.md` and `packages/queue-database/README.md` for the slim-pointer pattern. The readwrite README should be similar length (under 100 lines). +- **Why this task depends on 014:** The README cross-links to the docs page; the link target must exist. + +## Requirements (Test Descriptions) +*Docs-only task — assertions are content-quality, verified by review.* + +- [ ] `the README has a clear title and one-liner stating what the package does and its benefit` +- [ ] `the README has an Installation section with the correct composer require command` +- [ ] `the README has a Usage section showing the common case (config snippet + app code unchanged)` +- [ ] `the README mentions the ReplicaSelectorInterface customization seam briefly` +- [ ] `the README links to the full docs page at https://marko.build/docs/packages/database-readwrite/` +- [ ] `the README is a slim pointer (no duplicated full docs content)` + +## Acceptance Criteria +- README is under ~100 lines. +- All required sections present. +- Tone matches sibling READMEs. +- All code blocks are valid PHP / valid config. +- No broken markdown, no markdown-lint warnings. + +## Implementation Notes +(Left blank — filled in by programmer during implementation) diff --git a/.claude/plans/database-readwrite/_plan.md b/.claude/plans/database-readwrite/_plan.md new file mode 100644 index 00000000..9569dab4 --- /dev/null +++ b/.claude/plans/database-readwrite/_plan.md @@ -0,0 +1,148 @@ +# Plan: marko/database-readwrite + +## Created +2026-05-26 + +## Status +completed + +## Objective +Ship `marko/database-readwrite` — an opt-in sibling driver package that decorates `ConnectionInterface` to route reads to replica connections and writes to a primary connection, with sticky writes (transaction + per-request), single-request fallback on replica failure, and pluggable replica selection (random default, weighted option). Resolves GitHub issue #4. + +## Related Issues +Closes #4 + +## Discovery Notes + +**Architectural verification:** +- `ConnectionInterface` (`packages/database/src/Connection/ConnectionInterface.php`) has 6 methods. `query()` returns array (read), `execute()` returns int affected rows (write), `prepare()` returns `StatementInterface` (ambiguous — see policy below), `lastInsertId()` (write), `connect()`/`disconnect()`/`isConnected()` (lifecycle). +- `TransactionInterface` (`packages/database/src/Connection/TransactionInterface.php`) is a separate interface implemented alongside `ConnectionInterface` by drivers (`PgSqlConnection implements ConnectionInterface, TransactionInterface`). `ReadWriteConnection` must do the same to be a drop-in replacement. +- **`TransactionInterface` is NOT separately bound today.** Verified: `packages/database-pgsql/module.php` and `packages/database-mysql/module.php` bind only `ConnectionInterface::class => {Driver}Connection::class`. Consumers (`Repository::save()` line 335, `SeederRunner` via `database/module.php` line 50-52) use either `instanceof TransactionInterface` checks on the resolved connection OR `$container->has(TransactionInterface::class)` (which returns false today because no module binds it). Binding `TransactionInterface` separately in the readwrite boot IS a precedent — surfaced to maintainer in task 011 for explicit acknowledgment. +- `grep -rn '->prepare(' packages/ --include='*.php' | grep -v tests/` confirms **zero production callers** of `Connection::prepare()` outside the driver implementations. Policy decided: always route to write. +- `DatabaseConfig` is flat and single-connection. We do NOT mutate the existing shape; instead the readwrite package owns its own `ReadWriteConnectionConfig` reading nested `connections.read[]` / `connections.write` keys. We add a single additive static factory `DatabaseConfig::fromArray()` so the readwrite package can build configs for the underlying driver connections from raw arrays. +- `PgSqlQueryBuilder` and the framework's other database consumers use only `query()` and `execute()` — they route cleanly through the decorator with no changes. +- The boot-callback override pattern shipped in #31 (PR #85) is the mechanism used by the readwrite package's `module.php` to override `ConnectionInterface => ReadWriteConnection` without triggering `BindingConflictException`. **Container API confirmed:** `Container::instance(string $id, object $instance): void` exists at `packages/core/src/Container/Container.php:69-74` and stores in `$this->instances[]`; `resolve()` returns the stored instance unchanged on line 115-117. This is the correct API for sticky-stateful singletons. +- **Driver `query()` exception surface verified.** `PgSqlConnection::query()` (lines 140-151) does NOT catch PDOException — it bubbles. But `ensureConnected()` (line 130-135) can call `connect()` which catches `\Throwable` and rethrows as `Marko\Database\PgSql\Exceptions\ConnectionException` (line 52-58). So a replica that cannot CONNECT throws `ConnectionException` (a `MarkoException`), NOT `PDOException`. The fallback catch list in task 010 must include both. MySQL driver mirrors this pattern. + +**Wiring challenge & solution:** +The readwrite package needs to construct TWO+ underlying driver connections (one writer, N readers) without conflicting with the underlying driver's existing single-connection binding. Solution: +1. **New `ConnectionFactoryInterface` in `marko/database`** (additive) — defines `make(DatabaseConfig $config): ConnectionInterface`. +2. **Per-driver factory classes** — `PgSqlConnectionFactory` and `MySqlConnectionFactory` implement the interface. Each driver binds `ConnectionFactoryInterface => {Driver}ConnectionFactory` in its `module.php`. +3. **`DatabaseConfig::fromArray()` static factory** (additive) — lets the readwrite package build `DatabaseConfig` instances from the nested config arrays for each underlying connection. +4. **Readwrite `module.php` boot callback** — reads the nested config, uses the registered `ConnectionFactoryInterface` to build write + read connections, wraps in `ReadWriteConnection`, calls `$container->bind(ConnectionInterface::class, $instance)` to override. + +**Conflict consideration:** If a user installs both `marko/database-pgsql` AND `marko/database-mysql`, both drivers will bind `ConnectionFactoryInterface => {Driver}ConnectionFactory` at vendor priority, triggering `BindingConflictException`. This already happens today for `ConnectionInterface` — installing two drivers is not supported. The new factory binding follows the same rule. + +**Sticky-write lifecycle:** +- HTTP requests (PHP-FPM): `ReadWriteConnection` is a singleton with the lifetime of the script. Sticky flag is automatically reset between requests by the natural process lifecycle. No middleware needed. +- Long-running processes (queue workers, Swoole, RoadRunner): `resetStickyState()` is a public method on `ReadWriteConnection` that users (or future integration) can call between jobs. **v1 ships the method but does not auto-wire it into queue/HTTP middleware** — that's a follow-up if/when needed. +- Transactions: `beginTransaction()` sets sticky to true; the flag stays set after `commit()`/`rollback()` because if you wrote in a transaction, you may want to read your own writes for the rest of the request. Sticky is only cleared by `resetStickyState()`. + +**Config schema:** +```php +// config/database.php (when database-readwrite is installed) +return [ + 'driver' => 'readwrite', + 'connections' => [ + 'write' => [ + 'driver' => 'pgsql', + 'host' => 'primary.db', + 'port' => 5432, + 'database' => 'app', + 'username' => '...', + 'password' => '...', + ], + 'read' => [ + ['driver' => 'pgsql', 'host' => 'replica1.db', 'port' => 5432, ...], + ['driver' => 'pgsql', 'host' => 'replica2.db', 'port' => 5432, 'weight' => 3], + ], + 'read_strategy' => 'random', // or 'weighted' + ], +]; +``` + +The `driver` per connection allows (rare but possible) cross-driver pairing (e.g., MySQL writer with MySQL replicas via different DSN). The package's `module.php` registers itself for `'driver' => 'readwrite'` at the top level; when that's set, the readwrite override engages. + +## Scope + +### In Scope +- New package `packages/database-readwrite/` with composer.json, module.php, src/, tests/, README, LICENSE, .gitattributes. +- Additive changes to `marko/database`: `ConnectionFactoryInterface`, `DatabaseConfig::fromArray()` static factory. Zero changes to existing public method signatures. +- Additive changes to `marko/database-pgsql` and `marko/database-mysql`: `{Driver}ConnectionFactory` class + binding in `module.php`. +- `ReadWriteConnection implements ConnectionInterface, TransactionInterface` with full method routing. +- Sticky-write tracking: transaction-triggered + write-triggered, public `resetStickyState()`. +- Single-request fallback on read failure (try next replica on `PDOException`, throw loud error when all exhausted). +- `ReplicaSelectorInterface` with `RandomReplicaSelector` and `WeightedReplicaSelector` implementations. +- `ReadWriteConnectionConfig` reading nested keys with loud-error validation. +- Integration tests proving full wiring over pgsql and over mysql. +- README per Package README Standards. +- Docs page at `docs/src/content/docs/packages/database-readwrite.md`. +- Root `composer.json` path repositories, `PackagingTest`, `IntegrationVerificationTest`, issue-template entries updated for the new package. + +### Out of Scope +- Cross-request circuit-breaker / health-aware replica skipping (deferred; ops-layer concern; requires shared state store). +- Auto-reset of sticky state in queue worker / Swoole / RoadRunner middleware (`resetStickyState()` ships; auto-wiring is follow-up). +- Per-query routing override (e.g., a `forceRead()` modifier on the query builder). +- Multi-tenant / scoped connections. +- Connection pooling. +- Database failover / automatic primary election. +- Read-from-writer fallback when all replicas fail (currently throws; fallback could be a v2 config flag). +- Modifications to `ConnectionInterface`, `TransactionInterface`, `StatementInterface`, or any existing method signature on `DatabaseConfig`. + +## Success Criteria +- [ ] `ReadWriteConnection` implements both `ConnectionInterface` and `TransactionInterface`. +- [ ] `query()` routes to a read replica via the configured `ReplicaSelectorInterface`. +- [ ] `execute()`, `prepare()`, `lastInsertId()`, transaction methods route to the write connection. +- [ ] Sticky-write flag activates on any write or transaction begin; `query()` honors it; `resetStickyState()` clears it. +- [ ] Single-request fallback: a `PDOException` from one replica routes the same query to the next replica; loud-error exception when all replicas exhausted. +- [ ] Random and Weighted selectors both work; selector is chosen by `read_strategy` config. +- [ ] Config validation throws Marko exceptions with message/context/suggestion for: missing write, empty read array, invalid weight (≤ 0 or non-int), unknown driver per connection, unknown `read_strategy`. +- [ ] Integration test proves end-to-end wiring over pgsql. +- [ ] Integration test proves end-to-end wiring over mysql. +- [ ] `composer test` passes including all new tests and zero regressions in existing tests. +- [ ] `./vendor/bin/phpcs` and `./vendor/bin/php-cs-fixer fix --dry-run` clean on touched files. +- [ ] `npm --prefix docs run build` passes with no new warnings. +- [ ] Package README follows Package README Standards (slim pointer: title, install, quick example, docs link). +- [ ] Docs page at `packages/database-readwrite.md` covers installation, config schema, sticky-write semantics, `prepare()` policy, replica strategies, fallback behavior. +- [ ] PR opened against `develop` with "Closes #4" in the body. + +## Task Overview +| Task | Description | Depends On | Status | +|------|-------------|------------|--------| +| 001 | Add ConnectionFactoryInterface + DatabaseConfig::fromArray() to marko/database | - | completed | +| 002 | Package scaffolding (composer.json, module.php placeholder, LICENSE, root config, packaging tests) | - | completed | +| 003 | PgSqlConnectionFactory in marko/database-pgsql | 001 | completed | +| 004 | MySqlConnectionFactory in marko/database-mysql | 001 | completed | +| 005 | ReadWriteConnectionConfig (nested config loading + loud-error validation) | 002 | completed | +| 006 | ReplicaSelectorInterface + RandomReplicaSelector + WeightedReplicaSelector | 002 | completed | +| 007 | ReadWriteConnection — ConnectionInterface methods routing (query/execute/prepare/lastInsertId/connect/disconnect/isConnected) | 006 | completed | +| 008 | ReadWriteConnection — TransactionInterface methods routing (begin/commit/rollback/inTransaction/transaction) | 007 | completed | +| 009 | Sticky-write state on ReadWriteConnection (flag, write triggers, transaction triggers, resetStickyState()) | 008 | completed | +| 010 | Single-request fallback in ReadWriteConnection query routing | 008 | completed | +| 011 | module.php boot callback wiring (build underlying connections via factory, construct selector + ReadWriteConnection, override ConnectionInterface binding) | 003, 004, 005, 009, 010 | completed | +| 012 | Integration test over pgsql wiring | 011 | completed | +| 013 | Integration test over mysql wiring | 011 | completed | +| 014 | Docs page (packages/database-readwrite.md) | 011 | completed | +| 015 | README per Package README Standards (final) | 002, 011, 012, 013, 014 | completed | + +## Cross-Doc Updates (handled by doc-updater in post-implementation pipeline) +- `docs/src/content/docs/packages/database.md` — the "Wire-compatible variants" section (line 876) lists the 5-binding split. With this plan, `ConnectionFactoryInterface` becomes a sibling new-binding (additive, optional to override). Doc-updater should add a brief pointer paragraph linking to the new `database-readwrite.md` page as another extension pattern (decoration vs. dialect-replacement). +- `.claude/architecture.md` — package inventory should grow a "Database Read/Write Split" entry. +- The github-slugger anchor for the readwrite docs page section "Customization / Preferences" should remain stable (`#customization--preferences` or similar default slug — the docs page in task 014 cross-links TO `dependency-injection.md#overriding-another-modules-bindings` which is the only externally-referenced anchor; no other plan tasks hardcode anchors INTO the readwrite docs page). + +## Architecture Notes + +- **Additive-only principle:** Every change to `marko/database`, `marko/database-pgsql`, and `marko/database-mysql` is purely additive (new interface, new static factory, new factory classes, new bindings). No method signature or class shape changes on the existing public API. Existing apps continue working unchanged. +- **Override mechanism:** Readwrite's `module.php` uses the boot-callback pattern documented in `concepts/dependency-injection.md#overriding-another-modules-bindings` to override `ConnectionInterface` without triggering `BindingConflictException`. +- **Driver coupling:** The readwrite package depends on `marko/database` (the interface package) only. It resolves `ConnectionFactoryInterface` from the container — the actual driver factory is provided by whichever driver the user installed (pgsql, mysql, future). No hard dependency on any specific driver. +- **Cross-driver pairing:** The per-connection `driver` key in config allows different drivers for writer vs readers (rare but supported by virtue of factory-based construction). Whoever resolves `ConnectionFactoryInterface` provides the only available factory; multi-driver setups would need a future enhancement. +- **Sticky-write singleton lifecycle:** `ReadWriteConnection` is bound as a singleton (matching the existing `ConnectionInterface` binding lifecycle). In PHP-FPM, the singleton's lifetime equals the request — sticky state auto-resets. Long-running process integration is a documented follow-up. + +## Risks & Mitigations +- **Risk:** Adding `ConnectionFactoryInterface` to `marko/database` triggers binding conflict if user installs both pgsql AND mysql drivers. **Mitigation:** This already happens for `ConnectionInterface` — multi-driver installation is unsupported and fails loud. Document explicitly in the readwrite docs page. The factory binding inherits the same constraint. +- **Risk:** `DatabaseConfig::fromArray()` could become a back door for bypassing config file validation. **Mitigation:** The static factory runs the same validation as the constructor; missing required keys throw `ConfigurationException`. Add a test asserting the validation is equivalent. +- **Risk:** Sticky-write singleton lifecycle assumption (PHP-FPM = per-request) breaks in long-running processes. **Mitigation:** Ship `resetStickyState()` as a public method, document the long-running-process caveat clearly in the docs page, defer auto-wiring to a follow-up. +- **Risk:** `WeightedReplicaSelector` with all-zero weights throws division by zero or selects nothing. **Mitigation:** Config validation enforces `weight > 0`; the validator throws a Marko exception during boot if any weight is ≤ 0 or non-int. Document in the loud-error message. +- **Risk:** Test fixture connections in integration tests could accidentally hit a real database. **Mitigation:** Use mock implementations of `ConnectionInterface` (anonymous classes or named test stubs); never construct real `PgSqlConnection` or `MySqlConnection` against a real DSN in unit/integration tests. Reserve real-database verification for `tests/IntegrationVerificationTest.php` if at all. +- **Risk:** Single-request fallback masks real connection issues (silently retries on permanent failures). **Mitigation:** Only catch `PDOException` and only for the duration of one query's replica iteration; never retry on the writer; loud-error exception when all replicas exhausted with a list of which ones failed and why. +- **Risk:** Adding `ConnectionFactoryInterface` binding in pgsql and mysql `module.php` is an existing-package change — could conflict with the "additive-only to existing packages" claim. **Mitigation:** A new binding for a new interface IS additive — no existing binding or method is touched. Existing tests in those packages stay green; we add a new test for the factory binding. diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 00000000..4576ca5f --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"6e8afc39-d065-45f9-a722-75e4bec9365e","pid":79611,"procStart":"Tue May 26 16:35:39 2026","acquiredAt":1779814501910} \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 41ce17e4..12c1bdb0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -65,6 +65,7 @@ body: - database - database-mysql - database-pgsql + - database-readwrite - debugbar - dev-server - encryption diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 2950b022..cfd8e3d6 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -53,6 +53,7 @@ body: - database - database-mysql - database-pgsql + - database-readwrite - debugbar - dev-server - encryption diff --git a/README.md b/README.md index 501bb503..270fb21f 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,7 @@ Marko ships as composable packages — require only what you need. Every package | [database](packages/database/README.md) | Database abstraction, migrations, entity management | | [database-pgsql](packages/database-pgsql/README.md) | PostgreSQL driver | | [database-mysql](packages/database-mysql/README.md) | MySQL driver | +| [database-readwrite](packages/database-readwrite/README.md) | Read/write connection splitting | ### Caching diff --git a/composer.json b/composer.json index ead0ab43..0cf09ecd 100644 --- a/composer.json +++ b/composer.json @@ -84,6 +84,10 @@ "type": "path", "url": "packages/database-pgsql" }, + { + "type": "path", + "url": "packages/database-readwrite" + }, { "type": "path", "url": "packages/debugbar" @@ -351,6 +355,7 @@ "marko/database": "self.version", "marko/database-mysql": "self.version", "marko/database-pgsql": "self.version", + "marko/database-readwrite": "self.version", "marko/dev-server": "self.version", "marko/encryption": "self.version", "marko/encryption-openssl": "self.version", @@ -466,6 +471,7 @@ "Marko\\Database\\Tests\\": "packages/database/tests/", "Marko\\Database\\MySql\\Tests\\": "packages/database-mysql/tests/", "Marko\\Database\\PgSql\\Tests\\": "packages/database-pgsql/tests/", + "Marko\\Database\\ReadWrite\\Tests\\": "packages/database-readwrite/tests/", "Marko\\Debugbar\\Tests\\": "packages/debugbar/tests/", "Marko\\DevServer\\Tests\\": "packages/dev-server/tests/", "Marko\\Encryption\\Tests\\": "packages/encryption/tests/", diff --git a/docs/src/content/docs/packages/database-mysql.md b/docs/src/content/docs/packages/database-mysql.md index 8624018b..37ebc4d1 100644 --- a/docs/src/content/docs/packages/database-mysql.md +++ b/docs/src/content/docs/packages/database-mysql.md @@ -169,6 +169,14 @@ class MyService | `whereJsonMissing(string $path): static` | WHERE JSON key/path does not exist | | `raw(string $sql, array $bindings = []): array` | Execute raw SQL | +### MySqlConnectionFactory + +Implements `ConnectionFactoryInterface`. Creates `MySqlConnection` instances from a `DatabaseConfig`. Used by `marko/database-readwrite` to build per-connection instances for the write primary and each read replica. + +| Method | Description | +|---|---| +| `make(DatabaseConfig $config): ConnectionInterface` | Create and return a new `MySqlConnection` for the given config | + ### SQL Generator `MySqlGenerator` implements `SqlGeneratorInterface` --- produces MySQL-specific DDL from schema diffs (used by the migration system). diff --git a/docs/src/content/docs/packages/database-pgsql.md b/docs/src/content/docs/packages/database-pgsql.md index 4a1d2cee..3dd3bb32 100644 --- a/docs/src/content/docs/packages/database-pgsql.md +++ b/docs/src/content/docs/packages/database-pgsql.md @@ -242,6 +242,14 @@ Implements `QueryBuilderInterface`. Fluent builder for PostgreSQL queries. | `whereJsonMissing(string $path): static` | WHERE JSON key/path does not exist | | `raw(string $sql, array $bindings = []): array` | Execute a raw SQL query | +### PgSqlConnectionFactory + +Implements `ConnectionFactoryInterface`. Creates `PgSqlConnection` instances from a `DatabaseConfig`. Used by `marko/database-readwrite` to build per-connection instances for the write primary and each read replica. + +| Method | Description | +|---|---| +| `make(DatabaseConfig $config): ConnectionInterface` | Create and return a new `PgSqlConnection` for the given config | + ### PgSqlIntrospector Implements `IntrospectorInterface`. Reads schema metadata from `information_schema` and `pg_catalog`. diff --git a/docs/src/content/docs/packages/database-readwrite.md b/docs/src/content/docs/packages/database-readwrite.md new file mode 100644 index 00000000..ef69cd7e --- /dev/null +++ b/docs/src/content/docs/packages/database-readwrite.md @@ -0,0 +1,274 @@ +--- +title: marko/database-readwrite +description: Read/write connection splitting with replica routing for Marko database connections. +--- + +Read/write connection splitting with replica routing for Marko database connections. Wraps any existing [`marko/database`](/docs/packages/database/) driver connection — all write operations and transactions route to the primary, all reads route to one of your configured replicas. Uses the decorator pattern: `ReadWriteConnection` implements `ConnectionInterface` and `TransactionInterface` so the rest of the application code is unchanged. + +## Installation + +```bash +composer require marko/database-readwrite +``` + +This automatically installs `marko/database` (the interface package) as a dependency. + +## Configuration + +Set `driver` to `readwrite` in your `config/database.php`, then declare a `connections` block with your write primary and one or more read replicas: + +```php title="config/database.php" + 'readwrite', + 'connections' => [ + 'write' => [ + 'driver' => 'pgsql', + 'host' => $_ENV['DB_WRITE_HOST'] ?? 'localhost', + 'port' => (int) ($_ENV['DB_WRITE_PORT'] ?? 5432), + 'database' => $_ENV['DB_DATABASE'] ?? 'marko', + 'username' => $_ENV['DB_USERNAME'] ?? 'postgres', + 'password' => $_ENV['DB_PASSWORD'] ?? '', + ], + 'read' => [ + [ + 'driver' => 'pgsql', + 'host' => $_ENV['DB_READ_HOST_1'] ?? 'replica-1', + 'port' => (int) ($_ENV['DB_READ_PORT_1'] ?? 5432), + 'database' => $_ENV['DB_DATABASE'] ?? 'marko', + 'username' => $_ENV['DB_USERNAME'] ?? 'postgres', + 'password' => $_ENV['DB_PASSWORD'] ?? '', + ], + [ + 'driver' => 'pgsql', + 'host' => $_ENV['DB_READ_HOST_2'] ?? 'replica-2', + 'port' => (int) ($_ENV['DB_READ_PORT_2'] ?? 5432), + 'database' => $_ENV['DB_DATABASE'] ?? 'marko', + 'username' => $_ENV['DB_USERNAME'] ?? 'postgres', + 'password' => $_ENV['DB_PASSWORD'] ?? '', + ], + ], + 'read_strategy' => 'random', // 'random' (default) or 'weighted' + ], +]; +``` + +### Config Schema + +| Key | Type | Required | Description | +|-----|------|----------|-------------| +| `connections.write` | `array` | Yes | Single write (primary) connection config | +| `connections.read` | `array[]` | Yes | One or more replica connection configs | +| `connections.read_strategy` | `string` | No | Replica selection strategy: `random` (default) or `weighted` | +| `read[n].weight` | `int` | No | Required when `read_strategy` is `weighted`; positive integer | + +Each connection config inside `write` and `read[]` follows the same structure as a standalone driver config (e.g., `marko/database-pgsql`). + +## Replica Strategies + +### Random (default) + +Selects a replica uniformly at random on each read query. Use when all replicas have similar capacity: + +```php title="config/database.php" +'connections' => [ + // ... + 'read_strategy' => 'random', +], +``` + +### Weighted + +Routes a proportional share of read traffic to each replica based on its `weight`. Use when replicas have different hardware or capacity: + +```php title="config/database.php" +'connections' => [ + 'write' => [ /* primary config */ ], + 'read' => [ + [ + 'driver' => 'pgsql', + 'host' => 'replica-1', + // ... + 'weight' => 3, // receives 3/4 of reads + ], + [ + 'driver' => 'pgsql', + 'host' => 'replica-2', + // ... + 'weight' => 1, // receives 1/4 of reads + ], + ], + 'read_strategy' => 'weighted', +], +``` + +Weights are positive integers. The probability of selecting a replica equals its weight divided by the total of all weights. Each `weight` must be a positive integer or it is rejected at config parse time. + +:::note +`WeightedReplicaSelector` calculates probabilities based on the original replica list. When a replica fails and is removed during a request, the weights for the remaining replicas are not rebalanced. This is a known v1 limitation. +::: + +## Sticky Writes + +After any write operation or transaction, subsequent reads within the same request are routed to the write connection rather than a replica. This prevents stale reads caused by replication lag. + +The sticky flag is set by: + +- `execute()` — any INSERT, UPDATE, DELETE, or DDL statement +- `beginTransaction()` — entering a transaction + +```php +use Marko\Database\Connection\ConnectionInterface; + +class OrderService +{ + public function __construct( + private ConnectionInterface $connection, + ) {} + + public function placeOrder(array $data): int + { + // Writes set the sticky flag + $orderId = $this->connection->execute( + 'INSERT INTO orders (user_id, total) VALUES (?, ?)', + [$data['user_id'], $data['total']], + ); + + // This query routes to the write connection (sticky), not a replica, + // so it sees the order that was just inserted + return $this->connection->query( + 'SELECT id FROM orders WHERE id = ?', + [$orderId], + )[0]['id']; + } +} +``` + +The sticky flag persists until `resetStickyState()` is called. In a PHP-FPM application this happens automatically because each request runs in a fresh process. In long-running processes you must call it manually (see [Long-Running Processes](#long-running-processes)). + +## `prepare()` Policy + +`prepare()` always routes to the write connection. Prepared statements are typically used for bulk writes or repeated mutation patterns, so routing them to the primary is the safe default. There are no production callers of `prepare()` in the current Marko core, so this policy has no performance impact on stock setups. + +## Single-Request Fallback + +When a read query fails on a replica with a `PDOException` or `MarkoException`, `ReadWriteConnection` removes that replica from the candidate pool and retries the query on the next available replica. This continues until: + +- A replica responds successfully, or +- All replicas are exhausted, at which point `ReadException` is thrown + +``` +ReadException: All replicas failed to execute the query: ; +Context: While attempting to route a read query to an available replica +Suggestion: Check that at least one replica is reachable and accepting connections +``` + +Fallback is per-request only. The replica pool is rebuilt fresh on the next request (PHP-FPM) or the next call to `resetStickyState()` (long-running processes). + +:::caution +Sticky writes (via `execute()` or `beginTransaction()`) bypass all replicas entirely --- there is no fallback path. If the write connection is unavailable the underlying driver exception is propagated directly. +::: + +## Long-Running Processes + +In PHP-FPM the sticky flag is cleared automatically at the end of each request because each request is a new process. In a queue worker or other long-running process the sticky flag persists for the lifetime of the process. Call `resetStickyState()` between jobs to restore replica routing: + +```php +use Marko\Database\ReadWrite\Connection\ReadWriteConnection; +use Marko\Database\Connection\ConnectionInterface; + +class JobWorker +{ + public function __construct( + private ConnectionInterface $connection, + ) {} + + public function run(Job $job): void + { + try { + $job->handle(); + } finally { + // Always reset between jobs — even if the job threw + if ($this->connection instanceof ReadWriteConnection) { + $this->connection->resetStickyState(); + } + } + } +} +``` + +## Multi-Driver Limitation + +`ReadWriteConnection` binds `ConnectionInterface` as a container instance. This is the same interface binding used by all other database drivers, so only one driver can be active at a time. You cannot, for example, have a PostgreSQL read/write split alongside a MySQL connection in the same container. + +This constraint is inherited from the single-binding design of `ConnectionInterface` in `marko/database` and applies equally to `marko/database-pgsql` and `marko/database-mysql`. + +## Customization + +Override `ReadWriteConnection` using the [Preferences](/docs/concepts/dependency-injection/#overriding-another-modules-bindings) pattern. Define a `Preference` in your module's `module.php` to substitute your own implementation wherever `ReadWriteConnection` is resolved: + +```php title="module.php" + [ + ReadWriteConnection::class => CustomReadWriteConnection::class, + ], +]; +``` + +Your `CustomReadWriteConnection` must extend `ReadWriteConnection` (or independently implement `ConnectionInterface` and `TransactionInterface`). + +## API Reference + +### ReadWriteConnection + +Implements `ConnectionInterface` and `TransactionInterface`. Routes reads to replicas and writes to the primary. + +| Method | Routes To | Description | +|--------|-----------|-------------| +| `query(string $sql, array $bindings = []): array` | Read (replica or write if sticky) | Execute a SELECT and return all rows | +| `execute(string $sql, array $bindings = []): int` | Write (sets sticky) | Execute a write statement; returns affected row count | +| `prepare(string $sql): StatementInterface` | Write (always) | Prepare a statement for repeated execution | +| `lastInsertId(): int` | Write | Get the last auto-increment ID | +| `connect(): void` | Write | Establish the write connection | +| `disconnect(): void` | Write | Close the write connection | +| `isConnected(): bool` | Write | Check if the write connection is open | +| `beginTransaction(): void` | Write (sets sticky) | Start a transaction on the write connection | +| `commit(): void` | Write | Commit the current transaction | +| `rollback(): void` | Write | Roll back the current transaction | +| `inTransaction(): bool` | Write | Check if a transaction is active | +| `transaction(callable $callback): mixed` | Write | Run a callback inside an auto-managed transaction | +| `resetStickyState(): void` | — | Clear the sticky flag; subsequent reads route to replicas again | + +### ReadException + +Thrown when all replicas fail during a single read query. + +| Factory | Description | +|---------|-------------| +| `ReadException::allReplicasFailed(array $messages): self` | Builds the exception with a semicolon-joined summary of each replica's error message | + +### ReadWriteConnectionConfig + +Parses and validates the `connections` block from `config/database.php`. + +| Factory | Throws | Description | +|---------|--------|-------------| +| `ReadWriteConnectionConfig::fromArray(array $config): self` | `ReadWriteConfigException` | Validates presence of `write`, non-empty `read[]`, and valid `read_strategy` | + +### RandomReplicaSelector + +Selects a replica uniformly at random. Default strategy. + +### WeightedReplicaSelector + +Selects a replica proportionally to its configured weight. Constructed with an array of integer weights parallel to the replica array. diff --git a/docs/src/content/docs/packages/database.md b/docs/src/content/docs/packages/database.md index 60973647..81715341 100644 --- a/docs/src/content/docs/packages/database.md +++ b/docs/src/content/docs/packages/database.md @@ -877,19 +877,20 @@ marko db:seed Some databases speak an existing wire protocol (PostgreSQL or MySQL) but require different SQL dialect logic. CockroachDB, for example, accepts PostgreSQL connections but has its own DDL, introspection queries, and query-builder behaviour. A variant package can reuse the parent driver's connection and override only the four dialect interfaces. -### The 5-binding split +### The 6-binding split -Every driver package binds five interfaces. They fall into two categories: +Every driver package binds six interfaces. They fall into two categories: | Interface | Category | Role | |-----------|----------|------| | `ConnectionInterface` | **Wire** | PDO connection, DSN format, PostgreSQL/MySQL protocol | +| `ConnectionFactoryInterface` | **Wire** | Creates `ConnectionInterface` instances from a `DatabaseConfig` | | `SqlGeneratorInterface` | Dialect | DDL generation for schema diffs | | `IntrospectorInterface` | Dialect | Reading existing schema from `information_schema` etc. | | `QueryBuilderInterface` | Dialect | SELECT/INSERT/UPDATE/DELETE SQL generation | | `QueryBuilderFactoryInterface` | Dialect | Constructs query builder instances | -A wire-compatible variant inherits the parent's `ConnectionInterface` binding unchanged and overrides the four dialect interfaces. +A wire-compatible variant inherits the parent's `ConnectionInterface` and `ConnectionFactoryInterface` bindings unchanged and overrides the four dialect interfaces. ### CockroachDB example @@ -952,3 +953,7 @@ For the underlying `boot` callback mechanism, see [Overriding another module's b - [marko/database-pgsql](/docs/packages/database-pgsql/) — PostgreSQL driver - [marko/database-mysql](/docs/packages/database-mysql/) — MySQL driver + +## Read/Write Splitting + +To route reads to replicas and writes to a primary, see [marko/database-readwrite](/docs/packages/database-readwrite/). It wraps any existing driver connection using the decorator pattern — no changes to application code are required. diff --git a/packages/database-mysql/module.php b/packages/database-mysql/module.php index 6b2e9ea1..76e6c2ca 100644 --- a/packages/database-mysql/module.php +++ b/packages/database-mysql/module.php @@ -4,10 +4,12 @@ use Marko\Core\Container\ContainerInterface; use Marko\Database\Config\DatabaseConfig; +use Marko\Database\Connection\ConnectionFactoryInterface; use Marko\Database\Connection\ConnectionInterface; use Marko\Database\Diff\SqlGeneratorInterface; use Marko\Database\Introspection\IntrospectorInterface; use Marko\Database\MySql\Connection\MySqlConnection; +use Marko\Database\MySql\Connection\MySqlConnectionFactory; use Marko\Database\MySql\Introspection\MySqlIntrospector; use Marko\Database\MySql\Query\MySqlQueryBuilder; use Marko\Database\MySql\Query\MySqlQueryBuilderFactory; @@ -21,6 +23,7 @@ return [ 'bindings' => [ ConnectionInterface::class => MySqlConnection::class, + ConnectionFactoryInterface::class => MySqlConnectionFactory::class, IntrospectorInterface::class => function (ContainerInterface $container): IntrospectorInterface { $config = $container->get(DatabaseConfig::class); diff --git a/packages/database-mysql/src/Connection/MySqlConnectionFactory.php b/packages/database-mysql/src/Connection/MySqlConnectionFactory.php new file mode 100644 index 00000000..db757fec --- /dev/null +++ b/packages/database-mysql/src/Connection/MySqlConnectionFactory.php @@ -0,0 +1,19 @@ +charset); + } +} diff --git a/packages/database-mysql/tests/Connection/MySqlConnectionFactoryTest.php b/packages/database-mysql/tests/Connection/MySqlConnectionFactoryTest.php new file mode 100644 index 00000000..0e449961 --- /dev/null +++ b/packages/database-mysql/tests/Connection/MySqlConnectionFactoryTest.php @@ -0,0 +1,32 @@ +make($config); + + expect($connection)->toBeInstanceOf(MySqlConnection::class) + ->and($connection)->toBeInstanceOf(ConnectionInterface::class); + }); + + it('uses the default utf8mb4 charset', function (): void { + $config = createTestDatabaseConfig(); + $factory = new MySqlConnectionFactory(); + + $connection = $factory->make($config); + + // The default charset is utf8mb4 — verified by the DSN containing charset=utf8mb4 + expect($connection)->toBeInstanceOf(MySqlConnection::class) + ->and($connection->getDsn())->toContain('charset=utf8mb4'); + }); +}); diff --git a/packages/database-mysql/tests/Module/ModuleBindingsTest.php b/packages/database-mysql/tests/Module/ModuleBindingsTest.php index 8822becc..c066ba3e 100644 --- a/packages/database-mysql/tests/Module/ModuleBindingsTest.php +++ b/packages/database-mysql/tests/Module/ModuleBindingsTest.php @@ -7,11 +7,13 @@ use Closure; use Marko\Core\Path\ProjectPaths; use Marko\Database\Config\DatabaseConfig; +use Marko\Database\Connection\ConnectionFactoryInterface; use Marko\Database\Connection\ConnectionInterface; use Marko\Database\Diff\SqlGeneratorInterface; use Marko\Database\Exceptions\ConfigurationException; use Marko\Database\Introspection\IntrospectorInterface; use Marko\Database\MySql\Connection\MySqlConnection; +use Marko\Database\MySql\Connection\MySqlConnectionFactory; use Marko\Database\MySql\Query\MySqlQueryBuilderFactory; use Marko\Database\MySql\Sql\MySqlGenerator; use Marko\Database\Query\QueryBuilderFactoryInterface; @@ -49,7 +51,17 @@ $moduleConfig = require $modulePath . '/module.php'; expect($moduleConfig['bindings'])->toHaveKey(QueryBuilderFactoryInterface::class) - ->and($moduleConfig['bindings'][QueryBuilderFactoryInterface::class])->toBe(MySqlQueryBuilderFactory::class); + ->and($moduleConfig['bindings'][QueryBuilderFactoryInterface::class])->toBe( + MySqlQueryBuilderFactory::class + ); + }); + + it('binds ConnectionFactoryInterface to MySqlConnectionFactory in the module', function (): void { + $modulePath = dirname(__DIR__, 2); + $moduleConfig = require $modulePath . '/module.php'; + + expect($moduleConfig['bindings'])->toHaveKey(ConnectionFactoryInterface::class) + ->and($moduleConfig['bindings'][ConnectionFactoryInterface::class])->toBe(MySqlConnectionFactory::class); }); it('throws ConfigurationException when config file missing', function (): void { diff --git a/packages/database-pgsql/module.php b/packages/database-pgsql/module.php index 5bea8879..60926b1e 100644 --- a/packages/database-pgsql/module.php +++ b/packages/database-pgsql/module.php @@ -2,10 +2,12 @@ declare(strict_types=1); +use Marko\Database\Connection\ConnectionFactoryInterface; use Marko\Database\Connection\ConnectionInterface; use Marko\Database\Diff\SqlGeneratorInterface; use Marko\Database\Introspection\IntrospectorInterface; use Marko\Database\PgSql\Connection\PgSqlConnection; +use Marko\Database\PgSql\Connection\PgSqlConnectionFactory; use Marko\Database\PgSql\Introspection\PgSqlIntrospector; use Marko\Database\PgSql\Query\PgSqlQueryBuilder; use Marko\Database\PgSql\Query\PgSqlQueryBuilderFactory; @@ -19,6 +21,7 @@ return [ 'bindings' => [ ConnectionInterface::class => PgSqlConnection::class, + ConnectionFactoryInterface::class => PgSqlConnectionFactory::class, SqlGeneratorInterface::class => PgSqlGenerator::class, IntrospectorInterface::class => PgSqlIntrospector::class, QueryBuilderInterface::class => PgSqlQueryBuilder::class, diff --git a/packages/database-pgsql/src/Connection/PgSqlConnectionFactory.php b/packages/database-pgsql/src/Connection/PgSqlConnectionFactory.php new file mode 100644 index 00000000..57dd1a0a --- /dev/null +++ b/packages/database-pgsql/src/Connection/PgSqlConnectionFactory.php @@ -0,0 +1,19 @@ +charset); + } +} diff --git a/packages/database-pgsql/tests/Connection/PgSqlConnectionFactoryTest.php b/packages/database-pgsql/tests/Connection/PgSqlConnectionFactoryTest.php new file mode 100644 index 00000000..b89df263 --- /dev/null +++ b/packages/database-pgsql/tests/Connection/PgSqlConnectionFactoryTest.php @@ -0,0 +1,62 @@ + 'pgsql', + 'host' => 'localhost', + 'port' => 5432, + 'database' => 'test', + 'username' => 'user', + 'password' => 'pass', + ], true) . ';', + ); + + $paths = new ProjectPaths($tempDir); + $config = new DatabaseConfig($paths); + + unlink($tempDir . '/config/database.php'); + rmdir($tempDir . '/config'); + rmdir($tempDir); + + return $config; +} + +describe('PgSqlConnectionFactory', function (): void { + it('creates a PgSqlConnection from a DatabaseConfig', function (): void { + $config = makeFactoryTestConfig(); + $factory = new PgSqlConnectionFactory(); + + $connection = $factory->make($config); + + expect($connection)->toBeInstanceOf(PgSqlConnection::class) + ->and($connection)->toBeInstanceOf(ConnectionInterface::class); + }); + + it('uses the default utf8 charset', function (): void { + $config = makeFactoryTestConfig(); + $factory = new PgSqlConnectionFactory(); + + $connection = $factory->make($config); + + // Factory with default charset creates a valid PgSqlConnection + // The charset ('utf8') is the PgSqlConnection default — injected at construction + expect($connection)->toBeInstanceOf(PgSqlConnection::class) + ->and($connection->getDsn())->toBe('pgsql:host=localhost;port=5432;dbname=test'); + }); +}); diff --git a/packages/database-pgsql/tests/Module/ModuleBindingsTest.php b/packages/database-pgsql/tests/Module/ModuleBindingsTest.php index dca73718..8920be8c 100644 --- a/packages/database-pgsql/tests/Module/ModuleBindingsTest.php +++ b/packages/database-pgsql/tests/Module/ModuleBindingsTest.php @@ -6,11 +6,13 @@ use Marko\Core\Path\ProjectPaths; use Marko\Database\Config\DatabaseConfig; +use Marko\Database\Connection\ConnectionFactoryInterface; use Marko\Database\Connection\ConnectionInterface; use Marko\Database\Diff\SqlGeneratorInterface; use Marko\Database\Exceptions\ConfigurationException; use Marko\Database\Introspection\IntrospectorInterface; use Marko\Database\PgSql\Connection\PgSqlConnection; +use Marko\Database\PgSql\Connection\PgSqlConnectionFactory; use Marko\Database\PgSql\Introspection\PgSqlIntrospector; use Marko\Database\PgSql\Sql\PgSqlGenerator; @@ -41,6 +43,14 @@ ->and($moduleConfig['bindings'][IntrospectorInterface::class])->toBe(PgSqlIntrospector::class); }); + it('binds ConnectionFactoryInterface to PgSqlConnectionFactory in the module', function (): void { + $modulePath = dirname(__DIR__, 2); + $moduleConfig = require $modulePath . '/module.php'; + + expect($moduleConfig['bindings'])->toHaveKey(ConnectionFactoryInterface::class) + ->and($moduleConfig['bindings'][ConnectionFactoryInterface::class])->toBe(PgSqlConnectionFactory::class); + }); + it('throws ConfigurationException when config file missing', function (): void { // Create temp directory WITHOUT config $tempDir = sys_get_temp_dir() . '/marko_pgsql_noconfig_' . bin2hex(random_bytes(8)); diff --git a/packages/database-readwrite/.gitattributes b/packages/database-readwrite/.gitattributes new file mode 100644 index 00000000..e5736f06 --- /dev/null +++ b/packages/database-readwrite/.gitattributes @@ -0,0 +1,5 @@ +/tests export-ignore +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore diff --git a/packages/database-readwrite/LICENSE b/packages/database-readwrite/LICENSE new file mode 100644 index 00000000..eee3e37b --- /dev/null +++ b/packages/database-readwrite/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Devtomic LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/database-readwrite/README.md b/packages/database-readwrite/README.md new file mode 100644 index 00000000..e1e0be68 --- /dev/null +++ b/packages/database-readwrite/README.md @@ -0,0 +1,46 @@ +# marko/database-readwrite + +Routes reads to replicas, writes to primary --- drop-in decorator for any Marko database driver. + +## Installation + +```bash +composer require marko/database-readwrite +``` + +## Quick Example + +```php title="config/database.php" + 'readwrite', + 'connections' => [ + 'write' => [ + 'driver' => 'pgsql', + 'host' => $_ENV['DB_WRITE_HOST'] ?? 'localhost', + 'port' => (int) ($_ENV['DB_WRITE_PORT'] ?? 5432), + 'database' => $_ENV['DB_DATABASE'] ?? 'marko', + 'username' => $_ENV['DB_USERNAME'] ?? 'postgres', + 'password' => $_ENV['DB_PASSWORD'] ?? '', + ], + 'read' => [ + [ + 'driver' => 'pgsql', + 'host' => $_ENV['DB_READ_HOST'] ?? 'replica-1', + 'port' => (int) ($_ENV['DB_READ_PORT'] ?? 5432), + 'database' => $_ENV['DB_DATABASE'] ?? 'marko', + 'username' => $_ENV['DB_USERNAME'] ?? 'postgres', + 'password' => $_ENV['DB_PASSWORD'] ?? '', + ], + ], + 'read_strategy' => 'random', + ], +]; +``` + +## Documentation + +Full usage, configuration, API reference, and examples: [marko/database-readwrite](https://marko.build/docs/packages/database-readwrite/) diff --git a/packages/database-readwrite/composer.json b/packages/database-readwrite/composer.json new file mode 100644 index 00000000..6d6cced0 --- /dev/null +++ b/packages/database-readwrite/composer.json @@ -0,0 +1,35 @@ +{ + "name": "marko/database-readwrite", + "description": "Read/write connection splitting for the Marko Framework database layer", + "license": "MIT", + "type": "library", + "require": { + "php": "^8.5", + "ext-pdo": "*", + "marko/core": "self.version", + "marko/database": "self.version" + }, + "require-dev": { + "pestphp/pest": "^4.0" + }, + "autoload": { + "psr-4": { + "Marko\\Database\\ReadWrite\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Marko\\Database\\ReadWrite\\Tests\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, + "extra": { + "marko": { + "module": true + } + } +} diff --git a/packages/database-readwrite/module.php b/packages/database-readwrite/module.php new file mode 100644 index 00000000..33281f59 --- /dev/null +++ b/packages/database-readwrite/module.php @@ -0,0 +1,47 @@ + [], + 'boot' => function (ContainerInterface $container): void { + $config = $container->get(ConfigRepositoryInterface::class); + + if ($config->get('database.driver') !== 'readwrite') { + return; + } + + $connections = $config->get('database.connections'); + $rwConfig = ReadWriteConnectionConfig::fromArray(['connections' => $connections]); + + $factory = $container->get(ConnectionFactoryInterface::class); + $writeConnection = $factory->make(DatabaseConfig::fromArray($rwConfig->write)); + + $replicaConnections = array_map( + fn (array $replicaConfig) => $factory->make(DatabaseConfig::fromArray($replicaConfig)), + $rwConfig->reads, + ); + + $selector = match ($rwConfig->readStrategy) { + 'weighted' => new WeightedReplicaSelector( + array_map(fn (array $r) => $r['weight'] ?? 1, $rwConfig->reads), + ), + default => new RandomReplicaSelector(), + }; + + $readWriteConnection = new ReadWriteConnection($writeConnection, $replicaConnections, $selector); + $container->instance(ConnectionInterface::class, $readWriteConnection); + $container->instance(TransactionInterface::class, $readWriteConnection); + }, +]; diff --git a/packages/database-readwrite/src/Config/ReadWriteConnectionConfig.php b/packages/database-readwrite/src/Config/ReadWriteConnectionConfig.php new file mode 100644 index 00000000..3edd6574 --- /dev/null +++ b/packages/database-readwrite/src/Config/ReadWriteConnectionConfig.php @@ -0,0 +1,63 @@ +stickyWrite) { + return $this->write->query($sql, $bindings); + } + + // Try each replica in turn, falling back on PDOException or MarkoException. + // NOTE: WeightedReplicaSelector weights are based on original indices; they + // do not rebalance when replicas are removed during fallback (known v1 limitation). + $remaining = $this->replicas; + $failures = []; + + while ($remaining !== []) { + $replica = $this->replicaSelector->select($remaining); + + try { + return $replica->query($sql, $bindings); + } catch (PDOException $e) { + $failures[] = $e->getMessage(); + $remaining = array_values(array_filter($remaining, fn ($r) => $r !== $replica)); + } catch (MarkoException $e) { + $failures[] = $e->getMessage(); + $remaining = array_values(array_filter($remaining, fn ($r) => $r !== $replica)); + } + } + + throw ReadException::allReplicasFailed($failures); + } + + public function execute( + string $sql, + array $bindings = [], + ): int { + $this->stickyWrite = true; + + return $this->write->execute($sql, $bindings); + } + + public function prepare(string $sql): StatementInterface + { + return $this->write->prepare($sql); + } + + public function lastInsertId(): int + { + return $this->write->lastInsertId(); + } + + public function connect(): void + { + $this->write->connect(); + } + + public function disconnect(): void + { + $this->write->disconnect(); + } + + public function isConnected(): bool + { + return $this->write->isConnected(); + } + + public function beginTransaction(): void + { + $this->stickyWrite = true; + $this->write->beginTransaction(); + } + + public function commit(): void + { + $this->write->commit(); + } + + public function rollback(): void + { + $this->write->rollback(); + } + + public function inTransaction(): bool + { + return $this->write->inTransaction(); + } + + public function transaction(callable $callback): mixed + { + return $this->write->transaction($callback); + } + + public function resetStickyState(): void + { + $this->stickyWrite = false; + } +} diff --git a/packages/database-readwrite/src/Exceptions/ReadException.php b/packages/database-readwrite/src/Exceptions/ReadException.php new file mode 100644 index 00000000..a89324db --- /dev/null +++ b/packages/database-readwrite/src/Exceptions/ReadException.php @@ -0,0 +1,27 @@ +weights); + $rand = random_int(1, $total); + $cumulative = 0; + + foreach ($this->weights as $index => $weight) { + $cumulative += $weight; + if ($rand <= $cumulative) { + return $replicas[$index]; + } + } + + // Fallback (should not be reached if weights are valid) + return $replicas[count($replicas) - 1]; + } +} diff --git a/packages/database-readwrite/tests/Config/ReadWriteConnectionConfigTest.php b/packages/database-readwrite/tests/Config/ReadWriteConnectionConfigTest.php new file mode 100644 index 00000000..24839649 --- /dev/null +++ b/packages/database-readwrite/tests/Config/ReadWriteConnectionConfigTest.php @@ -0,0 +1,168 @@ + 'pgsql', + 'host' => 'primary.db', + 'port' => 5432, + 'database' => 'app', + 'username' => 'user', + 'password' => 'pass', +]; + +$validReads = [ + ['driver' => 'pgsql', 'host' => 'replica1.db', 'port' => 5432, 'database' => 'app', 'username' => 'user', 'password' => 'pass'], + ['driver' => 'pgsql', 'host' => 'replica2.db', 'port' => 5432, 'database' => 'app', 'username' => 'user', 'password' => 'pass'], +]; + +$validWeightedReads = [ + ['driver' => 'pgsql', 'host' => 'replica1.db', 'port' => 5432, 'database' => 'app', 'username' => 'user', 'password' => 'pass', 'weight' => 1], + ['driver' => 'pgsql', 'host' => 'replica2.db', 'port' => 5432, 'database' => 'app', 'username' => 'user', 'password' => 'pass', 'weight' => 3], +]; + +describe('ReadWriteConnectionConfig', function () use ($validWrite, $validReads, $validWeightedReads): void { + it( + 'builds ReadWriteConnectionConfig from a valid array with random strategy', + function () use ($validWrite, $validReads): void { + $config = ReadWriteConnectionConfig::fromArray([ + 'connections' => [ + 'write' => $validWrite, + 'read' => $validReads, + 'read_strategy' => 'random', + ], + ]); + + expect($config->write)->toBe($validWrite) + ->and($config->reads)->toBe($validReads) + ->and($config->readStrategy)->toBe('random'); + }, + ); + + it('defaults read_strategy to random when not specified', function () use ($validWrite, $validReads): void { + $config = ReadWriteConnectionConfig::fromArray([ + 'connections' => [ + 'write' => $validWrite, + 'read' => $validReads, + ], + ]); + + expect($config->readStrategy)->toBe('random'); + }); + + it( + 'builds ReadWriteConnectionConfig from a valid array with weighted strategy', + function () use ($validWrite, $validWeightedReads): void { + $config = ReadWriteConnectionConfig::fromArray([ + 'connections' => [ + 'write' => $validWrite, + 'read' => $validWeightedReads, + 'read_strategy' => 'weighted', + ], + ]); + + expect($config->write)->toBe($validWrite) + ->and($config->reads)->toBe($validWeightedReads) + ->and($config->readStrategy)->toBe('weighted'); + }, + ); + + it('throws ReadWriteConfigException when connections key is missing', function (): void { + expect(fn () => ReadWriteConnectionConfig::fromArray([]))->toThrow(ReadWriteConfigException::class); + }); + + it('throws ReadWriteConfigException when write connection is missing', function () use ($validReads): void { + expect(fn () => ReadWriteConnectionConfig::fromArray([ + 'connections' => [ + 'read' => $validReads, + ], + ]))->toThrow(ReadWriteConfigException::class); + }); + + it('throws ReadWriteConfigException when read connections array is empty', function () use ($validWrite): void { + expect(fn () => ReadWriteConnectionConfig::fromArray([ + 'connections' => [ + 'write' => $validWrite, + 'read' => [], + ], + ]))->toThrow(ReadWriteConfigException::class); + }); + + it( + 'throws ReadWriteConfigException when read_strategy is unknown', + function () use ($validWrite, $validReads): void { + expect(fn () => ReadWriteConnectionConfig::fromArray([ + 'connections' => [ + 'write' => $validWrite, + 'read' => $validReads, + 'read_strategy' => 'round_robin', + ], + ]))->toThrow(ReadWriteConfigException::class); + }, + ); + + it( + 'throws ReadWriteConfigException when weight is zero in weighted strategy', + function () use ($validWrite): void { + expect(fn () => ReadWriteConnectionConfig::fromArray([ + 'connections' => [ + 'write' => $validWrite, + 'read' => [ + ['driver' => 'pgsql', 'host' => 'replica1.db', 'weight' => 0], + ], + 'read_strategy' => 'weighted', + ], + ]))->toThrow(ReadWriteConfigException::class); + }, + ); + + it( + 'throws ReadWriteConfigException when weight is negative in weighted strategy', + function () use ($validWrite): void { + expect(fn () => ReadWriteConnectionConfig::fromArray([ + 'connections' => [ + 'write' => $validWrite, + 'read' => [ + ['driver' => 'pgsql', 'host' => 'replica1.db', 'weight' => -1], + ], + 'read_strategy' => 'weighted', + ], + ]))->toThrow(ReadWriteConfigException::class); + }, + ); + + it( + 'throws ReadWriteConfigException when weight is non-integer in weighted strategy', + function () use ($validWrite): void { + expect(fn () => ReadWriteConnectionConfig::fromArray([ + 'connections' => [ + 'write' => $validWrite, + 'read' => [ + ['driver' => 'pgsql', 'host' => 'replica1.db', 'weight' => 1.5], + ], + 'read_strategy' => 'weighted', + ], + ]))->toThrow(ReadWriteConfigException::class); + }, + ); + + it('ignores weight validation when using random strategy', function () use ($validWrite): void { + $readsWithBadWeights = [ + ['driver' => 'pgsql', 'host' => 'replica1.db', 'weight' => -5], + ['driver' => 'pgsql', 'host' => 'replica2.db', 'weight' => 0], + ]; + + $config = ReadWriteConnectionConfig::fromArray([ + 'connections' => [ + 'write' => $validWrite, + 'read' => $readsWithBadWeights, + 'read_strategy' => 'random', + ], + ]); + + expect($config->readStrategy)->toBe('random'); + }); +}); diff --git a/packages/database-readwrite/tests/Connection/ReadWriteConnectionTest.php b/packages/database-readwrite/tests/Connection/ReadWriteConnectionTest.php new file mode 100644 index 00000000..d80f3d9e --- /dev/null +++ b/packages/database-readwrite/tests/Connection/ReadWriteConnectionTest.php @@ -0,0 +1,639 @@ +calls[] = 'connect'; + } + + public function disconnect(): void + { + $this->calls[] = 'disconnect'; + } + + public function isConnected(): bool + { + return $this->overrides['isConnected'] ?? true; + } + + public function query( + string $sql, + array $bindings = [], + ): array { + $this->calls[] = ['query', $sql, $bindings]; + + return $this->overrides['query'] ?? [['col' => 'val']]; + } + + public function execute( + string $sql, + array $bindings = [], + ): int { + $this->calls[] = ['execute', $sql, $bindings]; + + return $this->overrides['execute'] ?? 1; + } + + public function prepare(string $sql): StatementInterface + { + $this->calls[] = ['prepare', $sql]; + + return $this->overrides['prepare'] ?? throw new RuntimeException('Not implemented'); + } + + public function lastInsertId(): int + { + $this->calls[] = 'lastInsertId'; + + return $this->overrides['lastInsertId'] ?? 42; + } + + public function beginTransaction(): void + { + $this->calls[] = 'beginTransaction'; + } + + public function commit(): void + { + $this->calls[] = 'commit'; + } + + public function rollback(): void + { + $this->calls[] = 'rollback'; + } + + public function inTransaction(): bool + { + $this->calls[] = 'inTransaction'; + + return $this->overrides['inTransaction'] ?? false; + } + + public function transaction(callable $callback): mixed + { + $this->calls[] = 'transaction'; + + return $callback(); + } + }; +} + +function makeSelector(ConnectionInterface $replica): ReplicaSelectorInterface +{ + return new class ($replica) implements ReplicaSelectorInterface + { + public function __construct(private ConnectionInterface $replica) {} + + public function select(array $replicas): ConnectionInterface + { + return $this->replica; + } + }; +} + +/** + * A selector that returns replicas from the provided list in order (round-robin). + */ +function makeSequentialSelector(): ReplicaSelectorInterface +{ + return new class () implements ReplicaSelectorInterface + { + public function select(array $replicas): ConnectionInterface + { + return $replicas[0]; + } + }; +} + +/** + * Create a connection whose query() throws a PDOException. + */ +function makeThrowingConnection(string $message = 'connection refused'): ConnectionInterface&TransactionInterface +{ + return new class ($message) implements ConnectionInterface, TransactionInterface + { + public array $calls = []; + + public function __construct(private string $message) {} + + public function connect(): void {} + + public function disconnect(): void {} + + public function isConnected(): bool + { + return false; + } + + public function query( + string $sql, + array $bindings = [], + ): array { + $this->calls[] = ['query', $sql, $bindings]; + throw new PDOException($this->message); + } + + public function execute( + string $sql, + array $bindings = [], + ): int { + return 0; + } + + public function prepare(string $sql): StatementInterface + { + throw new RuntimeException('Not implemented'); + } + + public function lastInsertId(): int + { + return 0; + } + + public function beginTransaction(): void {} + + public function commit(): void {} + + public function rollback(): void {} + + public function inTransaction(): bool + { + return false; + } + + public function transaction(callable $callback): mixed + { + return $callback(); + } + }; +} + +describe('ReadWriteConnection', function (): void { + it('routes query to the selected replica', function (): void { + $write = makeConnection(); + $replica = makeConnection(['query' => [['id' => 1]]]); + $selector = makeSelector($replica); + + $conn = new ReadWriteConnection($write, [$replica], $selector); + $result = $conn->query('SELECT 1'); + + expect($result)->toBe([['id' => 1]]) + ->and($replica->calls)->toContain(['query', 'SELECT 1', []]) + ->and($write->calls)->toBeEmpty(); + }); + + it('routes execute to the write connection', function (): void { + $write = makeConnection(['execute' => 5]); + $replica = makeConnection(); + $selector = makeSelector($replica); + + $conn = new ReadWriteConnection($write, [$replica], $selector); + $affected = $conn->execute('DELETE FROM foo'); + + expect($affected)->toBe(5) + ->and($write->calls)->toContain(['execute', 'DELETE FROM foo', []]) + ->and($replica->calls)->toBeEmpty(); + }); + + it('routes prepare to the write connection', function (): void { + $statement = new class () implements StatementInterface + { + public function execute(array $bindings = []): bool + { + return true; + } + + public function fetchAll(): array + { + return []; + } + + public function fetch(): ?array + { + return null; + } + + public function rowCount(): int + { + return 0; + } + }; + + $write = makeConnection(['prepare' => $statement]); + $replica = makeConnection(); + $selector = makeSelector($replica); + + $conn = new ReadWriteConnection($write, [$replica], $selector); + $result = $conn->prepare('INSERT INTO foo VALUES (?)'); + + expect($result)->toBe($statement) + ->and($write->calls)->toContain(['prepare', 'INSERT INTO foo VALUES (?)']) + ->and($replica->calls)->toBeEmpty(); + }); + + it('routes lastInsertId to the write connection', function (): void { + $write = makeConnection(['lastInsertId' => 99]); + $replica = makeConnection(); + $selector = makeSelector($replica); + + $conn = new ReadWriteConnection($write, [$replica], $selector); + $id = $conn->lastInsertId(); + + expect($id)->toBe(99) + ->and($write->calls)->toContain('lastInsertId') + ->and($replica->calls)->toBeEmpty(); + }); + + it('routes connect to the write connection', function (): void { + $write = makeConnection(); + $replica = makeConnection(); + $selector = makeSelector($replica); + + $conn = new ReadWriteConnection($write, [$replica], $selector); + $conn->connect(); + + expect($write->calls)->toContain('connect') + ->and($replica->calls)->toBeEmpty(); + }); + + it('routes disconnect to the write connection', function (): void { + $write = makeConnection(); + $replica = makeConnection(); + $selector = makeSelector($replica); + + $conn = new ReadWriteConnection($write, [$replica], $selector); + $conn->disconnect(); + + expect($write->calls)->toContain('disconnect') + ->and($replica->calls)->toBeEmpty(); + }); + + it('delegates isConnected to the write connection', function (): void { + $write = makeConnection(['isConnected' => false]); + $replica = makeConnection(['isConnected' => true]); + $selector = makeSelector($replica); + + $conn = new ReadWriteConnection($write, [$replica], $selector); + + expect($conn->isConnected())->toBeFalse(); + }); + + it('uses the replica selector to choose a replica for queries', function (): void { + $write = makeConnection(); + $replica1 = makeConnection(['query' => [['a' => 1]]]); + $replica2 = makeConnection(['query' => [['b' => 2]]]); + + $alwaysSecond = new class ($replica2) implements ReplicaSelectorInterface + { + public int $selectCallCount = 0; + + public function __construct(private ConnectionInterface $replica) {} + + public function select(array $replicas): ConnectionInterface + { + $this->selectCallCount++; + + return $this->replica; + } + }; + + $conn = new ReadWriteConnection($write, [$replica1, $replica2], $alwaysSecond); + $result = $conn->query('SELECT 1'); + + expect($result)->toBe([['b' => 2]]) + ->and($alwaysSecond->selectCallCount)->toBe(1) + ->and($replica1->calls)->toBeEmpty(); + }); + + it('passes query params to the replica', function (): void { + $write = makeConnection(); + $replica = makeConnection(['query' => [['id' => 7]]]); + $selector = makeSelector($replica); + + $conn = new ReadWriteConnection($write, [$replica], $selector); + $conn->query('SELECT * FROM users WHERE id = ?', [7]); + + expect($replica->calls)->toContain(['query', 'SELECT * FROM users WHERE id = ?', [7]]); + }); + + it('passes execute params to the write connection', function (): void { + $write = makeConnection(['execute' => 1]); + $replica = makeConnection(); + $selector = makeSelector($replica); + + $conn = new ReadWriteConnection($write, [$replica], $selector); + $conn->execute('UPDATE users SET name = ? WHERE id = ?', ['Alice', 3]); + + expect($write->calls)->toContain(['execute', 'UPDATE users SET name = ? WHERE id = ?', ['Alice', 3]]); + }); + + it('delegates beginTransaction to the write connection', function (): void { + $write = makeConnection(); + $replica = makeConnection(); + $selector = makeSelector($replica); + + $conn = new ReadWriteConnection($write, [$replica], $selector); + $conn->beginTransaction(); + + expect($write->calls)->toContain('beginTransaction') + ->and($replica->calls)->toBeEmpty(); + }); + + it('delegates commit to the write connection', function (): void { + $write = makeConnection(); + $replica = makeConnection(); + $selector = makeSelector($replica); + + $conn = new ReadWriteConnection($write, [$replica], $selector); + $conn->commit(); + + expect($write->calls)->toContain('commit') + ->and($replica->calls)->toBeEmpty(); + }); + + it('delegates rollback to the write connection', function (): void { + $write = makeConnection(); + $replica = makeConnection(); + $selector = makeSelector($replica); + + $conn = new ReadWriteConnection($write, [$replica], $selector); + $conn->rollback(); + + expect($write->calls)->toContain('rollback') + ->and($replica->calls)->toBeEmpty(); + }); + + it('delegates inTransaction to the write connection', function (): void { + $write = makeConnection(['inTransaction' => true]); + $replica = makeConnection(); + $selector = makeSelector($replica); + + $conn = new ReadWriteConnection($write, [$replica], $selector); + + expect($conn->inTransaction())->toBeTrue() + ->and($write->calls)->toContain('inTransaction') + ->and($replica->calls)->toBeEmpty(); + }); + + it('delegates transaction to the write connection', function (): void { + $write = makeConnection(); + $replica = makeConnection(); + $selector = makeSelector($replica); + + $conn = new ReadWriteConnection($write, [$replica], $selector); + $conn->transaction(function (): void {}); + + expect($write->calls)->toContain('transaction') + ->and($replica->calls)->toBeEmpty(); + }); + + it('returns the transaction callback result', function (): void { + $write = makeConnection(); + $replica = makeConnection(); + $selector = makeSelector($replica); + + $conn = new ReadWriteConnection($write, [$replica], $selector); + $result = $conn->transaction(fn () => 'expected-value'); + + expect($result)->toBe('expected-value'); + }); + + it('routes query to write when sticky after execute', function (): void { + $write = makeConnection(['query' => [['id' => 1]]]); + $replica = makeConnection(['query' => [['id' => 99]]]); + $selector = makeSelector($replica); + + $conn = new ReadWriteConnection($write, [$replica], $selector); + $conn->execute('INSERT INTO foo VALUES (1)'); + $result = $conn->query('SELECT 1'); + + expect($result)->toBe([['id' => 1]]) + ->and($write->calls)->toContain(['query', 'SELECT 1', []]) + ->and($replica->calls)->not->toContain(['query', 'SELECT 1', []]); + }); + + it('routes query to write when sticky after beginTransaction', function (): void { + $write = makeConnection(['query' => [['id' => 1]]]); + $replica = makeConnection(['query' => [['id' => 99]]]); + $selector = makeSelector($replica); + + $conn = new ReadWriteConnection($write, [$replica], $selector); + $conn->beginTransaction(); + $result = $conn->query('SELECT 1'); + + expect($result)->toBe([['id' => 1]]) + ->and($write->calls)->toContain(['query', 'SELECT 1', []]) + ->and($replica->calls)->not->toContain(['query', 'SELECT 1', []]); + }); + + it('routes query to replica when not sticky', function (): void { + $write = makeConnection(['query' => [['id' => 1]]]); + $replica = makeConnection(['query' => [['id' => 99]]]); + $selector = makeSelector($replica); + + $conn = new ReadWriteConnection($write, [$replica], $selector); + $result = $conn->query('SELECT 1'); + + expect($result)->toBe([['id' => 99]]) + ->and($replica->calls)->toContain(['query', 'SELECT 1', []]) + ->and($write->calls)->not->toContain(['query', 'SELECT 1', []]); + }); + + it('resets sticky state when resetStickyState is called', function (): void { + $write = makeConnection(['query' => [['id' => 1]]]); + $replica = makeConnection(['query' => [['id' => 99]]]); + $selector = makeSelector($replica); + + $conn = new ReadWriteConnection($write, [$replica], $selector); + $conn->execute('INSERT INTO foo VALUES (1)'); + $conn->resetStickyState(); + $result = $conn->query('SELECT 1'); + + expect($result)->toBe([['id' => 99]]) + ->and($replica->calls)->toContain(['query', 'SELECT 1', []]); + }); + + it('stays sticky after commit', function (): void { + $write = makeConnection(['query' => [['id' => 1]]]); + $replica = makeConnection(['query' => [['id' => 99]]]); + $selector = makeSelector($replica); + + $conn = new ReadWriteConnection($write, [$replica], $selector); + $conn->beginTransaction(); + $conn->commit(); + $result = $conn->query('SELECT 1'); + + expect($result)->toBe([['id' => 1]]) + ->and($write->calls)->toContain(['query', 'SELECT 1', []]) + ->and($replica->calls)->not->toContain(['query', 'SELECT 1', []]); + }); + + it('stays sticky after rollback', function (): void { + $write = makeConnection(['query' => [['id' => 1]]]); + $replica = makeConnection(['query' => [['id' => 99]]]); + $selector = makeSelector($replica); + + $conn = new ReadWriteConnection($write, [$replica], $selector); + $conn->beginTransaction(); + $conn->rollback(); + $result = $conn->query('SELECT 1'); + + expect($result)->toBe([['id' => 1]]) + ->and($write->calls)->toContain(['query', 'SELECT 1', []]) + ->and($replica->calls)->not->toContain(['query', 'SELECT 1', []]); + }); + + it('provides a public resetStickyState method', function (): void { + $write = makeConnection(); + $replica = makeConnection(); + $selector = makeSelector($replica); + + $conn = new ReadWriteConnection($write, [$replica], $selector); + + $reflection = new ReflectionMethod($conn, 'resetStickyState'); + + expect(method_exists($conn, 'resetStickyState'))->toBeTrue() + ->and($reflection->isPublic())->toBeTrue(); + }); + + it('tries the next replica when first replica throws PDOException', function (): void { + $write = makeConnection(); + $failing = makeThrowingConnection('replica1 down'); + $good = makeConnection(['query' => [['id' => 2]]]); + $selector = makeSequentialSelector(); + + $conn = new ReadWriteConnection($write, [$failing, $good], $selector); + $result = $conn->query('SELECT 1'); + + expect($result)->toBe([['id' => 2]]) + ->and($failing->calls)->toContain(['query', 'SELECT 1', []]) + ->and($good->calls)->toContain(['query', 'SELECT 1', []]); + }); + + it('throws ReadException when all replicas fail', function (): void { + $write = makeConnection(); + $failing1 = makeThrowingConnection('replica1 down'); + $failing2 = makeThrowingConnection('replica2 down'); + $selector = makeSequentialSelector(); + + $conn = new ReadWriteConnection($write, [$failing1, $failing2], $selector); + + expect(fn () => $conn->query('SELECT 1'))->toThrow(ReadException::class); + }); + + it('succeeds on second replica after first replica fails', function (): void { + $write = makeConnection(); + $failing = makeThrowingConnection('first down'); + $good = makeConnection(['query' => [['name' => 'alice']]]); + $selector = makeSequentialSelector(); + + $conn = new ReadWriteConnection($write, [$failing, $good], $selector); + $result = $conn->query('SELECT name FROM users', []); + + expect($result)->toBe([['name' => 'alice']]) + ->and($good->calls)->toContain(['query', 'SELECT name FROM users', []]); + }); + + it('bubbles non-PDOException errors immediately without trying other replicas', function (): void { + $write = makeConnection(); + $badQuery = new class () implements ConnectionInterface, TransactionInterface + { + public array $calls = []; + + public function connect(): void {} + + public function disconnect(): void {} + + public function isConnected(): bool + { + return true; + } + + public function query( + string $sql, + array $bindings = [], + ): array { + $this->calls[] = ['query', $sql, $bindings]; + throw new InvalidArgumentException('SQL syntax error'); + } + + public function execute( + string $sql, + array $bindings = [], + ): int { + return 0; + } + + public function prepare(string $sql): StatementInterface + { + throw new RuntimeException('Not implemented'); + } + + public function lastInsertId(): int + { + return 0; + } + + public function beginTransaction(): void {} + + public function commit(): void {} + + public function rollback(): void {} + + public function inTransaction(): bool + { + return false; + } + + public function transaction(callable $callback): mixed + { + return $callback(); + } + }; + + $good = makeConnection(['query' => [['id' => 1]]]); + $selector = makeSequentialSelector(); + + $conn = new ReadWriteConnection($write, [$badQuery, $good], $selector); + + expect(fn () => $conn->query('INVALID SQL')) + ->toThrow(InvalidArgumentException::class, 'SQL syntax error'); + + expect($badQuery->calls)->toContain(['query', 'INVALID SQL', []]) + ->and($good->calls)->toBeEmpty(); + }); + + it('includes failure messages in ReadException', function (): void { + $write = makeConnection(); + $failing1 = makeThrowingConnection('replica1 timed out'); + $failing2 = makeThrowingConnection('replica2 refused'); + $selector = makeSequentialSelector(); + + $conn = new ReadWriteConnection($write, [$failing1, $failing2], $selector); + + try { + $conn->query('SELECT 1'); + fail('Expected ReadException to be thrown'); + } catch (ReadException $e) { + expect($e->getMessage())->toContain('replica1 timed out') + ->and($e->getMessage())->toContain('replica2 refused'); + } + }); +}); diff --git a/packages/database-readwrite/tests/Integration/MySqlWiringTest.php b/packages/database-readwrite/tests/Integration/MySqlWiringTest.php new file mode 100644 index 00000000..1243d0ce --- /dev/null +++ b/packages/database-readwrite/tests/Integration/MySqlWiringTest.php @@ -0,0 +1,334 @@ +failOnQuery) { + throw new PDOException('Connection refused'); + } + + return ['result' => 'mysql-read']; + } + + public function execute( + string $sql, + array $bindings = [], + ): int { + return 1; + } + + public function prepare(string $sql): StatementInterface + { + throw new RuntimeException('Not implemented'); + } + + public function lastInsertId(): int + { + return 0; + } + + public function beginTransaction(): void {} + + public function commit(): void {} + + public function rollback(): void {} + + public function inTransaction(): bool + { + return false; + } + + public function transaction(callable $callback): mixed + { + return $callback(); + } + }; +} + +function getMySqlBootCallback(): Closure +{ + $module = require dirname(__DIR__, 2) . '/module.php'; + + return $module['boot']; +} + +function makeMySqlConfigRepository( + string $readStrategy = 'random', + array $reads = [], +): ConfigRepositoryInterface { + $defaultReads = [ + ['driver' => 'mysql', 'host' => 'read-host-1', 'port' => 3306, 'database' => 'app_db', 'username' => 'app_user', 'password' => 'secret'], + ]; + + $finalReads = $reads !== [] ? $reads : $defaultReads; + + return new class ($readStrategy, $finalReads) implements ConfigRepositoryInterface + { + public function __construct( + private string $readStrategy, + private array $reads, + ) {} + + public function get( + string $key, + ?string $scope = null, + ): mixed { + return match ($key) { + 'database.driver' => 'readwrite', + 'database.connections' => [ + 'write' => ['driver' => 'mysql', 'host' => 'write-host', 'port' => 3306, 'database' => 'app_db', 'username' => 'app_user', 'password' => 'secret'], + 'read' => $this->reads, + 'read_strategy' => $this->readStrategy, + ], + default => null, + }; + } + + public function has( + string $key, + ?string $scope = null, + ): bool { + return true; + } + + public function getString( + string $key, + ?string $scope = null, + ): string { + return ''; + } + + public function getInt( + string $key, + ?string $scope = null, + ): int { + return 0; + } + + public function getBool( + string $key, + ?string $scope = null, + ): bool { + return false; + } + + public function getFloat( + string $key, + ?string $scope = null, + ): float { + return 0.0; + } + + public function getArray( + string $key, + ?string $scope = null, + ): array { + return []; + } + + public function all(?string $scope = null): array + { + return []; + } + + public function withScope(string $scope): ConfigRepositoryInterface + { + return $this; + } + }; +} + +function makeMySqlSpyFactory(bool $failFirstReplica = false): ConnectionFactoryInterface +{ + return new class ($failFirstReplica) implements ConnectionFactoryInterface + { + public int $callCount = 0; + + /** @var array */ + public array $receivedConfigs = []; + + /** @var array */ + public array $createdConnections = []; + + public function __construct(private bool $failFirstReplica) {} + + public function make(DatabaseConfig $config): ConnectionInterface + { + $this->callCount++; + $this->receivedConfigs[] = $config; + + // First call is for write connection, subsequent calls are replicas + $isWrite = $this->callCount === 1; + $isFirstReplica = $this->callCount === 2; + $shouldFail = !$isWrite && $isFirstReplica && $this->failFirstReplica; + + $conn = makeMySqlTestConnection(failOnQuery: $shouldFail); + $this->createdConnections[] = $conn; + + return $conn; + } + }; +} + +function makeMySqlContainer(ConfigRepositoryInterface $config, ConnectionFactoryInterface $factory): ContainerInterface +{ + return new class ($config, $factory) implements ContainerInterface + { + /** @var array */ + public array $registered = []; + + public function __construct( + private ConfigRepositoryInterface $config, + private ConnectionFactoryInterface $factory, + ) {} + + public function get(string $id): mixed + { + return match ($id) { + ConfigRepositoryInterface::class => $this->config, + ConnectionFactoryInterface::class => $this->factory, + default => throw new RuntimeException("Unexpected get($id)"), + }; + } + + public function has(string $id): bool + { + return true; + } + + public function singleton(string $id): void {} + + public function instance( + string $id, + object $instance, + ): void { + $this->registered[$id] = $instance; + } + + public function call(Closure $callable): mixed + { + return $callable($this); + } + }; +} + +describe('mysql wiring integration', function (): void { + it('wires ReadWriteConnection as ConnectionInterface via boot callback with mysql config', function (): void { + $config = makeMySqlConfigRepository(); + $factory = makeMySqlSpyFactory(); + $container = makeMySqlContainer($config, $factory); + + $boot = getMySqlBootCallback(); + $boot($container); + + expect($container->registered)->toHaveKey(ConnectionInterface::class) + ->and($container->registered[ConnectionInterface::class])->toBeInstanceOf(ReadWriteConnection::class); + }); + + it('routes read queries to the replica connection with mysql driver', function (): void { + $config = makeMySqlConfigRepository(); + $factory = makeMySqlSpyFactory(); + $container = makeMySqlContainer($config, $factory); + + $boot = getMySqlBootCallback(); + $boot($container); + + /** @var ReadWriteConnection $rwConn */ + $rwConn = $container->registered[ConnectionInterface::class]; + + $result = $rwConn->query('SELECT 1'); + + expect($result)->toBe(['result' => 'mysql-read']); + }); + + it('routes write queries to the write connection with mysql driver', function (): void { + $config = makeMySqlConfigRepository(); + $factory = makeMySqlSpyFactory(); + $container = makeMySqlContainer($config, $factory); + + $boot = getMySqlBootCallback(); + $boot($container); + + /** @var ReadWriteConnection $rwConn */ + $rwConn = $container->registered[ConnectionInterface::class]; + + $rowsAffected = $rwConn->execute('INSERT INTO users (name) VALUES (?)', ['Alice']); + + expect($rowsAffected)->toBe(1); + }); + + it('uses WeightedReplicaSelector when read_strategy is weighted with mysql config', function (): void { + $config = makeMySqlConfigRepository( + readStrategy: 'weighted', + reads: [ + ['driver' => 'mysql', 'host' => 'read-host-1', 'port' => 3306, 'database' => 'app_db', 'username' => 'app_user', 'password' => 'secret', 'weight' => 1], + ['driver' => 'mysql', 'host' => 'read-host-2', 'port' => 3306, 'database' => 'app_db', 'username' => 'app_user', 'password' => 'secret', 'weight' => 3], + ], + ); + $factory = makeMySqlSpyFactory(); + $container = makeMySqlContainer($config, $factory); + + $boot = getMySqlBootCallback(); + $boot($container); + + $rwConn = $container->registered[ConnectionInterface::class]; + + $reflection = new ReflectionClass($rwConn); + $selectorProp = $reflection->getProperty('replicaSelector'); + $selector = $selectorProp->getValue($rwConn); + + expect($selector)->toBeInstanceOf(WeightedReplicaSelector::class); + }); + + it('falls back to next replica when first replica fails', function (): void { + $config = makeMySqlConfigRepository( + reads: [ + ['driver' => 'mysql', 'host' => 'read-host-1', 'port' => 3306, 'database' => 'app_db', 'username' => 'app_user', 'password' => 'secret'], + ['driver' => 'mysql', 'host' => 'read-host-2', 'port' => 3306, 'database' => 'app_db', 'username' => 'app_user', 'password' => 'secret'], + ], + ); + + $failFirstReplica = true; + $factory = makeMySqlSpyFactory(failFirstReplica: $failFirstReplica); + $container = makeMySqlContainer($config, $factory); + + $boot = getMySqlBootCallback(); + $boot($container); + + /** @var ReadWriteConnection $rwConn */ + $rwConn = $container->registered[ConnectionInterface::class]; + + // The first replica will throw a PDOException; the second should succeed + $result = $rwConn->query('SELECT 1'); + + expect($result)->toBe(['result' => 'mysql-read']); + }); +}); diff --git a/packages/database-readwrite/tests/Integration/PgSqlWiringTest.php b/packages/database-readwrite/tests/Integration/PgSqlWiringTest.php new file mode 100644 index 00000000..9b613cc1 --- /dev/null +++ b/packages/database-readwrite/tests/Integration/PgSqlWiringTest.php @@ -0,0 +1,357 @@ +queryCalls[] = $sql; + + return [['result' => 'row']]; + } + + public function execute( + string $sql, + array $bindings = [], + ): int { + $this->executeCalls[] = $sql; + + return 1; + } + + public function prepare(string $sql): StatementInterface + { + throw new RuntimeException('Not implemented'); + } + + public function lastInsertId(): int + { + return 1; + } + + public function beginTransaction(): void {} + + public function commit(): void {} + + public function rollback(): void {} + + public function inTransaction(): bool + { + return false; + } + + public function transaction(callable $callback): mixed + { + return $callback(); + } + }; +} + +function makePgSqlConfigRepository(): ConfigRepositoryInterface +{ + return new class () implements ConfigRepositoryInterface + { + public function get( + string $key, + ?string $scope = null, + ): mixed { + return match ($key) { + 'database.driver' => 'readwrite', + 'database.connections' => [ + 'write' => ['driver' => 'pgsql', 'host' => 'write-pg-host', 'port' => 5432, 'database' => 'mydb', 'username' => 'pguser', 'password' => 'secret'], + 'read' => [ + ['driver' => 'pgsql', 'host' => 'replica-pg-host', 'port' => 5432, 'database' => 'mydb', 'username' => 'pguser', 'password' => 'secret'], + ], + 'read_strategy' => 'random', + ], + default => null, + }; + } + + public function has( + string $key, + ?string $scope = null, + ): bool { + return true; + } + + public function getString( + string $key, + ?string $scope = null, + ): string { + return ''; + } + + public function getInt( + string $key, + ?string $scope = null, + ): int { + return 0; + } + + public function getBool( + string $key, + ?string $scope = null, + ): bool { + return false; + } + + public function getFloat( + string $key, + ?string $scope = null, + ): float { + return 0.0; + } + + public function getArray( + string $key, + ?string $scope = null, + ): array { + return []; + } + + public function all(?string $scope = null): array + { + return []; + } + + public function withScope(string $scope): ConfigRepositoryInterface + { + return $this; + } + }; +} + +function makePgSqlSpyFactory(): ConnectionFactoryInterface +{ + return new class () implements ConnectionFactoryInterface + { + /** @var array */ + public array $writeCalls = []; + + /** @var array */ + public array $replicaCalls = []; + + public ConnectionInterface&TransactionInterface $writeConnection; + + public ConnectionInterface&TransactionInterface $replicaConnection; + + public function __construct() + { + $this->writeConnection = makePgSqlTestConnection($this->writeCalls, $this->writeCalls); + $this->replicaConnection = makePgSqlTestConnection($this->replicaCalls, $this->replicaCalls); + } + + private int $callIndex = 0; + + public function make(DatabaseConfig $config): ConnectionInterface + { + $index = $this->callIndex++; + + return $index === 0 ? $this->writeConnection : $this->replicaConnection; + } + }; +} + +function makePgSqlContainer(ConfigRepositoryInterface $config, ConnectionFactoryInterface $factory): ContainerInterface +{ + return new class ($config, $factory) implements ContainerInterface + { + /** @var array */ + public array $registered = []; + + public function __construct( + private ConfigRepositoryInterface $config, + private ConnectionFactoryInterface $factory, + ) {} + + public function get(string $id): mixed + { + return match ($id) { + ConfigRepositoryInterface::class => $this->config, + ConnectionFactoryInterface::class => $this->factory, + default => throw new RuntimeException("Unexpected get($id)"), + }; + } + + public function has(string $id): bool + { + return true; + } + + public function singleton(string $id): void {} + + public function instance( + string $id, + object $instance, + ): void { + $this->registered[$id] = $instance; + } + + public function call(Closure $callable): mixed + { + return $callable($this); + } + }; +} + +function bootPgSqlModule(ContainerInterface $container): void +{ + $module = require dirname(__DIR__, 2) . '/module.php'; + ($module['boot'])($container); +} + +describe('pgsql wiring integration', function (): void { + it('wires ReadWriteConnection as ConnectionInterface via boot callback with pgsql config', function (): void { + $config = makePgSqlConfigRepository(); + $factory = makePgSqlSpyFactory(); + $container = makePgSqlContainer($config, $factory); + + bootPgSqlModule($container); + + expect($container->registered)->toHaveKey(ConnectionInterface::class) + ->and($container->registered[ConnectionInterface::class])->toBeInstanceOf(ReadWriteConnection::class); + }); + + it('routes read queries to the replica connection', function (): void { + $replicaQueries = []; + $replicaConn = makePgSqlTestConnection($replicaQueries); + + $factory = new class ($replicaConn) implements ConnectionFactoryInterface + { + private int $callIndex = 0; + + public function __construct( + private ConnectionInterface&TransactionInterface $replicaConnection, + ) {} + + public function make(DatabaseConfig $config): ConnectionInterface + { + $index = $this->callIndex++; + + return $index === 0 ? makePgSqlTestConnection() : $this->replicaConnection; + } + }; + + $config = makePgSqlConfigRepository(); + $container = makePgSqlContainer($config, $factory); + bootPgSqlModule($container); + + /** @var ReadWriteConnection $rwConn */ + $rwConn = $container->registered[ConnectionInterface::class]; + $rwConn->query('SELECT 1'); + + expect($replicaQueries)->toContain('SELECT 1'); + }); + + it('routes write queries to the write connection', function (): void { + $writeExecutes = []; + $writeConn = makePgSqlTestConnection(executeCalls: $writeExecutes); + + $factory = new class ($writeConn) implements ConnectionFactoryInterface + { + private int $callIndex = 0; + + public function __construct( + private ConnectionInterface&TransactionInterface $writeConnection, + ) {} + + public function make(DatabaseConfig $config): ConnectionInterface + { + $index = $this->callIndex++; + + return $index === 0 ? $this->writeConnection : makePgSqlTestConnection(); + } + }; + + $config = makePgSqlConfigRepository(); + $container = makePgSqlContainer($config, $factory); + bootPgSqlModule($container); + + /** @var ReadWriteConnection $rwConn */ + $rwConn = $container->registered[ConnectionInterface::class]; + $rwConn->execute('INSERT INTO users (name) VALUES (?)', ['Alice']); + + expect($writeExecutes)->toContain('INSERT INTO users (name) VALUES (?)'); + }); + + it('activates sticky write after execute', function (): void { + $writeQueries = []; + $replicaQueries = []; + $writeConn = makePgSqlTestConnection($writeQueries); + $replicaConn = makePgSqlTestConnection($replicaQueries); + + $factory = new class ($writeConn, $replicaConn) implements ConnectionFactoryInterface + { + private int $callIndex = 0; + + public function __construct( + private ConnectionInterface&TransactionInterface $writeConnection, + private ConnectionInterface&TransactionInterface $replicaConnection, + ) {} + + public function make(DatabaseConfig $config): ConnectionInterface + { + $index = $this->callIndex++; + + return $index === 0 ? $this->writeConnection : $this->replicaConnection; + } + }; + + $config = makePgSqlConfigRepository(); + $container = makePgSqlContainer($config, $factory); + bootPgSqlModule($container); + + /** @var ReadWriteConnection $rwConn */ + $rwConn = $container->registered[ConnectionInterface::class]; + + // Execute a write — this should activate sticky write + $rwConn->execute('INSERT INTO logs (msg) VALUES (?)', ['event']); + + // Subsequent query should go to write connection, not replica + $rwConn->query('SELECT * FROM logs'); + + expect($writeQueries)->toContain('SELECT * FROM logs') + ->and($replicaQueries)->not->toContain('SELECT * FROM logs'); + }); + + it('resolves TransactionInterface to the same ReadWriteConnection instance', function (): void { + $config = makePgSqlConfigRepository(); + $factory = makePgSqlSpyFactory(); + $container = makePgSqlContainer($config, $factory); + + bootPgSqlModule($container); + + expect($container->registered)->toHaveKey(TransactionInterface::class) + ->and($container->registered[TransactionInterface::class]) + ->toBe($container->registered[ConnectionInterface::class]); + }); +}); diff --git a/packages/database-readwrite/tests/Module/ModuleBootTest.php b/packages/database-readwrite/tests/Module/ModuleBootTest.php new file mode 100644 index 00000000..0dafe374 --- /dev/null +++ b/packages/database-readwrite/tests/Module/ModuleBootTest.php @@ -0,0 +1,403 @@ + 'mysql', 'host' => 'read-host-1', 'port' => 3306, 'database' => 'db', 'username' => 'root', 'password' => '']], + $extraReads, + ); + + return new class ($driver, $readStrategy, $reads) implements ConfigRepositoryInterface + { + public function __construct( + private string $driver, + private string $readStrategy, + private array $reads, + ) {} + + public function get( + string $key, + ?string $scope = null, + ): mixed + { + return match ($key) { + 'database.driver' => $this->driver, + 'database.connections' => [ + 'write' => ['driver' => 'mysql', 'host' => 'write-host', 'port' => 3306, 'database' => 'db', 'username' => 'root', 'password' => ''], + 'read' => $this->reads, + 'read_strategy' => $this->readStrategy, + ], + default => null, + }; + } + + public function has( + string $key, + ?string $scope = null, + ): bool + { + return true; + } + + public function getString( + string $key, + ?string $scope = null, + ): string + { + return ''; + } + + public function getInt( + string $key, + ?string $scope = null, + ): int + { + return 0; + } + + public function getBool( + string $key, + ?string $scope = null, + ): bool + { + return false; + } + + public function getFloat( + string $key, + ?string $scope = null, + ): float + { + return 0.0; + } + + public function getArray( + string $key, + ?string $scope = null, + ): array + { + return []; + } + + public function all(?string $scope = null): array + { + return []; + } + + public function withScope(string $scope): ConfigRepositoryInterface + { + return $this; + } + }; +} + +function makeTestContainer(ConfigRepositoryInterface $config, ConnectionFactoryInterface $factory): ContainerInterface +{ + return new class ($config, $factory) implements ContainerInterface + { + /** @var array */ + public array $registered = []; + + public function __construct( + private ConfigRepositoryInterface $config, + private ConnectionFactoryInterface $factory, + ) {} + + public function get(string $id): mixed + { + return match ($id) { + ConfigRepositoryInterface::class => $this->config, + ConnectionFactoryInterface::class => $this->factory, + default => throw new RuntimeException("Unexpected get($id)"), + }; + } + + public function has(string $id): bool + { + return true; + } + + public function singleton(string $id): void {} + + public function instance( + string $id, + object $instance, + ): void + { + $this->registered[$id] = $instance; + } + + public function call(Closure $callable): mixed + { + return $callable($this); + } + }; +} + +function makeSpyFactory(): ConnectionFactoryInterface +{ + return new class () implements ConnectionFactoryInterface + { + public int $callCount = 0; + + /** @var array */ + public array $receivedConfigs = []; + + /** @var array */ + public array $createdConnections = []; + + public function make(DatabaseConfig $config): ConnectionInterface + { + $this->callCount++; + $this->receivedConfigs[] = $config; + $conn = makeTestConnection(); + $this->createdConnections[] = $conn; + + return $conn; + } + }; +} + +describe('module boot callback', function (): void { + it('does not activate when driver is not readwrite', function (): void { + $config = makeConfigRepository(driver: 'mysql'); + $factory = makeSpyFactory(); + $container = makeTestContainer($config, $factory); + + $boot = getBootCallback(); + $boot($container); + + expect($container->registered)->toBeEmpty(); + }); + + it('registers ReadWriteConnection for ConnectionInterface when driver is readwrite', function (): void { + $config = makeConfigRepository(driver: 'readwrite'); + $factory = makeSpyFactory(); + $container = makeTestContainer($config, $factory); + + $boot = getBootCallback(); + $boot($container); + + expect($container->registered)->toHaveKey(ConnectionInterface::class) + ->and($container->registered[ConnectionInterface::class])->toBeInstanceOf(ReadWriteConnection::class); + }); + + it('registers ReadWriteConnection for TransactionInterface when driver is readwrite', function (): void { + $config = makeConfigRepository(driver: 'readwrite'); + $factory = makeSpyFactory(); + $container = makeTestContainer($config, $factory); + + $boot = getBootCallback(); + $boot($container); + + expect($container->registered)->toHaveKey(TransactionInterface::class) + ->and($container->registered[TransactionInterface::class])->toBeInstanceOf(ReadWriteConnection::class); + }); + + it('uses the factory to build write and replica connections', function (): void { + $config = makeConfigRepository(driver: 'readwrite'); + $factory = makeSpyFactory(); + $container = makeTestContainer($config, $factory); + + $boot = getBootCallback(); + $boot($container); + + expect($factory->callCount)->toBe(2); + }); + + it('uses RandomReplicaSelector when read_strategy is random', function (): void { + $config = makeConfigRepository(driver: 'readwrite', readStrategy: 'random'); + $factory = makeSpyFactory(); + $container = makeTestContainer($config, $factory); + + $boot = getBootCallback(); + $boot($container); + + $rwConn = $container->registered[ConnectionInterface::class]; + + // Verify the connection is a ReadWriteConnection with a RandomReplicaSelector + // by reflection inspection + $reflection = new ReflectionClass($rwConn); + $selectorProp = $reflection->getProperty('replicaSelector'); + $selector = $selectorProp->getValue($rwConn); + + expect($selector)->toBeInstanceOf(RandomReplicaSelector::class); + }); + + it('uses WeightedReplicaSelector when read_strategy is weighted', function (): void { + // Each read needs a weight when using the weighted strategy + $config = new class () implements ConfigRepositoryInterface + { + public function get( + string $key, + ?string $scope = null, + ): mixed + { + return match ($key) { + 'database.driver' => 'readwrite', + 'database.connections' => [ + 'write' => ['driver' => 'mysql', 'host' => 'write-host', 'port' => 3306, 'database' => 'db', 'username' => 'root', 'password' => ''], + 'read' => [ + ['driver' => 'mysql', 'host' => 'read-host-1', 'port' => 3306, 'database' => 'db', 'username' => 'root', 'password' => '', 'weight' => 1], + ['driver' => 'mysql', 'host' => 'read-host-2', 'port' => 3306, 'database' => 'db', 'username' => 'root', 'password' => '', 'weight' => 2], + ], + 'read_strategy' => 'weighted', + ], + default => null, + }; + } + + public function has( + string $key, + ?string $scope = null, + ): bool + { + return true; + } + + public function getString( + string $key, + ?string $scope = null, + ): string + { + return ''; + } + + public function getInt( + string $key, + ?string $scope = null, + ): int + { + return 0; + } + + public function getBool( + string $key, + ?string $scope = null, + ): bool + { + return false; + } + + public function getFloat( + string $key, + ?string $scope = null, + ): float + { + return 0.0; + } + + public function getArray( + string $key, + ?string $scope = null, + ): array + { + return []; + } + + public function all(?string $scope = null): array + { + return []; + } + + public function withScope(string $scope): ConfigRepositoryInterface + { + return $this; + } + }; + + $factory = makeSpyFactory(); + $container = makeTestContainer($config, $factory); + + $boot = getBootCallback(); + $boot($container); + + $rwConn = $container->registered[ConnectionInterface::class]; + + $reflection = new ReflectionClass($rwConn); + $selectorProp = $reflection->getProperty('replicaSelector'); + $selector = $selectorProp->getValue($rwConn); + + expect($selector)->toBeInstanceOf(WeightedReplicaSelector::class); + }); +}); diff --git a/packages/database-readwrite/tests/PackageTest.php b/packages/database-readwrite/tests/PackageTest.php new file mode 100644 index 00000000..fc423f53 --- /dev/null +++ b/packages/database-readwrite/tests/PackageTest.php @@ -0,0 +1,114 @@ +toBeTrue(); + + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer)->toBeArray() + ->and($composer['name'])->toBe('marko/database-readwrite') + ->and($composer['type'])->toBe('library') + ->and($composer['license'])->toBe('MIT') + ->and($composer['autoload']['psr-4'])->toHaveKey('Marko\\Database\\ReadWrite\\') + ->and($composer['autoload']['psr-4']['Marko\\Database\\ReadWrite\\'])->toBe('src/') + ->and($composer['autoload-dev']['psr-4'])->toHaveKey('Marko\\Database\\ReadWrite\\Tests\\') + ->and($composer['autoload-dev']['psr-4']['Marko\\Database\\ReadWrite\\Tests\\'])->toBe('tests/'); +}); + +it('declares marko/core and marko/database as required dependencies', function (): void { + $composerPath = dirname(__DIR__) . '/composer.json'; + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer['require'])->toHaveKey('marko/core') + ->and($composer['require']['marko/core'])->toBe('self.version') + ->and($composer['require'])->toHaveKey('marko/database') + ->and($composer['require']['marko/database'])->toBe('self.version') + ->and($composer['require'])->toHaveKey('php') + ->and($composer['require']['php'])->toBe('^8.5') + ->and($composer['require'])->toHaveKey('ext-pdo') + ->and($composer['require']['ext-pdo'])->toBe('*'); +}); + +it('does not hardcode a version key in composer.json', function (): void { + $composerPath = dirname(__DIR__) . '/composer.json'; + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer)->not->toHaveKey('version'); +}); + +it('does not require any specific database driver package', function (): void { + $composerPath = dirname(__DIR__) . '/composer.json'; + $composer = json_decode(file_get_contents($composerPath), true); + + $require = $composer['require'] ?? []; + + expect(array_key_exists('marko/database-pgsql', $require))->toBeFalse() + ->and(array_key_exists('marko/database-mysql', $require))->toBeFalse(); +}); + +it('has a module.php that loads without error', function (): void { + $modulePath = dirname(__DIR__) . '/module.php'; + + expect(file_exists($modulePath))->toBeTrue(); + + $module = require $modulePath; + + expect($module)->toBeArray(); +}); + +it('appears in the root composer.json path repositories list', function (): void { + $rootComposerPath = dirname(__DIR__, 3) . '/composer.json'; + $rootComposer = json_decode(file_get_contents($rootComposerPath), true); + + $repoUrls = array_column($rootComposer['repositories'], 'url'); + + expect(in_array('packages/database-readwrite', $repoUrls, true))->toBeTrue(); +}); + +it('appears in the root composer.json require section with self.version', function (): void { + $rootComposerPath = dirname(__DIR__, 3) . '/composer.json'; + $rootComposer = json_decode(file_get_contents($rootComposerPath), true); + + expect($rootComposer['require'])->toHaveKey('marko/database-readwrite') + ->and($rootComposer['require']['marko/database-readwrite'])->toBe('self.version'); +}); + +it('appears in RootComposerJsonTest expected-package list', function (): void { + $testFilePath = dirname(__DIR__, 3) . '/packages/framework/tests/RootComposerJsonTest.php'; + + expect(file_exists($testFilePath))->toBeTrue(); + + $contents = file_get_contents($testFilePath); + + expect(str_contains($contents, "'marko/database-readwrite'"))->toBeTrue(); +}); + +it( + 'is auto-discovered by PackagingTest via scandir (no list update needed — just a smoke check that the new directory satisfies the .gitattributes assertion)', + function (): void { + $gitattributesPath = dirname(__DIR__) . '/.gitattributes'; + + expect(file_exists($gitattributesPath))->toBeTrue(); + + $contents = file_get_contents($gitattributesPath); + + expect(str_contains($contents, 'export-ignore'))->toBeTrue(); + }, +); + +it( + 'appears in both .github/ISSUE_TEMPLATE/bug_report.yml and feature_request.yml package dropdowns', + function (): void { + $rootPath = dirname(__DIR__, 3); + + $bugReport = file_get_contents($rootPath . '/.github/ISSUE_TEMPLATE/bug_report.yml'); + $featureRequest = file_get_contents($rootPath . '/.github/ISSUE_TEMPLATE/feature_request.yml'); + + expect(str_contains($bugReport, '- database-readwrite'))->toBeTrue() + ->and(str_contains($featureRequest, '- database-readwrite'))->toBeTrue(); + }, +); diff --git a/packages/database-readwrite/tests/Pest.php b/packages/database-readwrite/tests/Pest.php new file mode 100644 index 00000000..c7e826c4 --- /dev/null +++ b/packages/database-readwrite/tests/Pest.php @@ -0,0 +1,21 @@ +isInterface())->toBeTrue() + ->and($reflection->hasMethod('select'))->toBeTrue(); + + $select = $reflection->getMethod('select'); + + expect($select->getReturnType()?->getName())->toBe(ConnectionInterface::class); + + $params = $select->getParameters(); + + expect($params)->toHaveCount(1) + ->and($params[0]->getName())->toBe('replicas') + ->and($params[0]->getType()?->getName())->toBe('array'); + }); +}); + +describe('RandomReplicaSelector', function (): void { + it('selects a replica from the list using RandomReplicaSelector', function (): void { + $replica1 = createFakeConnection(); + $replica2 = createFakeConnection(); + $replica3 = createFakeConnection(); + + $selector = new RandomReplicaSelector(); + $selected = $selector->select([$replica1, $replica2, $replica3]); + + expect(in_array($selected, [$replica1, $replica2, $replica3], true))->toBeTrue(); + }); + + it('always returns a replica from the provided list in RandomReplicaSelector', function (): void { + $replicas = array_map(fn () => createFakeConnection(), range(1, 5)); + $selector = new RandomReplicaSelector(); + + foreach (range(1, 100) as $_) { + $selected = $selector->select($replicas); + expect(in_array($selected, $replicas, true))->toBeTrue(); + } + }); + + it('returns the only replica when one replica is provided in RandomReplicaSelector', function (): void { + $replica = createFakeConnection(); + $selector = new RandomReplicaSelector(); + + $selected = $selector->select([$replica]); + + expect($selected)->toBe($replica); + }); +}); + +describe('WeightedReplicaSelector', function (): void { + it('selects replicas proportionally by weight in WeightedReplicaSelector', function (): void { + $replica1 = createFakeConnection(); + $replica2 = createFakeConnection(); + + // replica1 has weight 3, replica2 has weight 1 — expect ~75% vs ~25% + $selector = new WeightedReplicaSelector([3, 1]); + + $counts = [0, 0]; + $iterations = 1000; + + foreach (range(1, $iterations) as $_) { + $selected = $selector->select([$replica1, $replica2]); + if ($selected === $replica1) { + $counts[0]++; + } else { + $counts[1]++; + } + } + + // With 3:1 weight ratio, replica1 should be selected ~75% of the time + // Allow generous tolerance: expect at least 60% and at most 90% for replica1 + $ratio1 = $counts[0] / $iterations; + + expect($ratio1)->toBeGreaterThan(0.60) + ->and($ratio1)->toBeLessThan(0.90) + ->and($counts[0])->toBeGreaterThan($counts[1]); + }); + + it('returns the only replica when one replica is provided in WeightedReplicaSelector', function (): void { + $replica = createFakeConnection(); + $selector = new WeightedReplicaSelector([1]); + + $selected = $selector->select([$replica]); + + expect($selected)->toBe($replica); + }); +}); + +function createFakeConnection(): ConnectionInterface +{ + return new class () implements ConnectionInterface + { + public function connect(): void {} + + public function disconnect(): void {} + + public function isConnected(): bool + { + return true; + } + + public function query( + string $sql, + array $bindings = [], + ): array { + return []; + } + + public function execute( + string $sql, + array $bindings = [], + ): int { + return 0; + } + + public function prepare(string $sql): StatementInterface + { + throw new RuntimeException('Not implemented'); + } + + public function lastInsertId(): int + { + return 0; + } + }; +} diff --git a/packages/database/src/Config/DatabaseConfig.php b/packages/database/src/Config/DatabaseConfig.php index eccda9bb..65b16aaf 100644 --- a/packages/database/src/Config/DatabaseConfig.php +++ b/packages/database/src/Config/DatabaseConfig.php @@ -6,6 +6,7 @@ use Marko\Core\Path\ProjectPaths; use Marko\Database\Exceptions\ConfigurationException; +use ReflectionClass; /** * Database configuration loaded from config/database.php. @@ -48,13 +49,7 @@ public function __construct( $config = require $configPath; - $requiredKeys = ['driver', 'host', 'port', 'database', 'username', 'password']; - - foreach ($requiredKeys as $key) { - if (!array_key_exists($key, $config)) { - throw ConfigurationException::missingRequiredKey($key); - } - } + self::validateConfigArray($config); $this->driver = $config['driver']; $this->host = $config['host']; @@ -64,15 +59,73 @@ public function __construct( $this->password = $config['password']; $this->sslMode = $config['sslmode'] ?? null; $this->sslRootCert = $config['ssl_ca'] ?? null; - $this->sslVerifyServerCert = $config['ssl_verify_server_cert'] ?? ($this->sslRootCert !== null); + $this->sslVerifyServerCert = $config['ssl_verify_server_cert'] ?? ($config['ssl_ca'] ?? null) !== null; $this->sslCert = $config['ssl_cert'] ?? null; $this->sslKey = $config['ssl_key'] ?? null; + } + + /** + * Create a DatabaseConfig from a raw configuration array. + * + * @param array $config + * + * @throws ConfigurationException + */ + public static function fromArray(array $config): self + { + self::validateConfigArray($config); + + $instance = (new ReflectionClass(self::class))->newInstanceWithoutConstructor(); + + $props = [ + 'driver' => $config['driver'], + 'host' => $config['host'], + 'port' => $config['port'], + 'database' => $config['database'], + 'username' => $config['username'], + 'password' => $config['password'], + 'sslMode' => $config['sslmode'] ?? null, + 'sslRootCert' => $config['ssl_ca'] ?? null, + 'sslVerifyServerCert' => $config['ssl_verify_server_cert'] ?? ($config['ssl_ca'] ?? null) !== null, + 'sslCert' => $config['ssl_cert'] ?? null, + 'sslKey' => $config['ssl_key'] ?? null, + ]; + + $reflection = new ReflectionClass($instance); + + foreach ($props as $name => $value) { + $prop = $reflection->getProperty($name); + $prop->setValue($instance, $value); + } + + return $instance; + } + + /** + * Validate a database configuration array, throwing ConfigurationException on failure. + * + * @param array $config + * + * @throws ConfigurationException + */ + private static function validateConfigArray(array $config): void + { + $requiredKeys = ['driver', 'host', 'port', 'database', 'username', 'password']; + + foreach ($requiredKeys as $key) { + if (!array_key_exists($key, $config)) { + throw ConfigurationException::missingRequiredKey($key); + } + } + + $sslCert = $config['ssl_cert'] ?? null; + $sslKey = $config['ssl_key'] ?? null; - if ($this->sslCert !== null && $this->sslKey === null) { + if ($sslCert !== null && $sslKey === null) { throw ConfigurationException::incompleteSslKeyPair('ssl_cert', 'ssl_key'); } - if ($this->sslKey !== null && $this->sslCert === null) { + if ($sslKey !== null && $sslCert === null) { throw ConfigurationException::incompleteSslKeyPair('ssl_key', 'ssl_cert'); } } diff --git a/packages/database/src/Connection/ConnectionFactoryInterface.php b/packages/database/src/Connection/ConnectionFactoryInterface.php new file mode 100644 index 00000000..78f1da91 --- /dev/null +++ b/packages/database/src/Connection/ConnectionFactoryInterface.php @@ -0,0 +1,12 @@ +isInterface())->toBeTrue() + ->and($reflection->hasMethod('make'))->toBeTrue(); + + $make = $reflection->getMethod('make'); + $params = $make->getParameters(); + + expect($make->getReturnType()?->getName())->toBe(ConnectionInterface::class) + ->and($params)->toHaveCount(1) + ->and($params[0]->getName())->toBe('config') + ->and($params[0]->getType()?->getName())->toBe(DatabaseConfig::class); +}); diff --git a/packages/database/tests/Unit/DatabaseConfigFromArrayTest.php b/packages/database/tests/Unit/DatabaseConfigFromArrayTest.php new file mode 100644 index 00000000..832d53b1 --- /dev/null +++ b/packages/database/tests/Unit/DatabaseConfigFromArrayTest.php @@ -0,0 +1,145 @@ + 'pgsql', + 'host' => 'localhost', + 'port' => 5432, + 'database' => 'mydb', + 'username' => 'admin', + 'password' => 'secret', + ]); + + expect($config)->toBeInstanceOf(DatabaseConfig::class) + ->and($config->driver)->toBe('pgsql') + ->and($config->host)->toBe('localhost') + ->and($config->port)->toBe(5432) + ->and($config->database)->toBe('mydb') + ->and($config->username)->toBe('admin') + ->and($config->password)->toBe('secret'); + }); + + it('throws ConfigurationException when a required key is missing from the array', function (): void { + expect(fn () => DatabaseConfig::fromArray([ + 'host' => 'localhost', + 'port' => 5432, + 'database' => 'mydb', + 'username' => 'admin', + 'password' => 'secret', + ]))->toThrow(ConfigurationException::class, 'driver'); + }); + + it('throws ConfigurationException when ssl_cert is provided without ssl_key', function (): void { + expect(fn () => DatabaseConfig::fromArray([ + 'driver' => 'pgsql', + 'host' => 'localhost', + 'port' => 5432, + 'database' => 'mydb', + 'username' => 'admin', + 'password' => 'secret', + 'ssl_cert' => '/path/to/cert.pem', + ]))->toThrow(ConfigurationException::class, 'ssl_key'); + }); + + it('throws ConfigurationException when ssl_key is provided without ssl_cert', function (): void { + expect(fn () => DatabaseConfig::fromArray([ + 'driver' => 'pgsql', + 'host' => 'localhost', + 'port' => 5432, + 'database' => 'mydb', + 'username' => 'admin', + 'password' => 'secret', + 'ssl_key' => '/path/to/key.pem', + ]))->toThrow(ConfigurationException::class, 'ssl_cert'); + }); + + it('populates SSL fields when provided', function (): void { + $config = DatabaseConfig::fromArray([ + 'driver' => 'pgsql', + 'host' => 'localhost', + 'port' => 5432, + 'database' => 'mydb', + 'username' => 'admin', + 'password' => 'secret', + 'sslmode' => 'require', + 'ssl_ca' => '/path/to/ca.pem', + 'ssl_verify_server_cert' => true, + 'ssl_cert' => '/path/to/cert.pem', + 'ssl_key' => '/path/to/key.pem', + ]); + + expect($config->sslMode)->toBe('require') + ->and($config->sslRootCert)->toBe('/path/to/ca.pem') + ->and($config->sslVerifyServerCert)->toBeTrue() + ->and($config->sslCert)->toBe('/path/to/cert.pem') + ->and($config->sslKey)->toBe('/path/to/key.pem'); + }); + + it( + 'produces a DatabaseConfig with identical property values to the file-loaded path given equivalent input', + function (): void { + $tempDir = sys_get_temp_dir() . '/marko_test_' . bin2hex(random_bytes(8)); + $configDir = $tempDir . '/config'; + mkdir($configDir, 0755, true); + + $configContent = <<<'PHP' + 'mysql', + 'host' => 'db.example.com', + 'port' => 3306, + 'database' => 'mydb', + 'username' => 'root', + 'password' => 'pass', + 'sslmode' => 'verify-full', + 'ssl_ca' => '/path/to/ca.pem', + 'ssl_cert' => '/path/to/cert.pem', + 'ssl_key' => '/path/to/key.pem', +]; +PHP; + file_put_contents($configDir . '/database.php', $configContent); + + try { + $paths = new ProjectPaths($tempDir); + $fileLoaded = new DatabaseConfig($paths); + + $fromArray = DatabaseConfig::fromArray([ + 'driver' => 'mysql', + 'host' => 'db.example.com', + 'port' => 3306, + 'database' => 'mydb', + 'username' => 'root', + 'password' => 'pass', + 'sslmode' => 'verify-full', + 'ssl_ca' => '/path/to/ca.pem', + 'ssl_cert' => '/path/to/cert.pem', + 'ssl_key' => '/path/to/key.pem', + ]); + + expect($fromArray->driver)->toBe($fileLoaded->driver) + ->and($fromArray->host)->toBe($fileLoaded->host) + ->and($fromArray->port)->toBe($fileLoaded->port) + ->and($fromArray->database)->toBe($fileLoaded->database) + ->and($fromArray->username)->toBe($fileLoaded->username) + ->and($fromArray->password)->toBe($fileLoaded->password) + ->and($fromArray->sslMode)->toBe($fileLoaded->sslMode) + ->and($fromArray->sslRootCert)->toBe($fileLoaded->sslRootCert) + ->and($fromArray->sslVerifyServerCert)->toBe($fileLoaded->sslVerifyServerCert) + ->and($fromArray->sslCert)->toBe($fileLoaded->sslCert) + ->and($fromArray->sslKey)->toBe($fileLoaded->sslKey); + } finally { + unlink($configDir . '/database.php'); + rmdir($configDir); + rmdir($tempDir); + } + }, + ); +}); diff --git a/packages/framework/tests/RootComposerJsonTest.php b/packages/framework/tests/RootComposerJsonTest.php index 52a2d543..14efb66a 100644 --- a/packages/framework/tests/RootComposerJsonTest.php +++ b/packages/framework/tests/RootComposerJsonTest.php @@ -26,6 +26,7 @@ 'marko/database', 'marko/database-mysql', 'marko/database-pgsql', + 'marko/database-readwrite', 'marko/dev-server', 'marko/encryption', 'marko/encryption-openssl', @@ -112,7 +113,7 @@ return; } - foreach ($rootComposer['autoload']['psr-4'] as $namespace => $path) { + foreach (array_keys($rootComposer['autoload']['psr-4']) as $namespace) { expect(str_starts_with($namespace, 'Marko\\'))->toBeFalse(); } });