Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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/`.
66 changes: 66 additions & 0 deletions .claude/plans/database-readwrite/002-package-scaffolding.md
Original file line number Diff line number Diff line change
@@ -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)
52 changes: 52 additions & 0 deletions .claude/plans/database-readwrite/003-pgsql-connection-factory.md
Original file line number Diff line number Diff line change
@@ -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)
51 changes: 51 additions & 0 deletions .claude/plans/database-readwrite/004-mysql-connection-factory.md
Original file line number Diff line number Diff line change
@@ -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)
Loading