diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..233598b --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,31 @@ +# Project + +PHP library (tiny-blocks ecosystem). Self-contained package: immutable models, zero infrastructure +dependencies in core, small public surface area. Public API at `src/` root; implementation details +under `src/Internal/`. + +## Rules + +All coding standards, architecture, naming, testing, and documentation conventions +are defined in `rules/`. Read the applicable rule files before generating any code or documentation. + +## Commands + +- `make test` — runs PHPUnit (with coverage) and Infection (mutation testing) sequentially via `composer tests`. +- `make review` — run lint. +- `make help` — list all available commands. + +## Post-change validation + +After any code change, run `make review`, `make test`, and `make mutation-test`. +If any fails, iterate on the fix while respecting all project rules until all pass. +Never deliver code that breaks lint, tests, or leaves surviving mutants. + +## File formatting + +Every file produced or modified must: + +- Use **LF** line endings. Never CRLF. +- Have no trailing whitespace on any line. +- End with a single trailing newline. +- Have no consecutive blank lines (max one blank line between blocks). diff --git a/.claude/rules/github-workflows.md b/.claude/rules/github-workflows.md new file mode 100644 index 0000000..a369ba4 --- /dev/null +++ b/.claude/rules/github-workflows.md @@ -0,0 +1,78 @@ +--- +description: Naming, ordering, inputs, security, and structural rules for all GitHub Actions workflow files. +paths: + - ".github/workflows/**/*.yml" + - ".github/workflows/**/*.yaml" +--- + +# Workflows + +Structural and stylistic rules for GitHub Actions workflow files. Refer to `shell-scripts.md` for Bash conventions used +inside `run:` steps, and to `terraforms.md` for Terraform conventions used in `terraform/`. + +## Pre-output checklist + +Verify every item before producing any workflow YAML. If any item fails, revise before outputting. + +1. File name follows the convention: `ci-.yml` for reusable CI, `cd-.yml` for dispatch CD. +2. `name` field follows the pattern `CI — ` or `CD — `, using sentence case after the dash + (e.g., `CD — Run migration`, not `CD — Run Migration`). +3. Reusable workflows use `workflow_call` trigger. CD workflows use `workflow_dispatch` trigger. +4. Each workflow has a single responsibility. CI tests code. CD deploys it. Never combine both. +5. Every input has a `description` field. Descriptions use American English and end with a period. +6. Input names use `kebab-case`: `service-name`, `dry-run`, `skip-build`. +7. Inputs are ordered: required first, then optional. Each group by **name length ascending**. +8. Choice input options are in **alphabetical order**. +9. `env`, `outputs`, and `with` entries are ordered by **key length ascending**. +10. `permissions` keys are ordered by **key length ascending** (`contents` before `id-token`). +11. Top-level workflow keys follow canonical order: `name`, `on`, `concurrency`, `permissions`, `env`, `jobs`. +12. Job-level properties follow canonical order: `if`, `name`, `needs`, `uses`, `with`, `runs-on`, + `environment`, `timeout-minutes`, `strategy`, `outputs`, `permissions`, `env`, `steps`. +13. All other YAML property names within a block are ordered by **name length ascending**. +14. Jobs follow execution order: `load-config` → `lint` → `test` → `build` → `deploy`. +15. Step names start with a verb and use sentence case: `Setup PHP`, `Run lint`, `Resolve image tag`. +16. Runtime versions are resolved from the service repo's native dependency file (`composer.json`, `go.mod`, + `package.json`). No version is hardcoded in any workflow. +17. Service-specific overrides live in a pipeline config file (e.g., `.pipeline.yml`) in the service repo, + not in the workflows repository. +18. The `load-config` job reads the pipeline config file at runtime with safe fallback to defaults when absent. +19. Top-level `permissions` defaults to read-only (`contents: read`). Jobs escalate only the permissions they + need. +20. AWS authentication uses OIDC federation exclusively. Static access keys are forbidden. +21. Secrets are passed via `secrets: inherit` from callers. No secret is hardcoded. +22. Sensitive values fetched from SSM are masked with `::add-mask::` before assignment. +23. Third-party actions are pinned to the latest available full commit SHA with a version comment: + `uses: aws-actions/configure-aws-credentials@ # v4.0.2`. Always verify the latest + version before generating a workflow. +24. First-party actions (`actions/*`) are pinned to the latest major version tag available: + `actions/checkout@v4`. Always check for the most recent major version before generating a workflow. +25. Production deployments require GitHub Environments protection rules (manual approval). +26. Every job sets `timeout-minutes` to prevent indefinite hangs. CI jobs: 10–15 minutes. CD jobs: 20–30 + minutes. Adjust only with justification in a comment. +27. CI workflows set `concurrency` with `group` scoped to the PR and `cancel-in-progress: true` to avoid + redundant runs. +28. CD workflows set `concurrency` with `group` scoped to the environment and `cancel-in-progress: false` to + prevent interrupted deployments. +29. CD workflows use `if: ${{ !cancelled() }}` to allow to deploy after optional build steps. +30. Inline logic longer than 3 lines is extracted to a script in `scripts/ci/` or `scripts/cd/`. + +## Style + +- All text (workflow names, step names, input descriptions, comments) uses American English with correct + spelling and punctuation. Sentences and descriptions end with a period. + +## Callers + +- Callers trigger on `pull_request` targeting `main` only. No `push` trigger. +- Callers in service repos are static (~10 lines) and pass only `service-name` or `app-name`. +- Callers reference workflows with `@main` during development. Pin to a tag or SHA for production. + +## Image tagging + +- CD deploy builds: `-sha-` + `latest`. + +## Migrations + +- Migrations run **before** service deployment (schema first, code second). +- `cd-migrate.yml` supports `dry-run` mode (`flyway validate`) for pre-flight checks. +- Database credentials are fetched from SSM at runtime, never stored in workflow files. diff --git a/.claude/rules/php-library-code-style.md b/.claude/rules/php-library-code-style.md new file mode 100644 index 0000000..7ec196e --- /dev/null +++ b/.claude/rules/php-library-code-style.md @@ -0,0 +1,154 @@ +--- +description: Pre-output checklist, naming, typing, complexity, and PHPDoc rules for all PHP files in libraries. +paths: + - "src/**/*.php" + - "tests/**/*.php" +--- + +# Code style + +Semantic code rules for all PHP files. Formatting rules (PSR-1, PSR-4, PSR-12, line length) are enforced by `phpcs.xml` +and are not repeated here. Refer to `php-library-modeling.md` for library modeling rules. + +## Pre-output checklist + +Verify every item before producing any PHP code. If any item fails, revise before outputting. + +1. `declare(strict_types=1)` is present. +2. All classes are `final readonly` by default. Use `class` (without `final` or `readonly`) only when the class is + designed as an extension point for consumers (e.g., `Collection`, `ValueObject`). Use `final class` without + `readonly` only when the parent class is not readonly (e.g., extending a third-party abstract class). +3. All parameters, return types, and properties have explicit types. +4. Constructor property promotion is used. +5. Named arguments are used at call sites for own code, tests, and third-party library methods (e.g., tiny-blocks). + Never use named arguments on native PHP functions (`array_map`, `in_array`, `preg_match`, `is_null`, + `iterator_to_array`, `sprintf`, `implode`, etc.) or PHPUnit assertions (`assertEquals`, `assertSame`, + `assertTrue`, `expectException`, etc.). +6. No `else` or `else if` exists anywhere. Use early returns, polymorphism, or map dispatch instead. +7. No abbreviations appear in identifiers. Use `$index` instead of `$i`, `$account` instead of `$acc`. +8. No generic identifiers exist. Use domain-specific names instead: + `$data` → `$payload`, `$value` → `$totalAmount`, `$item` → `$element`, + `$info` → `$currencyDetails`, `$result` → `$conversionOutcome`. +9. No raw arrays exist where a typed collection or value object is available. Use the `tiny-blocks/collection` + fluent API (`Collection`, `Collectible`) when data is `Collectible`. Use `createLazyFrom` when elements are + consumed once. Raw arrays are acceptable only for primitive configuration data, variadic pass-through, and + interop at system boundaries. See "Collection usage" below for the full rule and example. +10. No private methods exist except private constructors for factory patterns. Inline trivial logic at the call site + or extract it to a collaborator or value object. +11. Members are ordered: constants first, then constructor, then static methods, then instance methods. Within each + group, order by body size ascending (number of lines between `{` and `}`). Constants and enum cases, which have + no body, are ordered by name length ascending. +12. Constructor parameters are ordered by parameter name length ascending (count the name only, without `$` or type), + except when parameters have an implicit semantic order (e.g., `$start/$end`, `$from/$to`, `$startAt/$endAt`), + which takes precedence. Parameters with default values go last, regardless of name length. The same rule + applies to named arguments at call sites. + Example: `$id` (2) → `$value` (5) → `$status` (6) → `$precision` (9). +13. Time and space complexity are first-class design concerns. + - No `O(N²)` or worse time complexity exists unless the problem inherently requires it and the cost is + documented in PHPDoc on the interface method. + - Space complexity is kept minimal: prefer lazy/streaming pipelines (`createLazyFrom`) over materializing + intermediate collections. + - Never re-iterate the same source; fuse stages when possible. + - Public interface methods document time and space complexity in Big O form (see "PHPDoc" section). +14. No logic is duplicated across two or more places (DRY). +15. No abstraction exists without real duplication or isolation need (KISS). +16. All identifiers, comments, and documentation are written in American English. +17. No justification comments exist (`// NOTE:`, `// REASON:`, etc.). Code speaks for itself. +18. `// TODO: ` is used when implementation is unknown, uncertain, or intentionally deferred. + Never leave silent gaps. +19. All class references use `use` imports at the top of the file. Fully qualified names inline are prohibited. +20. No dead or unused code exists. Remove unreferenced classes, methods, constants, and imports. +21. Never create public methods, constants, or classes in `src/` solely to serve tests. If production code does not + need it, it does not exist. +22. Always use the most current and clean syntax available in the target PHP version. Prefer match to switch, + first-class callables over `Closure::fromCallable()`, readonly promotion over manual assignment, enum methods + over external switch/if chains, named arguments over positional ambiguity (except where excluded by rule 5), + and `Collection::map` over foreach accumulation. +23. No vertical alignment of types in parameter lists or property declarations. Use a single space between + type and variable name. Never pad with extra spaces to align columns: + `public OrderId $id` — not `public OrderId $id`. +24. Opening brace `{` follows PSR-12: on a **new line** for classes, interfaces, traits, enums, and methods + (including constructors); on the **same line** for closures and control structures (`if`, `for`, `foreach`, + `while`, `switch`, `match`, `try`). +25. Never pass an argument whose value equals the parameter's default. Omit the argument entirely. + Example — `toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE)`: + `$collection->toArray(keyPreservation: KeyPreservation::PRESERVE)` → `$collection->toArray()`. + Only pass the argument when the value differs from the default. +26. No trailing comma in any multi-line list. This applies to parameter lists (constructors, methods, + closures), argument lists at call sites, array literals, match arms, and any other comma-separated + multi-line structure. The last element never has a comma after it. PHP accepts trailing commas in + parameter lists, but this project prohibits them for visual consistency. + Example — correct: + ``` + new Precision( + value: 2, + rounding: RoundingMode::HALF_UP + ); + ``` + Example — prohibited: + ``` + new Precision( + value: 2, + rounding: RoundingMode::HALF_UP, + ); + ``` + +## Casing conventions + +- Internal code (variables, methods, classes): **`camelCase`**. +- Constants and enum-backed values when representing codes: **`SCREAMING_SNAKE_CASE`**. + +## Naming + +- Names describe **what** in domain terms, not **how** technically: `$monthlyRevenue` instead of `$calculatedValue`. +- Generic technical verbs are avoided. See `php-library-modeling.md` — Nomenclature. +- Booleans use predicate form: `isActive`, `hasPermission`, `wasProcessed`. +- Collections are always plural: `$orders`, `$lines`. +- Methods returning bool use prefixes: `is`, `has`, `can`, `was`, `should`. + +## Comparisons + +1. Null checks: use `is_null($variable)`, never `$variable === null`. +2. Empty string checks on typed `string` parameters: use `$variable === ''`. Avoid `empty()` on typed strings + because `empty('0')` returns `true`. +3. Mixed or untyped checks (value may be `null`, empty string, `0`, or `false`): use `empty($variable)`. + +## American English + +All identifiers, enum values, comments, and error codes use American English spelling: +`canceled` (not `cancelled`), `organization` (not `organisation`), `initialize` (not `initialise`), +`behavior` (not `behaviour`), `modeling` (not `modelling`), `labeled` (not `labelled`), +`fulfill` (not `fulfil`), `color` (not `colour`). + +## PHPDoc + +- PHPDoc is restricted to interfaces only, documenting obligations, `@throws`, and complexity. +- Never add PHPDoc to concrete classes. +- Document `@throws` for every exception the method may raise. +- Document time and space complexity in Big O form. When a method participates in a fused pipeline (e.g., collection + pipelines), express cost as a two-part form: call-site cost + fused-pass contribution. Include a legend defining + variables (e.g., `N` for input size, `K` for number of stages). + +## Collection usage + +When a property or parameter is `Collectible`, use its fluent API. Never break out to raw array functions such as +`array_map`, `array_filter`, `iterator_to_array`, or `foreach` + accumulation. The same applies to `filter()`, +`reduce()`, `each()`, and all other `Collectible` operations. Chain them fluently. Never materialize with +`iterator_to_array` to then pass into a raw `array_*` function. + +**Prohibited — `array_map` + `iterator_to_array` on a Collectible:** + +```php +$names = array_map( + static fn(Element $element): string => $element->name(), + iterator_to_array($collection) +); +``` + +**Correct — fluent chain with `map()` + `toArray()`:** + +```php +$names = $collection + ->map(transformations: static fn(Element $element): string => $element->name()) + ->toArray(keyPreservation: KeyPreservation::DISCARD); +``` diff --git a/.claude/rules/php-library-documentation.md b/.claude/rules/php-library-documentation.md new file mode 100644 index 0000000..4791cb9 --- /dev/null +++ b/.claude/rules/php-library-documentation.md @@ -0,0 +1,40 @@ +--- +description: Standards for README files and all project documentation in PHP libraries. +paths: + - "**/*.md" +--- + +# Documentation + +## README + +1. Include an anchor-linked table of contents. +2. Start with a concise one-line description of what the library does. +3. Include a **license** badge. Do not include any other badges. +4. Provide an **Overview** section explaining the problem the library solves and its design philosophy. +5. **Installation** section: Composer command (`composer require vendor/package`). +6. **How to use** section: complete, runnable code examples covering the primary use cases. Each example + includes a brief heading describing what it demonstrates. +7. If the library exposes multiple entry points, strategies, or container types, document each with its own + subsection and example. +8. **FAQ** section: include entries for common pitfalls, non-obvious behaviors, or design decisions that users + frequently ask about. Each entry is a numbered question as heading (e.g., `### 01. Why does X happen?`) + followed by a concise explanation. Only include entries that address real confusion points. +9. **License** and **Contributing** sections at the end. +10. Write strictly in American English. See `php-library-code-style.md` American English section for spelling + conventions. + +## Structured data + +1. When documenting constructors, factory methods, or configuration options with more than 3 parameters, + use tables with columns: Parameter, Type, Required, Description. +2. Prefer tables to prose for any structured information. + +## Style + +1. Keep language concise and scannable. +2. Never include placeholder content (`TODO`, `TBD`). +3. Code examples must be syntactically correct and self-contained. +4. Code examples include every `use` statement needed to compile. Each example stands alone — copyable into + a fresh file without modification. +5. Do not document `Internal/` classes or private API. Only document what consumers interact with. diff --git a/.claude/rules/php-library-modeling.md b/.claude/rules/php-library-modeling.md new file mode 100644 index 0000000..bedb733 --- /dev/null +++ b/.claude/rules/php-library-modeling.md @@ -0,0 +1,163 @@ +--- +description: Library modeling rules — folder structure, public API boundary, naming, value objects, exceptions, enums, extension points, and complexity. +paths: + - "src/**/*.php" +--- + +# Library modeling + +Libraries are self-contained packages. The core has no dependency on frameworks, databases, or I/O. Refer to +`php-library-code-style.md` for the pre-output checklist applied to all PHP code. + +## Folder structure + +``` +src/ +├── .php # Primary contract for consumers +├── .php # Main implementation or extension point +├── .php # Public enum +├── Contracts/ # Interfaces for data returned to consumers +├── Internal/ # Implementation details (not part of public API) +│ ├── .php +│ └── Exceptions/ # Internal exception classes +├── / # Feature-specific subdirectory when needed +└── Exceptions/ # Public exception classes (when part of the API) +``` + +Never use `Models/`, `Entities/`, `ValueObjects/`, `Enums/`, or `Domain/` as folder names. + +## Public API boundary + +Only interfaces, extension points, enums, and thin orchestration classes live at the `src/` root. These classes +define the contract consumers interact with and delegate all real work to collaborators inside `src/Internal/`. +If a class contains substantial logic (algorithms, state machines, I/O), it belongs in `Internal/`, not at the root. + +The `Internal/` namespace signals classes that are implementation details. Consumers must not depend on them. +Breaking changes inside `Internal/` are not semver-breaking for the library. + +## Nomenclature + +1. Every class, property, method, and exception name reflects the **concept** the library represents. A math library + uses `Precision`, `RoundingMode`; a money library uses `Currency`, `Amount`; a collection library uses + `Collectible`, `Order`. +2. Name classes after what they represent: `Money`, `Color`, `Pipeline` — not after what they do technically. +3. Name methods after the operation in the library's vocabulary: `add()`, `convertTo()`, `splitAt()`. + +### Always banned + +These names carry zero semantic content. Never use them anywhere, as class suffixes, prefixes, or method names: + +- `Data`, `Info`, `Utils`, `Item`, `Record`, `Entity`. +- `Exception` as a class suffix (e.g., `FooException` — use `Foo` when it already extends a native exception). + +### Anemic verbs (banned by default) + +These verbs hide what is actually happening behind a generic action. Banned unless the verb **is** the operation +that constitutes the library's reason to exist (e.g., a JSON parser may have `parse()`; a hashing library may +have `compute()`): + +- `ensure`, `validate`, `check`, `verify`, `assert`, `mark`, `enforce`, `sanitize`, `normalize`, `compute`, + `transform`, `parse`. + +When in doubt, prefer the domain operation name. `Password::hash()` beats `Password::compute()`; `Email::parse()` +is fine in a parser library but suspicious elsewhere (use `Email::from()` instead). + +### Architectural roles (allowed with justification) + +These names describe a role the library offers as a building block. Acceptable when the class **is** that role +(e.g., `EventHandler` in an events library, `CacheManager` in a cache library, `Upcaster` in an event-sourcing +library). Not acceptable on domain objects inside the library (value objects, enums, contract interfaces): + +- `Manager`, `Handler`, `Processor`, `Service`, and their verb forms `process`, `handle`, `execute`. + +The test: if the consumer instantiates or extends this class to integrate with the library, the role name is +legitimate. If the class models a concept the consumer manipulates (a money amount, a country code, a color), +the role name is wrong. + +## Value objects + +1. Are immutable: no setters, no mutation after construction. Operations return new instances. +2. Compare by value, not by reference. +3. Validate invariants in the constructor and throw on invalid input. +4. Have no identity field. +5. Use static factory methods (e.g., `from`, `of`, `zero`) with a private constructor when multiple creation paths + exist. The factory name communicates the semantic intent. + +## Exceptions + +1. Every failure throws a **dedicated exception class** named after the invariant it guards — never + `throw new DomainException('...')`, `throw new InvalidArgumentException('...')`, + `throw new RuntimeException('...')`, or any other generic native exception thrown directly. If the invariant + is worth throwing for, it is worth a named class. +2. Dedicated exception classes **extend** the appropriate native PHP exception (`DomainException`, + `InvalidArgumentException`, `OverflowException`, etc.) — the native class is the parent, never the thing that + is thrown. Consumers that catch the broad standard types continue to work; consumers that need precise handling + can catch the specific classes. +3. Exceptions are pure: no transport-specific fields (`code` populated with HTTP status, formatted `message` meant + for end-user display). Formatting to any transport happens at the consumer's boundary, not inside the library. +4. Exceptions signal invariant violations only, not control flow. +5. Name the class after the invariant violated, never after the technical type: + - `PrecisionOutOfRange` — not `InvalidPrecisionException`. + - `CurrencyMismatch` — not `BadCurrencyException`. + - `ContainerWaitTimeout` — not `TimeoutException`. +6. A descriptive `message` argument is allowed and encouraged when it carries **debugging context** — the violating + value, the boundary that was crossed, the state the library was in. The class name identifies the invariant; + the message describes the specific violation for stack traces and test assertions. Do not build messages meant + for end-user display or transport rendering. Keep them short, factual, and in American English. +7. Public exceptions live in `src/Exceptions/`. Internal exceptions live in `src/Internal/Exceptions/`. + +**Prohibited** — throwing a native exception directly: + +```php +if ($value < 0) { + throw new InvalidArgumentException('Precision cannot be negative.'); +} +``` + +**Correct** — dedicated class, no message (class name is sufficient): + +```php +// src/Exceptions/PrecisionOutOfRange.php +final class PrecisionOutOfRange extends InvalidArgumentException +{ +} + +// at the callsite +if ($value < 0) { + throw new PrecisionOutOfRange(); +} +``` + +**Correct** — dedicated class with debugging context: + +```php +if ($value < 0 || $value > 16) { + throw new PrecisionOutOfRange(sprintf('Precision must be between 0 and 16, got %d.', $value)); +} +``` + +## Enums + +1. Are PHP backed enums. +2. Include methods when they carry vocabulary meaning (e.g., `Order::ASCENDING_KEY`, `RoundingMode::apply()`). +3. Live at the `src/` root when public. Enums used only by internals live in `src/Internal/`. + +## Extension points + +1. When a class is designed to be extended by consumers (e.g., `Collection`, `ValueObject`), it uses `class` instead + of `final readonly class`. All other classes use `final readonly class`. +2. Extension point classes use a private constructor with static factory methods (`createFrom`, `createFromEmpty`) + as the only creation path. +3. Internal state is injected via the constructor and stored in a `private readonly` property. + +## Time and space complexity + +1. Every public method has predictable, documented complexity. Document Big O in PHPDoc on the interface + (see `php-library-code-style.md`, "PHPDoc" section). +2. Algorithms run in `O(N)` or `O(N log N)` unless the problem inherently requires worse. `O(N²)` or worse must + be justified and documented. +3. Prefer lazy/streaming evaluation over materializing intermediate results. In pipeline-style libraries, fuse + stages so a single pass suffices. +4. Memory usage is bounded and proportional to the output, not to the sum of intermediate stages. +5. Validate complexity claims with benchmarks against a reference implementation when optimizing critical paths. + Parity testing against the reference library is the validation standard for optimization work. diff --git a/.claude/rules/php-library-testing.md b/.claude/rules/php-library-testing.md new file mode 100644 index 0000000..610b928 --- /dev/null +++ b/.claude/rules/php-library-testing.md @@ -0,0 +1,116 @@ +--- +description: BDD Given/When/Then structure, PHPUnit conventions, test organization, and fixture rules for PHP libraries. +paths: + - "tests/**/*.php" +--- + +# Testing conventions + +Framework: **PHPUnit**. Refer to `php-library-code-style.md` for the code style checklist, which also applies to +test files. + +## Structure: Given/When/Then (BDD) + +Every test uses `/** @Given */`, `/** @And */`, `/** @When */`, `/** @Then */` doc comments without exception. + +### Happy path example + +```php +public function testAddMoneyWhenSameCurrencyThenAmountsAreSummed(): void +{ + /** @Given two money instances in the same currency */ + $ten = Money::of(amount: 1000, currency: Currency::BRL); + $five = Money::of(amount: 500, currency: Currency::BRL); + + /** @When adding them together */ + $total = $ten->add(other: $five); + + /** @Then the result contains the sum of both amounts */ + self::assertEquals(expected: 1500, actual: $total->amount()); +} +``` + +### Exception example + +When testing that an exception is thrown, place `@Then` (expectException) **before** `@When`. PHPUnit requires this +ordering. + +```php +public function testAddMoneyWhenDifferentCurrenciesThenCurrencyMismatch(): void +{ + /** @Given two money instances in different currencies */ + $brl = Money::of(amount: 1000, currency: Currency::BRL); + $usd = Money::of(amount: 500, currency: Currency::USD); + + /** @Then an exception indicating currency mismatch should be thrown */ + $this->expectException(CurrencyMismatch::class); + + /** @When trying to add money with different currencies */ + $brl->add(other: $usd); +} +``` + +Use `@And` for complementary preconditions or actions within the same scenario, avoiding consecutive `@Given` or +`@When` tags. + +## Rules + +1. Include exactly one `@When` per test. Two actions require two tests. +2. Test only the public API. Never assert on private state or `Internal/` classes directly. +3. Never mock internal collaborators. Use real objects. Use test doubles only at system boundaries (filesystem, + clock, network) when the library interacts with external resources. +4. Name tests to describe behavior, not method names. +5. Never include conditional logic inside tests. +6. Include one logical concept per `@Then` block. +7. Maintain strict independence between tests. No inherited state. +8. Use domain-specific model classes in `tests/Models/` for test fixtures that represent domain concepts + (e.g., `Amount`, `Invoice`, `Order`). +9. Use mock classes in `tests/Mocks/` (or `tests/Unit/Mocks/`) for test doubles of system boundaries + (e.g., `ClientMock`, `ExecutionCompletedMock`). +10. Exercise invariants and edge cases through the library's public entry point. Create a dedicated test class + for an internal model only when the condition cannot be reached through the public API. +11. Never use `/** @test */` annotation. Test methods are discovered by the `test` prefix in the method name. +12. Never use named arguments on PHPUnit assertions (`assertEquals`, `assertSame`, `assertTrue`, + `expectException`, etc.). Pass arguments positionally. + +## Test setup and fixtures + +1. **One annotation = one statement.** Each `@Given` or `@And` block contains exactly one annotation line + followed by one expression or assignment. Never place multiple variable declarations or object + constructions under a single annotation. +2. **No intermediate variables used only once.** If a value is consumed in a single place, inline it at the + call site. Chain method calls when the intermediate state is not referenced elsewhere + (e.g., `Money::of(...)->add(...)` instead of `$money = Money::of(...); $money->add(...);`). +3. **No private or helper methods in test classes.** The only non-test methods allowed are data providers. + If setup logic is complex enough to extract, it belongs in a dedicated fixture class, not in a + private method on the test class. +4. **Domain terms in variables and annotations.** Never use technical testing jargon (`$spy`, `$mock`, + `$stub`, `$fake`, `$dummy`) as variable or property names. Use the domain concept the object + represents: `$collection`, `$amount`, `$currency`, `$sortedElements`. Class names like + `ClientMock` or `GatewaySpy` are acceptable — the variable holding the instance is what matters. +5. **Annotations use domain language.** Write `/** @Given a collection of amounts */`, not + `/** @Given a mocked collection in test state */`. The annotation describes the domain + scenario, not the technical setup. + +## Test organization + +``` +tests/ +├── Models/ # Domain-specific fixtures reused across tests +├── Mocks/ # Test doubles for system boundaries +├── Unit/ # Unit tests for public API +│ └── Mocks/ # Alternative location for test doubles +├── Integration/ # Tests requiring real external resources (Docker, filesystem) +└── bootstrap.php # Test bootstrap when needed +``` + +`tests/Integration/` is only present when the library interacts with infrastructure. + +## Coverage and mutation testing + +1. Line and branch coverage must be **100%**. No annotations (`@codeCoverageIgnore`), attributes, or configuration + that exclude code from coverage are allowed. +2. All mutations reported by Infection must be **killed**. Never ignore or suppress mutants via `infection.json.dist` + or any other mechanism. +3. If a line or mutation cannot be covered or killed, it signals a design problem in the production code. Refactor + the code to make it testable, do not work around the tool. diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..73e3c9a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{yml,yaml}] +indent_size = 2 + +[Makefile] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..744a43b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,22 @@ +* text=auto eol=lf + +*.php text diff=php + +# Dev-only — excluded from the Packagist tarball +/.github export-ignore +/tests export-ignore +/.claude export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml export-ignore +/phpunit.xml.dist export-ignore +/phpstan.neon export-ignore +/phpstan.neon.dist export-ignore +/phpcs.xml export-ignore +/phpcs.xml.dist export-ignore +/infection.json export-ignore +/infection.json.dist export-ignore +/Makefile export-ignore +/CONTRIBUTING.md export-ignore +/CHANGES.md export-ignore diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..77c2bb8 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,11 @@ +# Copilot instructions + +## Context + +PHP library (tiny-blocks). Immutable domain models, zero infrastructure dependencies in core. + +## Mandatory pre-task step + +Before starting any task, read and strictly follow all instruction files located in `.claude/CLAUDE.md` and +`.claude/rules/`. These files are the absolute source of truth for code generation. Apply every rule strictly. Do not +deviate from the patterns, folder structure, or naming conventions defined in them. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f0ce8fc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +version: 2 + +updates: + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 0 + labels: + - "php" + - "security" + - "dependencies" + groups: + php-security: + applies-to: security-updates + patterns: + - "*" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "build" + labels: + - "dependencies" + - "github-actions" + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml new file mode 100644 index 0000000..d0ba49e --- /dev/null +++ b/.github/workflows/auto-assign.yml @@ -0,0 +1,25 @@ +name: Auto assign issues and pull requests + +on: + issues: + types: + - opened + pull_request: + types: + - opened + +jobs: + run: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - name: Assign issues and pull requests + uses: gustavofreze/auto-assign@2.1.0 + with: + assignees: '${{ vars.ASSIGNEES }}' + github_token: '${{ secrets.GITHUB_TOKEN }}' + allow_self_assign: 'true' + allow_no_assignees: 'true' + assignment_options: 'ISSUE,PULL_REQUEST' \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..20d4018 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,111 @@ +name: CI + +on: + pull_request: + +concurrency: + group: pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + load-config: + name: Load config + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + php-version: ${{ steps.config.outputs.php-version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve PHP version from composer.json + id: config + run: | + version=$(jq -r '.require.php' composer.json | grep -oP '\d+\.\d+' | head -1) + echo "php-version=$version" >> "$GITHUB_OUTPUT" + + build: + name: Build + needs: load-config + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + tools: composer:2 + php-version: ${{ needs.load-config.outputs.php-version }} + + - name: Validate composer.json + run: composer validate --no-interaction + + - name: Install dependencies + run: composer install --no-progress --optimize-autoloader --prefer-dist --no-interaction + + - name: Upload vendor and composer.lock as artifact + uses: actions/upload-artifact@v4 + with: + name: vendor-artifact + path: | + vendor + composer.lock + + auto-review: + name: Auto review + needs: [load-config, build] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + tools: composer:2 + php-version: ${{ needs.load-config.outputs.php-version }} + + - name: Download vendor artifact from build + uses: actions/download-artifact@v4 + with: + name: vendor-artifact + path: . + + - name: Run review + run: composer review + + tests: + name: Tests + needs: [load-config, auto-review] + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + DATABASE_HOST: outbox-test-db + DATABASE_NAME: outbox_test + DATABASE_USER: outbox + DATABASE_PASSWORD: outbox + TEST_DB_HOST_PORT: '33306' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + tools: composer:2 + php-version: ${{ needs.load-config.outputs.php-version }} + + - name: Download vendor artifact from build + uses: actions/download-artifact@v4 + with: + name: vendor-artifact + path: . + + - name: Run tests + run: composer tests diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..4c6d7f7 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,35 @@ +name: Security checks + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: "0 0 * * *" + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: [ "actions" ] + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd5baa3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Agent/IDE +.claude/ +.idea/ +.vscode/ +.cursor/ + +# Composer +/vendor/ +composer.lock + +# PHPUnit / coverage +.phpunit.cache/ +.phpunit.result.cache +report/ +coverage/ +build/ + +# OS +.DS_Store +Thumbs.db diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6d6ada2 --- /dev/null +++ b/Makefile @@ -0,0 +1,81 @@ +PWD := $(CURDIR) +ARCH := $(shell uname -m) +PLATFORM := + +ifeq ($(ARCH),arm64) + PLATFORM := --platform=linux/amd64 +endif + +DOCKER_RUN = docker run ${PLATFORM} --rm -it --net=host \ + -e DATABASE_HOST=outbox-test-db \ + -e TEST_DB_HOST_PORT=33306 \ + -e DATABASE_NAME=outbox_test \ + -e DATABASE_USER=outbox \ + -e DATABASE_PASSWORD=outbox \ + -v ${PWD}:/app -w /app \ + -v /var/run/docker.sock:/var/run/docker.sock \ + gustavofreze/php:8.5-alpine + +RESET := \033[0m +GREEN := \033[0;32m +YELLOW := \033[0;33m + +.DEFAULT_GOAL := help + +.PHONY: configure +configure: ## Configure development environment + @${DOCKER_RUN} composer update --optimize-autoloader + @${DOCKER_RUN} composer normalize + +.PHONY: test +test: ## Run all tests with coverage + @${DOCKER_RUN} composer tests + +.PHONY: test-file +test-file: ## Run tests for a specific file (usage: make test-file FILE=ClassNameTest) + @${DOCKER_RUN} composer test-file ${FILE} + +.PHONY: test-no-coverage +test-no-coverage: ## Run all tests without coverage + @${DOCKER_RUN} composer tests-no-coverage + +.PHONY: review +review: ## Run static code analysis + @${DOCKER_RUN} composer review + +.PHONY: show-reports +show-reports: ## Open static analysis reports (e.g., coverage, lints) in the browser + @sensible-browser report/coverage/coverage-html/index.html report/coverage/mutation-report.html + +.PHONY: show-outdated +show-outdated: ## Show outdated direct dependencies + @${DOCKER_RUN} composer outdated --direct + +.PHONY: clean +clean: ## Remove dependencies and generated artifacts + @sudo chown -R ${USER}:${USER} ${PWD} + @rm -rf report vendor .phpunit.cache *.lock + +.PHONY: help +help: ## Display this help message + @echo "Usage: make [target]" + @echo "" + @echo "$$(printf '$(GREEN)')Setup$$(printf '$(RESET)')" + @grep -E '^(configure):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*? ## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @echo "" + @echo "$$(printf '$(GREEN)')Testing$$(printf '$(RESET)')" + @grep -E '^(test|test-file|test-no-coverage):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @echo "" + @echo "$$(printf '$(GREEN)')Quality$$(printf '$(RESET)')" + @grep -E '^(review):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @echo "" + @echo "$$(printf '$(GREEN)')Reports$$(printf '$(RESET)')" + @grep -E '^(show-reports|show-outdated):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @echo "" + @echo "$$(printf '$(GREEN)')Cleanup$$(printf '$(RESET)')" + @grep -E '^(clean):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' diff --git a/README.md b/README.md index d8a5a2b..c1ad7f2 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,457 @@ -# outbox -Implements the transactional outbox engine for PHP: relay loop, transactional batching, back-off strategies, and observability hooks. +# Outbox + +[![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/tiny-blocks/outbox/blob/main/LICENSE) + +* [Overview](#overview) +* [Installation](#installation) +* [How to use](#how-to-use) + + [Expected table schema](#expected-table-schema) + + [Wiring the repository](#wiring-the-repository) + + [Producing events from an aggregate](#producing-events-from-an-aggregate) + + [Customizing the table layout](#customizing-the-table-layout) + + [Writing a custom payload serializer](#writing-a-custom-payload-serializer) + + [Writing a custom snapshot serializer](#writing-a-custom-snapshot-serializer) + + [Event schema versioning](#event-schema-versioning) +* [FAQ](#faq) +* [License](#license) +* [Contributing](#contributing) + +## Overview + +The **Transactional Outbox** pattern solves the dual-write problem: persisting an aggregate state change and publishing +a domain event must happen atomically. Doing both independently risks a crash leaving one side committed and the other +lost. The outbox pattern records both in the same database transaction, delegating event delivery to a separate relay +process. + +This library is the write-side adapter. It persists outbox records via Doctrine DBAL and is opinionated on correctness. +Transactions are always required and JSON validity is always checked, while leaving every schema decision to you: table +name, column names, and identity column storage type are all configurable. + +The library composes with [`tiny-blocks/building-blocks`](https://github.com/tiny-blocks/building-blocks), which +contributes `DomainEvent`, `DomainEventBehavior`, `EventRecord`, `EventRecords`, `EventType`, `Revision`, +`SequenceNumber`, `SnapshotData`, and the `EventualAggregateRoot` family. This library provides the persistence step +only. + +## Installation + +``` +composer require tiny-blocks/outbox +``` + +## How to use + +### Expected table schema + +The library does not create or manage the outbox table. Add it in your own migration. + +**Default schema (BINARY(16) identity columns, recommended for UUID-based aggregates):** + +```sql +CREATE TABLE outbox_events ( + sequence BIGINT NOT NULL AUTO_INCREMENT UNIQUE, + id BINARY(16) NOT NULL PRIMARY KEY, + aggregate_type VARCHAR(255) NOT NULL, + aggregate_id BINARY(16) NOT NULL, + event_type VARCHAR(255) NOT NULL, + revision INT NOT NULL, + sequence_number BIGINT NOT NULL, + payload JSON NOT NULL, + snapshot JSON NOT NULL, + occurred_at DATETIME(6) NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + INDEX idx_aggregate (aggregate_id), + UNIQUE KEY uniq_aggregate_sequence (aggregate_type, aggregate_id, sequence_number) +); +``` + +The library writes to `id`, `aggregate_id`, `aggregate_type`, `event_type`, `revision`, `sequence_number`, `payload`, +`snapshot`, and `occurred_at`. It never writes to `sequence` or `created_at`. The database fills those automatically. + +For aggregates whose identities are not UUID v4 strings, use VARCHAR columns and configure `IdentityColumnType::STRING` +(see [Customizing the table layout](#customizing-the-table-layout)): + +```sql +-- For non-UUID identities: VARCHAR(36) or wider. +id VARCHAR(36) NOT NULL PRIMARY KEY, +aggregate_id VARCHAR(36) NOT NULL, +``` + +### Wiring the repository + +`DoctrineOutboxRepository` requires a Doctrine DBAL `Connection` and a `PayloadSerializers` collection. The snapshot +serializer collection defaults to a `SnapshotSerializers` containing `SnapshotSerializerReflection`, and the table +layout defaults to table `outbox_events` with BINARY(16) identity columns. + +```php +beginTransaction(); + +try { + $order = Order::place(orderId: 'order-123'); + $orderRepository->save(order: $order); + $outboxRepository->push(records: $order->recordedEvents()); + $connection->commit(); +} catch (Throwable $exception) { + $connection->rollBack(); + throw $exception; +} +``` + +The aggregate instance is use-once: its recorded-events buffer is never cleared by the library. Discard the instance +after `push()`. Re-saving the same instance pushes the same records again and throws `DuplicateOutboxEvent`. + +### Customizing the table layout + +`TableLayout::builder()` controls the table name. `Columns::builder()` renames individual columns and +switches identity column storage between BINARY(16) and STRING. + +```php +withTableName(tableName: 'domain_events') + ->withColumns(columns: Columns::builder() + ->withId(name: 'id', type: IdentityColumnType::STRING) + ->withEventType(name: 'kind') + ->withAggregateId(name: 'aggregate_id', type: IdentityColumnType::STRING) + ->withAggregateType(name: 'entity_class') + ->withSequenceNumber(name: 'position') + ->build()) + ->build(); + +$repository = new DoctrineOutboxRepository( + connection: $connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new PayloadSerializerReflection()]), + tableLayout: $tableLayout +); +``` + +All `Columns::builder()` methods are optional. Omit any method to keep its default. +`withId` and `withAggregateId` require both `name:` and `type:`; all other methods require only `name:`. + +| Method | Default column name | Default type | Description | +|---------------------------------|---------------------|--------------|-----------------------------------------------------------------| +| `withId(name:, type:)` | `id` | `BINARY` | Renames the event id column and/or changes its storage type | +| `withAggregateId(name:, type:)` | `aggregate_id` | `BINARY` | Renames the aggregate id column and/or changes its storage type | +| `withAggregateType(name:)` | `aggregate_type` | — | Renames the aggregate type column | +| `withEventType(name:)` | `event_type` | — | Renames the event type column | +| `withRevision(name:)` | `revision` | — | Renames the schema revision column | +| `withSequenceNumber(name:)` | `sequence_number` | — | Renames the aggregate sequence number column | +| `withPayload(name:)` | `payload` | — | Renames the event payload column | +| `withSnapshot(name:)` | `snapshot` | — | Renames the aggregate snapshot column | +| `withOccurredAt(name:)` | `occurred_at` | — | Renames the event timestamp column | +| `withCreatedAt(name:)` | `created_at` | — | Renames the record creation timestamp column | + +`TableLayout::builder()` controls the table name, columns, and unique constraint name. + +| Method | Default | Description | +|-------------------------------|---------------------------|--------------------------------------------------------------------| +| `withTableName(tableName:)` | `outbox_events` | Sets the outbox table name | +| `withColumns(columns:)` | Default column names | Provides a custom `Columns` configuration | +| `withUniqueConstraint(name:)` | `uniq_aggregate_sequence` | Sets the unique constraint name used to detect duplicate sequences | + +The DDL example uses `uniq_aggregate_sequence` as the unique constraint name. The library expects this name by +default; if you rename it in your DDL, configure it via +`TableLayout::builder()->withUniqueConstraint(name: 'your_name')->build()`. + +Constraint violation detection works with MySQL, MariaDB, PostgreSQL, and SQL Server. These DBMSs include the +constraint name in their violation messages. SQLite is not supported because it omits the constraint name. +All unique violations with SQLite fall under `DuplicateOutboxEvent`. + +### Writing a custom payload serializer + +`PayloadSerializerReflection` covers events whose public properties are scalars or `JsonSerializable`. Implement +`PayloadSerializer` explicitly for events that contain value objects or domain types that need custom JSON shaping. + +Both `supports()` and `serialize()` receive the full `EventRecord`, giving access to `$record->event`, +`$record->aggregateType`, `$record->snapshotData`, and all other fields when routing or shaping the payload. + +Use `match (true)` in `serialize()` to handle multiple event types from the same aggregate in a single serializer: + +```php +event instanceof OrderPlaced || $record->event instanceof OrderShipped; + } + + public function serialize(EventRecord $record): SerializedPayload + { + return match (true) { + $record->event instanceof OrderPlaced => SerializedPayload::from( + payload: json_encode(['orderId' => $record->event->orderId], JSON_THROW_ON_ERROR) + ), + $record->event instanceof OrderShipped => SerializedPayload::from( + payload: json_encode( + ['orderId' => $record->event->orderId, 'shippedAt' => $record->event->shippedAt], + JSON_THROW_ON_ERROR + ) + ) + }; + } +} + +# Register custom serializers before PayloadSerializerReflection. +# PayloadSerializerReflection always returns true from supports(), so it must come last. +$repository = new DoctrineOutboxRepository( + connection: $connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [ + new OrderEventSerializer(), + new PayloadSerializerReflection() + ]) +); +``` + +`SerializedPayload::from()` validates the JSON string at construction time and throws `InvalidPayloadJson` if the JSON +is malformed, before the INSERT is attempted. When building the JSON from an array, prefer +`SerializedPayload::fromArray($array)` over `SerializedPayload::from(json_encode($array, JSON_THROW_ON_ERROR))`. The +library handles encoding internally. + +### Writing a custom snapshot serializer + +The aggregate snapshot records the state at the time of each event. By default, `SnapshotSerializerReflection` +serializes `$record->snapshotData->toArray()` using `json_encode`. Provide a custom `SnapshotSerializer` when the +snapshot payload contains value objects or domain types that are not directly JSON-encodable. + +Both `supports()` and `serialize()` receive the full `EventRecord`, giving access to `$record->snapshotData`, +`$record->aggregateType`, `$record->event`, and all other fields when routing or shaping the snapshot. + +```php +aggregateType === 'Order'; + } + + public function serialize(EventRecord $record): SerializedSnapshot + { + $state = $record->snapshotData->toArray(); + + return SerializedSnapshot::from( + snapshot: json_encode( + ['orderId' => $state['orderId']->value, 'status' => $state['status']], + JSON_THROW_ON_ERROR + ) + ); + } +} + +# Register custom snapshot serializers before SnapshotSerializerReflection. +# SnapshotSerializerReflection always returns true from supports(), so it must come last. +$repository = new DoctrineOutboxRepository( + connection: $connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new PayloadSerializerReflection()]), + snapshotSerializers: SnapshotSerializers::createFrom(elements: [ + new OrderSnapshotSerializer(), + new SnapshotSerializerReflection() + ]) +); +``` + +`SerializedSnapshot::from()` validates the JSON string at construction time and throws `InvalidSnapshotJson` if the +JSON is malformed, before the INSERT is attempted. When building the JSON from an array, prefer +`SerializedSnapshot::fromArray($array)` over `SerializedSnapshot::from(json_encode($array, JSON_THROW_ON_ERROR))`. The +library handles encoding internally. + +### Event schema versioning + +Each domain event declares its schema revision via `DomainEvent::revision()`. `DomainEventBehavior` provides the +default implementation, returning revision 1. Override `revision()` when the event's payload structure changes: + +```php +aggregateType === 'Order'`), or the snapshot shaping may vary +based on which event triggered the state change (`$record->event`). Receiving the full `EventRecord` in both +`supports()` and `serialize()` gives serializers access to all available context without requiring any additional +indirection. + +### 09. How does the library handle transient database errors? + +The library catches `UniqueConstraintViolationException` to differentiate +`DuplicateAggregateSequence` from `DuplicateOutboxEvent`. All other DBAL +exceptions, including transient errors like deadlocks (`DeadlockException`), +lock wait timeouts (`LockWaitTimeoutException`), and connection failures +(`ConnectionLost`), propagate unchanged to the caller. + +The consumer is responsible for any retry policy. A common pattern is to wrap +the unit of work (aggregate save + outbox push) in a retry loop that catches +transient exceptions and re-executes the entire transaction. + +## License + +Outbox is licensed under [MIT](LICENSE). + +## Contributing + +Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to +contribute to the project. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..8b11ba1 --- /dev/null +++ b/composer.json @@ -0,0 +1,84 @@ +{ + "name": "tiny-blocks/outbox", + "description": "Implements the Transactional Outbox pattern. Persists domain events atomically with aggregate state changes through a customizable table schema, reflection-based payload serialization, and built-in support for event schema versioning.", + "license": "MIT", + "type": "library", + "keywords": [ + "ddd", + "outbox", + "tiny-blocks", + "event-store", + "event-driven", + "domain-events", + "event-sourcing", + "event-versioning", + "event-persistence", + "event-serialization", + "transactional-outbox" + ], + "authors": [ + { + "name": "Gustavo Freze de Araujo Santos", + "homepage": "https://github.com/gustavofreze" + } + ], + "homepage": "https://github.com/tiny-blocks/outbox", + "support": { + "issues": "https://github.com/tiny-blocks/outbox/issues", + "source": "https://github.com/tiny-blocks/outbox" + }, + "require": { + "php": "^8.5", + "doctrine/dbal": "^4.0", + "ramsey/uuid": "^4.9", + "tiny-blocks/building-blocks": "^2.2", + "tiny-blocks/collection": "^2.3" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.51", + "infection/infection": "^0.32", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^13.1", + "squizlabs/php_codesniffer": "^4.0", + "tiny-blocks/docker-container": "^2.0", + "tiny-blocks/environment-variable": "^1.2" + }, + "minimum-stability": "stable", + "prefer-stable": true, + "autoload": { + "psr-4": { + "TinyBlocks\\Outbox\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Test\\TinyBlocks\\Outbox\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "ergebnis/composer-normalize": true, + "infection/extension-installer": true + }, + "sort-packages": true + }, + "scripts": { + "mutation-test": "php ./vendor/bin/infection --threads=max --logger-html=report/coverage/mutation-report.html --coverage=report/coverage", + "phpcs": "php ./vendor/bin/phpcs --standard=PSR12 --extensions=php ./src", + "phpstan": "php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress", + "review": [ + "@phpcs", + "@phpstan" + ], + "test": "php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", + "test-file": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", + "test-no-coverage": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage tests", + "tests": [ + "@test", + "@mutation-test" + ], + "tests-no-coverage": [ + "@test-no-coverage" + ] + } +} diff --git a/infection.json.dist b/infection.json.dist new file mode 100644 index 0000000..45c49fc --- /dev/null +++ b/infection.json.dist @@ -0,0 +1,23 @@ +{ + "logs": { + "text": "report/infection/logs/infection-text.log", + "summary": "report/infection/logs/infection-summary.log" + }, + "tmpDir": "report/infection/", + "minMsi": 100, + "timeout": 30, + "source": { + "directories": [ + "src" + ] + }, + "phpUnit": { + "configDir": "", + "customPath": "./vendor/bin/phpunit" + }, + "mutators": { + "@default": true + }, + "minCoveredMsi": 100, + "testFramework": "phpunit" +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..297c011 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,9 @@ +parameters: + paths: + - src + level: 9 + tmpDir: report/phpstan + ignoreErrors: + - identifier: cast.string + - identifier: missingType.iterableValue + reportUnmatchedIgnoredErrors: true diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..97ff87c --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,37 @@ + + + + + + src + + + + + + tests + + + + + + + + + + + + + + + + + diff --git a/src/DoctrineOutboxRepository.php b/src/DoctrineOutboxRepository.php new file mode 100644 index 0000000..d2260f3 --- /dev/null +++ b/src/DoctrineOutboxRepository.php @@ -0,0 +1,80 @@ +tableLayout = $tableLayout ?? TableLayout::default(); + $this->snapshotSerializers = $snapshotSerializers + ?? SnapshotSerializers::createFrom(elements: [new SnapshotSerializerReflection()]); + } + + public function push(EventRecords $records): void + { + if (!$this->connection->isTransactionActive()) { + throw OutboxRequiresActiveTransaction::asMissing(); + } + + $records->each(actions: function (EventRecord $record): void { + $payloadSerializer = $this->payloadSerializers->findFor(record: $record); + + if (is_null($payloadSerializer)) { + throw new PayloadSerializerNotConfigured(eventClass: $record->event::class); + } + + $snapshotSerializer = $this->snapshotSerializers->findFor(record: $record); + + if (is_null($snapshotSerializer)) { + throw SnapshotSerializerNotConfigured::for(aggregateType: $record->aggregateType); + } + + $insert = OutboxInsert::from( + record: $record, + payload: $payloadSerializer->serialize(record: $record), + snapshot: $snapshotSerializer->serialize(record: $record), + tableLayout: $this->tableLayout + ); + + try { + $this->connection->executeStatement($insert->sql, $insert->parameters); + } catch (UniqueConstraintViolationException $exception) { + if ($this->tableLayout->uniqueConstraint->isViolatedBy(exception: $exception)) { + throw new DuplicateAggregateSequence( + aggregateId: (string)$record->identity->identityValue(), + aggregateType: $record->aggregateType, + sequenceNumber: $record->sequenceNumber->value, + previous: $exception + ); + } + + throw DuplicateOutboxEvent::forRecord(eventId: $record->id, previous: $exception); + } + }); + } +} diff --git a/src/Exceptions/DuplicateAggregateSequence.php b/src/Exceptions/DuplicateAggregateSequence.php new file mode 100644 index 0000000..cd14141 --- /dev/null +++ b/src/Exceptions/DuplicateAggregateSequence.php @@ -0,0 +1,24 @@ + at sequence number <%d>.'; + + parent::__construct(sprintf($template, $aggregateType, $aggregateId, $sequenceNumber), self::CODE, $previous); + } +} diff --git a/src/Exceptions/DuplicateOutboxEvent.php b/src/Exceptions/DuplicateOutboxEvent.php new file mode 100644 index 0000000..7febc2c --- /dev/null +++ b/src/Exceptions/DuplicateOutboxEvent.php @@ -0,0 +1,21 @@ +.'; + + parent::__construct(sprintf($template, $eventClass)); + } +} diff --git a/src/Exceptions/SnapshotSerializerNotConfigured.php b/src/Exceptions/SnapshotSerializerNotConfigured.php new file mode 100644 index 0000000..94f33a2 --- /dev/null +++ b/src/Exceptions/SnapshotSerializerNotConfigured.php @@ -0,0 +1,17 @@ +getBytes(); + } +} diff --git a/src/Internal/ColumnsBuilder.php b/src/Internal/ColumnsBuilder.php new file mode 100644 index 0000000..eaaf600 --- /dev/null +++ b/src/Internal/ColumnsBuilder.php @@ -0,0 +1,112 @@ +payload = $name; + return $this; + } + + public function withRevision(string $name): ColumnsBuilder + { + $this->revision = $name; + return $this; + } + + public function withCreatedAt(string $name): ColumnsBuilder + { + $this->createdAt = $name; + return $this; + } + + public function withSnapshot(string $name): ColumnsBuilder + { + $this->snapshot = $name; + return $this; + } + + public function withEventType(string $name): ColumnsBuilder + { + $this->eventType = $name; + return $this; + } + + public function withOccurredAt(string $name): ColumnsBuilder + { + $this->occurredAt = $name; + return $this; + } + + public function withAggregateType(string $name): ColumnsBuilder + { + $this->aggregateType = $name; + return $this; + } + + public function withSequenceNumber(string $name): ColumnsBuilder + { + $this->sequenceNumber = $name; + return $this; + } + + public function withId(string $name, IdentityColumnType $type): ColumnsBuilder + { + $this->idName = $name; + $this->idType = $type; + return $this; + } + + public function withAggregateId(string $name, IdentityColumnType $type): ColumnsBuilder + { + $this->aggregateIdName = $name; + $this->aggregateIdType = $type; + return $this; + } + + public function build(): Columns + { + return Columns::from( + id: $this->idType->toColumn(name: $this->idName), + payload: $this->payload, + revision: $this->revision, + snapshot: $this->snapshot, + createdAt: $this->createdAt, + eventType: $this->eventType, + occurredAt: $this->occurredAt, + aggregateId: $this->aggregateIdType->toColumn(name: $this->aggregateIdName), + aggregateType: $this->aggregateType, + sequenceNumber: $this->sequenceNumber + ); + } +} diff --git a/src/Internal/IdentityColumn.php b/src/Internal/IdentityColumn.php new file mode 100644 index 0000000..3718618 --- /dev/null +++ b/src/Internal/IdentityColumn.php @@ -0,0 +1,14 @@ +columns; + $idValue = $columns->id->convert(identityValue: $record->id); + $aggregateIdValue = $columns->aggregateId->convert(identityValue: $record->identity->identityValue()); + + return new OutboxInsert( + sql: sprintf( + $template, + $tableLayout->tableName, + $columns->id->name, + $columns->aggregateId->name, + $columns->aggregateType, + $columns->eventType, + $columns->revision, + $columns->sequenceNumber, + $columns->payload, + $columns->snapshot, + $columns->occurredAt + ), + parameters: [ + 'id' => $idValue, + 'aggregateId' => $aggregateIdValue, + 'aggregateType' => $record->aggregateType, + 'eventType' => $record->type->value, + 'revision' => $record->revision->value, + 'sequenceNumber' => $record->sequenceNumber->value, + 'payload' => $payload->toJson(), + 'snapshot' => $snapshot->toJson(), + 'occurredAt' => $record->occurredOn->toIso8601() + ] + ); + } +} diff --git a/src/Internal/StringIdentityColumn.php b/src/Internal/StringIdentityColumn.php new file mode 100644 index 0000000..4118075 --- /dev/null +++ b/src/Internal/StringIdentityColumn.php @@ -0,0 +1,18 @@ +columns = Columns::default(); + $this->uniqueConstraint = UniqueConstraint::default(); + } + + public static function create(): TableLayoutBuilder + { + return new TableLayoutBuilder(); + } + + public function withColumns(Columns $columns): TableLayoutBuilder + { + $this->columns = $columns; + return $this; + } + + public function withTableName(string $tableName): TableLayoutBuilder + { + $this->tableName = $tableName; + return $this; + } + + public function withUniqueConstraint(string $name): TableLayoutBuilder + { + $this->uniqueConstraint = UniqueConstraint::named(name: $name); + return $this; + } + + public function build(): TableLayout + { + return TableLayout::from( + columns: $this->columns, + tableName: $this->tableName, + uniqueConstraint: $this->uniqueConstraint + ); + } +} diff --git a/src/OutboxRepository.php b/src/OutboxRepository.php new file mode 100644 index 0000000..54305be --- /dev/null +++ b/src/OutboxRepository.php @@ -0,0 +1,40 @@ +Used by aggregate repositories during the business unit of work. The implementation must not + * open or commit a transaction. Atomicity with the aggregate state change is the caller's responsibility.

+ */ +interface OutboxRepository +{ + /** + * Persists the given records as part of the caller's open transaction. + * + *

The implementation must not open or commit a transaction. It is the caller's responsibility + * to ensure this call happens inside the same unit of work as the aggregate state change.

+ * + * @param EventRecords $records The records to persist. + * @throws OutboxRequiresActiveTransaction When called outside an active transaction. + * @throws PayloadSerializerNotConfigured When no serializer supports the event class. + * @throws SnapshotSerializerNotConfigured When no serializer supports the aggregate type. + * @throws InvalidPayloadJson When a serializer produces an invalid JSON payload. + * @throws InvalidSnapshotJson When a serializer produces an invalid JSON snapshot. + * @throws DuplicateOutboxEvent When a record with a duplicate id already exists in the outbox. + * @throws DuplicateAggregateSequence When two records share the same aggregate type, id, and sequence number. + */ + public function push(EventRecords $records): void; +} diff --git a/src/Schema/Columns.php b/src/Schema/Columns.php new file mode 100644 index 0000000..42a51d5 --- /dev/null +++ b/src/Schema/Columns.php @@ -0,0 +1,61 @@ +build(); + } + + public static function from( + IdentityColumn $id, + string $payload, + string $revision, + string $snapshot, + string $createdAt, + string $eventType, + string $occurredAt, + IdentityColumn $aggregateId, + string $aggregateType, + string $sequenceNumber + ): Columns { + return new Columns( + id: $id, + payload: $payload, + revision: $revision, + snapshot: $snapshot, + createdAt: $createdAt, + eventType: $eventType, + occurredAt: $occurredAt, + aggregateId: $aggregateId, + aggregateType: $aggregateType, + sequenceNumber: $sequenceNumber + ); + } +} diff --git a/src/Schema/IdentityColumnType.php b/src/Schema/IdentityColumnType.php new file mode 100644 index 0000000..8c1bd9f --- /dev/null +++ b/src/Schema/IdentityColumnType.php @@ -0,0 +1,23 @@ + BinaryIdentityColumn::named(name: $name), + IdentityColumnType::STRING => StringIdentityColumn::named(name: $name) + }; + } +} diff --git a/src/Schema/TableLayout.php b/src/Schema/TableLayout.php new file mode 100644 index 0000000..f753235 --- /dev/null +++ b/src/Schema/TableLayout.php @@ -0,0 +1,39 @@ +build(); + } + + public static function builder(): TableLayoutBuilder + { + return TableLayoutBuilder::create(); + } +} diff --git a/src/Schema/UniqueConstraint.php b/src/Schema/UniqueConstraint.php new file mode 100644 index 0000000..96cbf61 --- /dev/null +++ b/src/Schema/UniqueConstraint.php @@ -0,0 +1,29 @@ +getMessage(), $this->name); + } +} diff --git a/src/Serialization/PayloadSerializer.php b/src/Serialization/PayloadSerializer.php new file mode 100644 index 0000000..80408b3 --- /dev/null +++ b/src/Serialization/PayloadSerializer.php @@ -0,0 +1,26 @@ +event)); + } +} diff --git a/src/Serialization/PayloadSerializers.php b/src/Serialization/PayloadSerializers.php new file mode 100644 index 0000000..9406c86 --- /dev/null +++ b/src/Serialization/PayloadSerializers.php @@ -0,0 +1,20 @@ +findBy( + predicates: static fn(PayloadSerializer $serializer): bool => $serializer->supports(record: $record) + ); + + return $serializer instanceof PayloadSerializer ? $serializer : null; + } +} diff --git a/src/Serialization/SerializedPayload.php b/src/Serialization/SerializedPayload.php new file mode 100644 index 0000000..2e96b56 --- /dev/null +++ b/src/Serialization/SerializedPayload.php @@ -0,0 +1,35 @@ +payload; + } +} diff --git a/src/Serialization/SerializedSnapshot.php b/src/Serialization/SerializedSnapshot.php new file mode 100644 index 0000000..1a0a8be --- /dev/null +++ b/src/Serialization/SerializedSnapshot.php @@ -0,0 +1,35 @@ +snapshot; + } +} diff --git a/src/Serialization/SnapshotSerializer.php b/src/Serialization/SnapshotSerializer.php new file mode 100644 index 0000000..357615f --- /dev/null +++ b/src/Serialization/SnapshotSerializer.php @@ -0,0 +1,26 @@ +snapshotData->toArray()); + } +} diff --git a/src/Serialization/SnapshotSerializers.php b/src/Serialization/SnapshotSerializers.php new file mode 100644 index 0000000..7eb51c0 --- /dev/null +++ b/src/Serialization/SnapshotSerializers.php @@ -0,0 +1,20 @@ +findBy( + predicates: static fn(SnapshotSerializer $serializer): bool => $serializer->supports(record: $record) + ); + + return $serializer instanceof SnapshotSerializer ? $serializer : null; + } +} diff --git a/tests/Database.php b/tests/Database.php new file mode 100644 index 0000000..fe6435d --- /dev/null +++ b/tests/Database.php @@ -0,0 +1,78 @@ +toString(); + $port = EnvironmentVariable::from(name: 'TEST_DB_HOST_PORT')->toString(); + $database = EnvironmentVariable::from(name: 'DATABASE_NAME')->toString(); + $username = EnvironmentVariable::from(name: 'DATABASE_USER')->toString(); + $password = EnvironmentVariable::from(name: 'DATABASE_PASSWORD')->toString(); + + return new Database( + host: $host, + port: $port, + database: $database, + username: $username, + password: $password + ); + } + + public function start(): void + { + try { + $this->connection()->executeQuery('SELECT 1'); + return; + } catch (Exception) { + } + + new Process(['docker', 'rm', '-f', sprintf('tiny-blocks-reaper-%s', $this->host)])->run(); + new Process(['docker', 'rm', '-f', $this->host])->run(); + + MySQLDockerContainer::from(image: 'mysql:8.4', name: $this->host) + ->pullImage() + ->withTimezone(timezone: 'UTC') + ->withUsername(user: $this->username) + ->withPassword(password: $this->password) + ->withDatabase(database: $this->database) + ->withPortMapping(portOnHost: (int)$this->port, portOnContainer: 3306) + ->withRootPassword(rootPassword: 'root') + ->withGrantedHosts() + ->withoutAutoRemove() + ->withReadinessTimeout(timeoutInSeconds: 60) + ->run(); + } + + public function connection(): Connection + { + return DriverManager::getConnection(params: [ + 'host' => '127.0.0.1', + 'port' => (int)$this->port, + 'user' => $this->username, + 'driver' => 'pdo_mysql', + 'dbname' => $this->database, + 'password' => $this->password + ]); + } +} diff --git a/tests/DoctrineOutboxRepositoryTest.php b/tests/DoctrineOutboxRepositoryTest.php new file mode 100644 index 0000000..4ed8ce7 --- /dev/null +++ b/tests/DoctrineOutboxRepositoryTest.php @@ -0,0 +1,1239 @@ +executeStatement('DROP TABLE IF EXISTS outbox_events'); + OutboxTableFactory::createWithBinaryIdentities( + connection: self::$connection, + tableLayout: TableLayout::default() + ); + } + + public function testPushWhenNoTransactionThenOutboxRequiresActiveTransaction(): void + { + /** @Given a repository */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]) + ); + + /** @And a record to push */ + $records = EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ) + ]); + + /** @Then an exception requiring an active transaction is thrown */ + $this->expectException(OutboxRequiresActiveTransaction::class); + $this->expectExceptionMessage('push() must be called within an active transaction.'); + + /** @When pushing without an active transaction */ + $repository->push(records: $records); + } + + public function testPushWhenMultipleSerializersAndFirstMatchesThenFirstIsUsed(): void + { + /** @Given a repository with two serializers supporting the same event */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [ + new OrderPlacedSerializer(), + new FallbackOrderPlacedSerializer() + ]) + ); + + /** @When pushing an order placed event inside a transaction */ + self::$connection->beginTransaction(); + $repository->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ) + ])); + self::$connection->commit(); + + /** @Then the payload is from the first serializer, not the fallback */ + self::assertSame('{}', self::$connection->fetchOne('SELECT payload FROM outbox_events LIMIT 1')); + } + + public function testPushWhenReflectionPayloadSerializerThenEventPropertiesAreEncoded(): void + { + /** @Given a repository using ReflectionPayloadSerializer as the only serializer */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new PayloadSerializerReflection()]) + ); + + /** @When pushing a record inside a transaction */ + self::$connection->beginTransaction(); + $repository->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ) + ])); + self::$connection->commit(); + + /** @Then the payload reflects the event's public properties */ + self::assertSame('[]', self::$connection->fetchOne('SELECT payload FROM outbox_events LIMIT 1')); + } + + public function testPushWhenCallerRollsBackThenNoRecordPersisted(): void + { + /** @Given a repository */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]) + ); + + /** @And the caller opens a transaction */ + self::$connection->beginTransaction(); + + /** @And a record is pushed inside the caller transaction */ + $repository->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ) + ])); + + /** @When the caller rolls back the transaction */ + self::$connection->rollBack(); + + /** @Then no records are persisted */ + self::assertSame(0, (int)self::$connection->fetchOne('SELECT COUNT(*) FROM outbox_events')); + } + + public function testPushWhenTwoRecordsThenBothPersisted(): void + { + /** @Given a repository */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]) + ); + + /** @When pushing two records inside a transaction */ + self::$connection->beginTransaction(); + $repository->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ), + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ) + ])); + self::$connection->commit(); + + /** @Then exactly two records are stored */ + self::assertSame(2, (int)self::$connection->fetchOne('SELECT COUNT(*) FROM outbox_events')); + } + + public function testPushWhenUuidWithNullBytesThenBytesPreservedInStorage(): void + { + /** @Given a UUID whose bytes include a leading null byte */ + $recordId = Uuid::fromBytes(bytes: "\x00\x11\x22\x33\x44\x55\x67\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff"); + + /** @And a repository */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]) + ); + + /** @When pushing a record with the null-byte UUID inside a transaction */ + self::$connection->beginTransaction(); + $repository->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced', + id: $recordId + ) + ])); + self::$connection->commit(); + + /** @Then the retrieved bytes are identical to the original UUID bytes */ + $row = self::$connection->fetchAssociative('SELECT id FROM outbox_events LIMIT 1'); + self::assertSame($recordId->getBytes(), $row['id']); + } + + public function testPushWhenSerializerReturnsInvalidJsonThenInvalidPayloadJson(): void + { + /** @Given a repository with a serializer that produces invalid JSON */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new InvalidPayloadSerializer()]) + ); + + /** @And the connection has an active transaction */ + self::$connection->beginTransaction(); + + /** @And a record to push */ + $records = EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ) + ]); + + /** @Then an exception indicating invalid JSON payload is thrown */ + $this->expectException(InvalidPayloadJson::class); + $this->expectExceptionMessage('Payload is not valid JSON: not json'); + + /** @When pushing the record */ + $repository->push(records: $records); + } + + public function testPushWhenMultipleSerializersAndSecondMatchesThenCorrectSerializerIsUsed(): void + { + /** @Given a repository with an order serializer followed by a refund serializer */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [ + new OrderPlacedSerializer(), + new RefundIssuedSerializer() + ]) + ); + + /** @When pushing a refund event inside a transaction */ + self::$connection->beginTransaction(); + $repository->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new RefundIssued(), + aggregateType: 'Refund', + eventTypeName: 'RefundIssued' + ) + ])); + self::$connection->commit(); + + /** @Then the payload is from the refund serializer */ + self::assertSame( + '{"type": "refund"}', + self::$connection->fetchOne('SELECT payload FROM outbox_events LIMIT 1') + ); + } + + public function testPushWhenNoSerializerSupportsThenPayloadSerializerNotConfigured(): void + { + /** @Given a repository with no serializers */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFromEmpty() + ); + + /** @And the connection has an active transaction */ + self::$connection->beginTransaction(); + + /** @And a record to push */ + $records = EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ) + ]); + + /** @Then an exception indicating no serializer is configured is thrown */ + $this->expectException(PayloadSerializerNotConfigured::class); + $this->expectExceptionMessage( + 'No payload serializer configured for event class .' + ); + + /** @When pushing the record */ + $repository->push(records: $records); + } + + public function testPushWhenSerializerDoesNotSupportEventThenPayloadSerializerNotConfigured(): void + { + /** @Given a repository with only an order serializer */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]) + ); + + /** @And the connection has an active transaction */ + self::$connection->beginTransaction(); + + /** @And a refund event that the order serializer does not support */ + $records = EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new RefundIssued(), + aggregateType: 'Refund', + eventTypeName: 'RefundIssued' + ) + ]); + + /** @Then an exception indicating no serializer is configured is thrown */ + $this->expectException(PayloadSerializerNotConfigured::class); + $this->expectExceptionMessage( + 'No payload serializer configured for event class .' + ); + + /** @When pushing the unsupported event */ + $repository->push(records: $records); + } + + public function testPushWhenNoSnapshotSerializerSupportsThenSnapshotSerializerNotConfigured(): void + { + /** @Given a repository with empty snapshot serializers */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]), + snapshotSerializers: SnapshotSerializers::createFromEmpty() + ); + + /** @And the connection has an active transaction */ + self::$connection->beginTransaction(); + + /** @And a record to push */ + $records = EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ) + ]); + + /** @Then an exception indicating no snapshot serializer is configured is thrown */ + $this->expectException(SnapshotSerializerNotConfigured::class); + $this->expectExceptionMessage('No snapshot serializer configured for aggregate type "Order".'); + + /** @When pushing the record */ + $repository->push(records: $records); + } + + public function testPushWhenDuplicateEventIdThenDuplicateOutboxEvent(): void + { + /** @Given a repository */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]) + ); + + /** @And a record with a fixed id */ + $records = EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ) + ]); + + /** @And the connection has an active transaction */ + self::$connection->beginTransaction(); + + /** @And the record is pushed once */ + $repository->push(records: $records); + + /** @Then an exception indicating a duplicate event is thrown */ + $this->expectException(DuplicateOutboxEvent::class); + + /** @When pushing the same record again */ + $repository->push(records: $records); + } + + public function testPushWhenDuplicateAggregateSequenceThenDuplicateAggregateSequence(): void + { + /** @Given a repository */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]) + ); + + /** @And a fixed aggregate identity and sequence number */ + $aggregateId = Uuid::uuid4()->toString(); + + /** @And the connection has an active transaction */ + self::$connection->beginTransaction(); + + /** @And a first record is pushed with that aggregate and sequence number */ + $repository->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced', + aggregateId: $aggregateId, + sequenceNumber: SequenceNumber::of(value: 1) + ) + ])); + + /** @Then an exception indicating a duplicate aggregate sequence is thrown */ + $this->expectException(DuplicateAggregateSequence::class); + $this->expectExceptionMessage( + sprintf('Duplicate aggregate sequence for at sequence number <1>.', $aggregateId) + ); + + /** @When pushing a second record with the same aggregate and sequence number but a different id */ + $repository->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced', + aggregateId: $aggregateId, + sequenceNumber: SequenceNumber::of(value: 1) + ) + ])); + } + + public function testPushWhenNoSnapshotSerializerThenReflectionSnapshotSerializerIsUsed(): void + { + /** @Given a repository without an explicit snapshot serializer */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]) + ); + + /** @When pushing a record with snapshot data inside a transaction */ + self::$connection->beginTransaction(); + $repository->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced', + snapshot: ['status' => 'placed'] + ) + ])); + self::$connection->commit(); + + /** @Then the snapshot is stored using the default reflection serializer */ + self::assertSame( + '{"status": "placed"}', + self::$connection->fetchOne('SELECT snapshot FROM outbox_events LIMIT 1') + ); + } + + public function testPushWhenCustomSnapshotSerializerThenCustomSnapshotIsUsed(): void + { + /** @Given a repository with a custom snapshot serializer */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]), + snapshotSerializers: SnapshotSerializers::createFrom(elements: [new CustomOrderSnapshotSerializer()]) + ); + + /** @When pushing a record inside a transaction */ + self::$connection->beginTransaction(); + $repository->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ) + ])); + self::$connection->commit(); + + /** @Then the snapshot uses the custom serializer output */ + self::assertSame('{"custom": true}', self::$connection->fetchOne('SELECT snapshot FROM outbox_events LIMIT 1')); + } + + public function testPushWhenExplicitReflectionSnapshotSerializerThenBehaviorMatchesDefault(): void + { + /** @Given a repository with ReflectionSnapshotSerializer explicitly provided */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]), + snapshotSerializers: SnapshotSerializers::createFrom(elements: [new SnapshotSerializerReflection()]) + ); + + /** @When pushing a record with snapshot data inside a transaction */ + self::$connection->beginTransaction(); + $repository->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced', + snapshot: ['status' => 'placed'] + ) + ])); + self::$connection->commit(); + + /** @Then the snapshot is identical to the default behavior */ + self::assertSame( + '{"status": "placed"}', + self::$connection->fetchOne('SELECT snapshot FROM outbox_events LIMIT 1') + ); + } + + public function testPushWhenCustomTableNameThenRecordStoredInCustomTable(): void + { + /** @Given a custom table layout with a different table name */ + $tableLayout = TableLayout::builder() + ->withTableName(tableName: 'custom_outbox') + ->build(); + + /** @And any pre-existing custom table is dropped */ + self::$connection->executeStatement('DROP TABLE IF EXISTS custom_outbox'); + + /** @And the custom table is registered for cleanup */ + self::registerTableForCleanup(tableName: 'custom_outbox'); + + /** @And the custom table is created */ + OutboxTableFactory::createWithBinaryIdentities( + connection: self::$connection, + tableLayout: $tableLayout + ); + + /** @And a repository using the custom layout */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]), + tableLayout: $tableLayout + ); + + /** @When pushing a record inside a transaction */ + self::$connection->beginTransaction(); + $repository->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ) + ])); + self::$connection->commit(); + + /** @Then the record is in the custom table */ + self::assertSame(1, (int)self::$connection->fetchOne('SELECT COUNT(*) FROM custom_outbox')); + + /** @And the default table remains empty */ + self::assertSame(0, (int)self::$connection->fetchOne('SELECT COUNT(*) FROM outbox_events')); + } + + public function testPushWhenStringIdentityTypeStoredThenIdIsUuidString(): void + { + /** @Given a layout with STRING identity columns */ + $tableLayout = TableLayout::builder() + ->withTableName(tableName: 'string_outbox') + ->withColumns( + columns: Columns::builder() + ->withId(name: 'id', type: IdentityColumnType::STRING) + ->withAggregateId(name: 'aggregate_id', type: IdentityColumnType::STRING) + ->build() + ) + ->build(); + + /** @And any pre-existing string outbox table is dropped */ + self::$connection->executeStatement('DROP TABLE IF EXISTS string_outbox'); + + /** @And the string outbox table is registered for cleanup */ + self::registerTableForCleanup(tableName: 'string_outbox'); + + /** @And the string outbox table is created */ + OutboxTableFactory::createWithStringIdentities( + connection: self::$connection, + tableLayout: $tableLayout + ); + + /** @And a repository using the string layout */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]), + tableLayout: $tableLayout + ); + + /** @When pushing a record inside a transaction */ + self::$connection->beginTransaction(); + $repository->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ) + ])); + self::$connection->commit(); + + /** @Then the id is stored as a 36-character UUID string */ + $row = self::$connection->fetchAssociative('SELECT id FROM string_outbox LIMIT 1'); + self::assertSame(36, strlen($row['id'])); + } + + public function testPushWhenNonUuidAggregateIdWithStringTypeThenStoredAsOriginalString(): void + { + /** @Given a layout with STRING identity columns */ + $tableLayout = TableLayout::builder() + ->withTableName(tableName: 'string_outbox') + ->withColumns( + columns: Columns::builder() + ->withId(name: 'id', type: IdentityColumnType::STRING) + ->withAggregateId(name: 'aggregate_id', type: IdentityColumnType::STRING) + ->build() + ) + ->build(); + + /** @And any pre-existing string outbox table is dropped */ + self::$connection->executeStatement('DROP TABLE IF EXISTS string_outbox'); + + /** @And the string outbox table is registered for cleanup */ + self::registerTableForCleanup(tableName: 'string_outbox'); + + /** @And the string outbox table is created */ + OutboxTableFactory::createWithStringIdentities( + connection: self::$connection, + tableLayout: $tableLayout + ); + + /** @And a repository using the string layout */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]), + tableLayout: $tableLayout + ); + + /** @And a non-UUID aggregate identity */ + $aggregateId = 'ord-1'; + + /** @When pushing a record with a non-UUID aggregate id inside a transaction */ + self::$connection->beginTransaction(); + $repository->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced', + aggregateId: $aggregateId + ) + ])); + self::$connection->commit(); + + /** @Then the aggregate_id is stored as the original string */ + self::assertSame($aggregateId, self::$connection->fetchOne('SELECT aggregate_id FROM string_outbox LIMIT 1')); + } + + public function testPushWhenSingleRecordThenAllFieldsPersistedCorrectly(): void + { + /** @Given a repository with an order placed serializer */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]) + ); + + /** @When pushing the record inside a transaction */ + self::$connection->beginTransaction(); + $repository->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced', + revision: Revision::of(value: 2), + snapshot: ['status' => 'placed'], + occurredOn: Instant::fromString(value: '2024-06-01 12:00:00.000000'), + sequenceNumber: SequenceNumber::of(value: 3) + ) + ])); + self::$connection->commit(); + + /** @Then the row is retrievable from the database */ + $row = self::$connection->fetchAssociative('SELECT * FROM outbox_events LIMIT 1'); + + /** @And the id is stored as 16-byte binary */ + self::assertSame(16, strlen($row['id'])); + + /** @And the aggregate_id is stored as 16-byte binary */ + self::assertSame(16, strlen($row['aggregate_id'])); + + /** @And the event_type is correct */ + self::assertSame('OrderPlaced', $row['event_type']); + + /** @And the revision is correct */ + self::assertSame(2, (int)$row['revision']); + + /** @And the sequence_number is correct */ + self::assertSame(3, (int)$row['sequence_number']); + + /** @And the payload matches the serializer output */ + self::assertSame('{}', $row['payload']); + + /** @And the snapshot contains the aggregate state */ + self::assertSame('{"status": "placed"}', $row['snapshot']); + + /** @And the payload and snapshot store distinct JSON objects */ + self::assertNotSame($row['payload'], $row['snapshot']); + + /** @And the aggregate_type is correct */ + self::assertSame('Order', $row['aggregate_type']); + + /** @And the occurred_at is stored */ + self::assertStringStartsWith('2024-06-01', $row['occurred_at']); + } + + public function testPushWhenKnownIdThenPersistedIdMatchesOriginal(): void + { + /** @Given a repository */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]) + ); + + /** @And a record with a known id */ + $recordId = Uuid::uuid4(); + + /** @When pushing the record inside a transaction */ + self::$connection->beginTransaction(); + $repository->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced', + id: $recordId + ) + ])); + self::$connection->commit(); + + /** @Then the persisted id bytes match the original record id */ + $row = self::$connection->fetchAssociative( + 'SELECT id FROM outbox_events WHERE id = UUID_TO_BIN(?)', + [$recordId->toString()] + ); + self::assertSame($recordId->getBytes(), $row['id']); + } + + public function testPushWhenAllColumnNamesAreCustomThenRecordStoredInCustomColumns(): void + { + /** @Given a layout with all column names customized */ + $tableLayout = TableLayout::builder() + ->withTableName(tableName: 'custom_columns_outbox') + ->withColumns( + columns: Columns::builder() + ->withId(name: 'event_id', type: IdentityColumnType::BINARY) + ->withPayload(name: 'event_payload') + ->withRevision(name: 'event_revision') + ->withSnapshot(name: 'event_snapshot') + ->withCreatedAt(name: 'event_created_at') + ->withEventType(name: 'event_event_type') + ->withOccurredAt(name: 'event_occurred_at') + ->withAggregateId(name: 'event_aggregate_id', type: IdentityColumnType::BINARY) + ->withAggregateType(name: 'event_aggregate_type') + ->withSequenceNumber(name: 'event_sequence_number') + ->build() + ) + ->build(); + + /** @And any pre-existing table is dropped */ + self::$connection->executeStatement('DROP TABLE IF EXISTS custom_columns_outbox'); + + /** @And the custom columns table is registered for cleanup */ + self::registerTableForCleanup(tableName: 'custom_columns_outbox'); + + /** @And the table is created with custom column names */ + OutboxTableFactory::createWithBinaryIdentities( + connection: self::$connection, + tableLayout: $tableLayout + ); + + /** @And a repository using this layout */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]), + tableLayout: $tableLayout + ); + + /** @When pushing a record inside a transaction */ + self::$connection->beginTransaction(); + $repository->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced', + sequenceNumber: SequenceNumber::of(value: 5) + ) + ])); + self::$connection->commit(); + + /** @Then the record is retrievable from the custom table */ + $row = self::$connection->fetchAssociative('SELECT * FROM custom_columns_outbox LIMIT 1'); + + /** @And event_id is stored as 16-byte binary */ + self::assertSame(16, strlen($row['event_id'])); + + /** @And event_aggregate_id is stored as 16-byte binary */ + self::assertSame(16, strlen($row['event_aggregate_id'])); + + /** @And event_aggregate_type is correct */ + self::assertSame('Order', $row['event_aggregate_type']); + + /** @And event_event_type is correct */ + self::assertSame('OrderPlaced', $row['event_event_type']); + + /** @And event_revision is correct */ + self::assertSame(1, (int)$row['event_revision']); + + /** @And event_sequence_number is correct */ + self::assertSame(5, (int)$row['event_sequence_number']); + + /** @And event_payload matches the serializer output */ + self::assertSame('{}', $row['event_payload']); + + /** @And event_snapshot is stored */ + self::assertNotNull($row['event_snapshot']); + + /** @And event_occurred_at is stored */ + self::assertNotNull($row['event_occurred_at']); + + /** @And event_created_at is auto-populated by the database */ + self::assertNotNull($row['event_created_at']); + } + + public function testPushWhenSnapshotSerializerReturnsInvalidJsonThenInvalidSnapshotJson(): void + { + /** @Given a repository with a snapshot serializer that produces invalid JSON */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]), + snapshotSerializers: SnapshotSerializers::createFrom(elements: [new InvalidSnapshotSerializer()]) + ); + + /** @And the connection has an active transaction */ + self::$connection->beginTransaction(); + + /** @And a record to push */ + $records = EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ) + ]); + + /** @Then an exception indicating invalid JSON snapshot is thrown */ + $this->expectException(InvalidSnapshotJson::class); + $this->expectExceptionMessage('Snapshot is not valid JSON: not json'); + + /** @When pushing the record */ + $repository->push(records: $records); + } + + public function testPushWhenEventRecordsIsEmptyThenNoInsertIsExecuted(): void + { + /** @Given a repository */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]) + ); + + /** @When pushing an empty EventRecords collection inside a transaction */ + self::$connection->beginTransaction(); + $repository->push(records: EventRecords::createFromEmpty()); + self::$connection->commit(); + + /** @Then no records are persisted */ + self::assertSame(0, (int)self::$connection->fetchOne('SELECT COUNT(*) FROM outbox_events')); + } + + public function testPushWhenRealEventualAggregateRootThenEventRecordIsPersistedCorrectly(): void + { + /** @Given a repository with a reflection payload serializer */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new PayloadSerializerReflection()]) + ); + + /** @When pushing the aggregate's recorded events inside a transaction */ + self::$connection->beginTransaction(); + $repository->push(records: Order::place(orderId: Uuid::uuid4()->toString())->recordedEvents()); + self::$connection->commit(); + + /** @Then one outbox record is persisted */ + self::assertSame(1, (int)self::$connection->fetchOne('SELECT COUNT(*) FROM outbox_events')); + + /** @And the aggregate type is Order */ + self::assertSame('Order', self::$connection->fetchOne('SELECT aggregate_type FROM outbox_events LIMIT 1')); + } + + public function testPushWhenNullTableLayoutThenSqlUsesDefaultTableName(): void + { + /** @Given a mocked connection with an active transaction */ + $connection = $this->createMock(Connection::class); + + /** @And the connection reports an active transaction */ + $connection->method('isTransactionActive')->willReturn(true); + + /** @Then the SQL statement targets the default table name */ + $connection->expects(self::once()) + ->method('executeStatement') + ->with(self::stringContains('outbox_events')) + ->willReturn(1); + + /** @When pushing a record using the default table layout */ + new DoctrineOutboxRepository( + connection: $connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]) + )->push( + records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ) + ]) + ); + } + + public function testPushWhenCustomTableLayoutThenSqlUsesCustomTableName(): void + { + /** @Given a mocked connection with an active transaction */ + $connection = $this->createMock(Connection::class); + + /** @And the connection reports an active transaction */ + $connection->method('isTransactionActive')->willReturn(true); + + /** @And a custom table layout with a distinct table name */ + $tableLayout = TableLayout::builder()->withTableName(tableName: 'custom_outbox')->build(); + + /** @Then the SQL statement targets the custom table name */ + $connection->expects(self::once()) + ->method('executeStatement') + ->with(self::stringContains('custom_outbox')) + ->willReturn(1); + + /** @When pushing a record using the custom table layout */ + new DoctrineOutboxRepository( + connection: $connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]), + tableLayout: $tableLayout + )->push( + records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ) + ]) + ); + } + + public function testPushWhenNullSnapshotSerializersThenExecuteStatementIsCalled(): void + { + /** @Given a mocked connection with an active transaction */ + $connection = $this->createMock(Connection::class); + + /** @And the connection reports an active transaction */ + $connection->method('isTransactionActive')->willReturn(true); + + /** @Then the statement is executed exactly once */ + $connection->expects(self::once())->method('executeStatement')->willReturn(1); + + /** @When pushing a record using the default snapshot serializers */ + new DoctrineOutboxRepository( + connection: $connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]) + )->push( + records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ) + ]) + ); + } + + public function testPushWhenCustomSnapshotSerializersThenExecuteStatementUsesCustomSnapshot(): void + { + /** @Given a mocked connection with an active transaction */ + $connection = $this->createMock(Connection::class); + + /** @And the connection reports an active transaction */ + $connection->method('isTransactionActive')->willReturn(true); + + /** @Then the statement is executed with the custom snapshot value */ + $connection->expects(self::once()) + ->method('executeStatement') + ->with( + self::anything(), + self::callback(static fn(array $params): bool => $params['snapshot'] === '{"custom":true}') + ) + ->willReturn(1); + + /** @When pushing a record using custom snapshot serializers */ + new DoctrineOutboxRepository( + connection: $connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]), + snapshotSerializers: SnapshotSerializers::createFrom(elements: [new CustomOrderSnapshotSerializer()]) + )->push( + records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ) + ]) + ); + } + + public function testPushWhenRecordHasAllFieldsThenExecuteStatementReceivesAllBindings(): void + { + /** @Given a mocked connection with an active transaction */ + $connection = $this->createMock(Connection::class); + + /** @And the connection reports an active transaction */ + $connection->method('isTransactionActive')->willReturn(true); + + /** @And a table layout with string identity columns */ + $tableLayout = TableLayout::builder() + ->withColumns( + columns: Columns::builder() + ->withId(name: 'id', type: IdentityColumnType::STRING) + ->withAggregateId(name: 'aggregate_id', type: IdentityColumnType::STRING) + ->build() + ) + ->build(); + + /** @And a variable to capture the parameters passed to executeStatement */ + $capturedParameters = null; + + /** @And the connection captures all parameters on executeStatement */ + $connection->expects(self::once()) + ->method('executeStatement') + ->willReturnCallback( + function (string $sql, array $params) use (&$capturedParameters): int { + $capturedParameters = $params; + return 1; + } + ); + + /** @When pushing a record with all deterministic fields */ + new DoctrineOutboxRepository( + connection: $connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]), + tableLayout: $tableLayout + )->push( + records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced', + id: Uuid::fromString('550e8400-e29b-41d4-a716-446655440000'), + occurredOn: Instant::fromString(value: '2021-01-01T00:00:00+00:00'), + aggregateId: '6ba7b810-9dad-11d1-80b4-00c04fd430c8' + ) + ]) + ); + + /** @Then executeStatement receives all nine expected parameter bindings */ + self::assertSame( + [ + 'id' => '550e8400-e29b-41d4-a716-446655440000', + 'aggregateId' => '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + 'aggregateType' => 'Order', + 'eventType' => 'OrderPlaced', + 'revision' => 1, + 'sequenceNumber' => 1, + 'payload' => '{}', + 'snapshot' => '[]', + 'occurredAt' => '2021-01-01T00:00:00+00:00' + ], + $capturedParameters + ); + } + + public function testPushWhenUniqueConstraintOnSequenceNumberThenDuplicateAggregateSequenceIsThrown(): void + { + /** @Given a mocked connection with an active transaction */ + $connection = self::createConfiguredStub(Connection::class, ['isTransactionActive' => true]); + + /** @And the connection raises a sequence number constraint violation */ + $connection->method('executeStatement')->willThrowException( + new UniqueConstraintViolationException( + new DriverExceptionStub('Duplicate entry for key uniq_aggregate_sequence'), + null + ) + ); + + /** @Then a duplicate aggregate sequence exception is thrown */ + $this->expectException(DuplicateAggregateSequence::class); + $this->expectExceptionMessage( + 'Duplicate aggregate sequence for at sequence number <1>.' + ); + + /** @When pushing the record */ + new DoctrineOutboxRepository( + connection: $connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]) + )->push( + records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced', + aggregateId: '6ba7b810-9dad-11d1-80b4-00c04fd430c8' + ) + ]) + ); + } + + public function testPushWhenUniqueConstraintOnEventIdThenDuplicateOutboxEventIsThrown(): void + { + /** @Given a mocked connection with an active transaction */ + $connection = self::createConfiguredStub(Connection::class, ['isTransactionActive' => true]); + + /** @And the connection raises a duplicate event id violation */ + $connection->method('executeStatement')->willThrowException( + new UniqueConstraintViolationException( + new DriverExceptionStub('Duplicate entry for key PRIMARY'), + null + ) + ); + + /** @Then a duplicate outbox event exception is thrown */ + $this->expectException(DuplicateOutboxEvent::class); + $this->expectExceptionMessageMatches('/Event with id ".+" already exists in outbox\./'); + + /** @When pushing the record */ + new DoctrineOutboxRepository( + connection: $connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]) + )->push( + records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ) + ]) + ); + } + + public function testPushWhenUniqueConstraintWithCustomNameThenDuplicateAggregateSequenceIsThrown(): void + { + /** @Given a mocked connection with an active transaction */ + $connection = self::createConfiguredStub(Connection::class, ['isTransactionActive' => true]); + + /** @And a custom table layout with a distinct unique constraint name */ + $tableLayout = TableLayout::builder() + ->withUniqueConstraint(name: 'uniq_custom_outbox_sequence') + ->build(); + + /** @And the connection raises a violation on the custom constraint name */ + $connection->method('executeStatement')->willThrowException( + new UniqueConstraintViolationException( + new DriverExceptionStub('Duplicate entry for key uniq_custom_outbox_sequence'), + null + ) + ); + + /** @Then a duplicate aggregate sequence exception is thrown */ + $this->expectException(DuplicateAggregateSequence::class); + + /** @When pushing a record with the custom table layout */ + new DoctrineOutboxRepository( + connection: $connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]), + tableLayout: $tableLayout + )->push( + records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ) + ]) + ); + } + + public function testPushWhenConstraintNameIsCustomThenDuplicateAggregateSequence(): void + { + /** @Given a custom table layout with a distinct unique constraint name */ + $tableLayout = TableLayout::builder() + ->withTableName(tableName: 'custom_constraint_outbox') + ->withUniqueConstraint(name: 'uniq_custom_outbox_sequence') + ->build(); + + /** @And any pre-existing table is dropped */ + self::$connection->executeStatement('DROP TABLE IF EXISTS custom_constraint_outbox'); + + /** @And the table is registered for cleanup */ + self::registerTableForCleanup(tableName: 'custom_constraint_outbox'); + + /** @And the table is created with the custom constraint name */ + OutboxTableFactory::createWithBinaryIdentities( + connection: self::$connection, + tableLayout: $tableLayout + ); + + /** @And a repository using the custom table layout */ + $repository = new DoctrineOutboxRepository( + connection: self::$connection, + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]), + tableLayout: $tableLayout + ); + + /** @And a fixed aggregate identity */ + $aggregateId = Uuid::uuid4()->toString(); + + /** @And the connection has an active transaction */ + self::$connection->beginTransaction(); + + /** @And a first record is pushed with that aggregate and sequence number */ + $repository->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced', + aggregateId: $aggregateId, + sequenceNumber: SequenceNumber::of(value: 1) + ) + ])); + + /** @Then an exception indicating a duplicate aggregate sequence is thrown */ + $this->expectException(DuplicateAggregateSequence::class); + + /** @When pushing a second record with the same aggregate and sequence number */ + $repository->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced', + aggregateId: $aggregateId, + sequenceNumber: SequenceNumber::of(value: 1) + ) + ])); + } +} diff --git a/tests/InMemoryOutboxRepositoryTest.php b/tests/InMemoryOutboxRepositoryTest.php new file mode 100644 index 0000000..7fa94f4 --- /dev/null +++ b/tests/InMemoryOutboxRepositoryTest.php @@ -0,0 +1,330 @@ +beginTransaction(); + + /** @When a single valid event record is pushed */ + $outbox->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ) + ])); + + /** @And the transaction is committed */ + $outbox->commit(); + + /** @Then one record is persisted in the repository */ + self::assertCount(1, $outbox->persistedRecords()); + } + + public function testPushWhenMultipleRecordsThenAllArePersistedInOrder(): void + { + /** @Given an in-memory repository with configured serializers */ + $outbox = new InMemoryOutboxRepositoryMock( + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]), + snapshotSerializers: SnapshotSerializers::createFrom(elements: [new SnapshotSerializerReflection()]) + ); + + /** @And a transaction is started */ + $outbox->beginTransaction(); + + /** @When two event records are pushed in a single call */ + $outbox->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced', + sequenceNumber: SequenceNumber::of(value: 1) + ), + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced', + sequenceNumber: SequenceNumber::of(value: 2) + ) + ])); + + /** @And the transaction is committed */ + $outbox->commit(); + + /** @Then both records are persisted in the repository */ + self::assertCount(2, $outbox->persistedRecords()); + } + + public function testPushWhenNoTransactionIsActiveThenOutboxRequiresActiveTransaction(): void + { + /** @Given an in-memory repository without an active transaction */ + $outbox = new InMemoryOutboxRepositoryMock( + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]), + snapshotSerializers: SnapshotSerializers::createFrom(elements: [new SnapshotSerializerReflection()]) + ); + + /** @Then an exception requiring an active transaction is thrown */ + $this->expectException(OutboxRequiresActiveTransaction::class); + + /** @When pushing a record without starting a transaction */ + $outbox->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ) + ])); + } + + public function testPushWhenNoPayloadSerializerMatchesThenPayloadSerializerNotConfigured(): void + { + /** @Given an in-memory repository with no payload serializers configured */ + $outbox = new InMemoryOutboxRepositoryMock( + payloadSerializers: PayloadSerializers::createFromEmpty(), + snapshotSerializers: SnapshotSerializers::createFrom(elements: [new SnapshotSerializerReflection()]) + ); + + /** @And a transaction is started */ + $outbox->beginTransaction(); + + /** @Then an exception indicating no configured payload serializer is thrown */ + $this->expectException(PayloadSerializerNotConfigured::class); + + /** @When pushing a record whose event type has no matching serializer */ + $outbox->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ) + ])); + } + + public function testPushWhenNoSnapshotSerializerMatchesThenSnapshotSerializerNotConfigured(): void + { + /** @Given an in-memory repository with no snapshot serializers configured */ + $outbox = new InMemoryOutboxRepositoryMock( + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]), + snapshotSerializers: SnapshotSerializers::createFromEmpty() + ); + + /** @And a transaction is started */ + $outbox->beginTransaction(); + + /** @Then an exception indicating no configured snapshot serializer is thrown */ + $this->expectException(SnapshotSerializerNotConfigured::class); + + /** @When pushing a record whose aggregate type has no matching snapshot serializer */ + $outbox->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ) + ])); + } + + public function testPushWhenPayloadSerializerReturnsInvalidJsonThenInvalidPayloadJson(): void + { + /** @Given an in-memory repository with a serializer that produces invalid JSON */ + $outbox = new InMemoryOutboxRepositoryMock( + payloadSerializers: PayloadSerializers::createFrom(elements: [new InvalidPayloadSerializer()]), + snapshotSerializers: SnapshotSerializers::createFrom(elements: [new SnapshotSerializerReflection()]) + ); + + /** @And a transaction is started */ + $outbox->beginTransaction(); + + /** @Then an exception indicating invalid payload JSON is thrown */ + $this->expectException(InvalidPayloadJson::class); + + /** @When pushing a record whose serializer produces malformed JSON */ + $outbox->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ) + ])); + } + + public function testPushWhenSnapshotSerializerReturnsInvalidJsonThenInvalidSnapshotJson(): void + { + /** @Given an in-memory repository with a snapshot serializer that produces invalid JSON */ + $outbox = new InMemoryOutboxRepositoryMock( + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]), + snapshotSerializers: SnapshotSerializers::createFrom(elements: [new InvalidSnapshotSerializer()]) + ); + + /** @And a transaction is started */ + $outbox->beginTransaction(); + + /** @Then an exception indicating invalid snapshot JSON is thrown */ + $this->expectException(InvalidSnapshotJson::class); + + /** @When pushing a record whose snapshot serializer produces malformed JSON */ + $outbox->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced' + ) + ])); + } + + public function testPushWhenTwoRecordsShareTheSameIdThenDuplicateOutboxEvent(): void + { + /** @Given an in-memory repository with configured serializers */ + $outbox = new InMemoryOutboxRepositoryMock( + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]), + snapshotSerializers: SnapshotSerializers::createFrom(elements: [new SnapshotSerializerReflection()]) + ); + + /** @And a transaction is started */ + $outbox->beginTransaction(); + + /** @And a fixed event id shared by both records */ + $eventId = Uuid::uuid4(); + + /** @And a first record with the fixed id is pushed */ + $outbox->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced', + id: $eventId + ) + ])); + + /** @Then a duplicate outbox event exception is thrown */ + $this->expectException(DuplicateOutboxEvent::class); + + /** @When a second record with the same id is pushed */ + $outbox->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced', + id: $eventId, + sequenceNumber: SequenceNumber::of(value: 2) + ) + ])); + } + + public function testPushWhenTwoRecordsShareTheSameAggregateSequenceThenDuplicateAggregateSequence(): void + { + /** @Given an in-memory repository with configured serializers */ + $outbox = new InMemoryOutboxRepositoryMock( + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]), + snapshotSerializers: SnapshotSerializers::createFrom(elements: [new SnapshotSerializerReflection()]) + ); + + /** @And a transaction is started */ + $outbox->beginTransaction(); + + /** @And a fixed aggregate identity shared by both records */ + $aggregateId = Uuid::uuid4()->toString(); + + /** @And a first record with the fixed aggregate and sequence number 1 is pushed */ + $outbox->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced', + aggregateId: $aggregateId, + sequenceNumber: SequenceNumber::of(value: 1) + ) + ])); + + /** @Then a duplicate aggregate sequence exception is thrown */ + $this->expectException(DuplicateAggregateSequence::class); + + /** @When a second record with the same aggregate and sequence number is pushed */ + $outbox->push(records: EventRecords::createFrom(elements: [ + EventRecordFactory::create( + event: new OrderPlaced(), + aggregateType: 'Order', + eventTypeName: 'OrderPlaced', + aggregateId: $aggregateId, + sequenceNumber: SequenceNumber::of(value: 1) + ) + ])); + } + + public function testPushWhenEventRecordsIsEmptyThenNoRecordIsPersisted(): void + { + /** @Given an in-memory repository with configured serializers */ + $outbox = new InMemoryOutboxRepositoryMock( + payloadSerializers: PayloadSerializers::createFrom(elements: [new OrderPlacedSerializer()]), + snapshotSerializers: SnapshotSerializers::createFrom(elements: [new SnapshotSerializerReflection()]) + ); + + /** @And a transaction is started */ + $outbox->beginTransaction(); + + /** @When an empty EventRecords collection is pushed */ + $outbox->push(records: EventRecords::createFromEmpty()); + + /** @And the transaction is committed */ + $outbox->commit(); + + /** @Then no records are persisted in the repository */ + self::assertCount(0, $outbox->persistedRecords()); + } + + public function testPushWhenRealEventualAggregateRootThenEventRecordIsPersisted(): void + { + /** @Given an in-memory repository with a reflection payload serializer */ + $outbox = new InMemoryOutboxRepositoryMock( + payloadSerializers: PayloadSerializers::createFrom(elements: [new PayloadSerializerReflection()]), + snapshotSerializers: SnapshotSerializers::createFrom(elements: [new SnapshotSerializerReflection()]) + ); + + /** @And a transaction is started */ + $outbox->beginTransaction(); + + /** @When the aggregate's recorded events are pushed */ + $outbox->push(records: Order::place(orderId: Uuid::uuid4()->toString())->recordedEvents()); + + /** @And the transaction is committed */ + $outbox->commit(); + + /** @Then one event record is persisted in the repository */ + self::assertCount(1, $outbox->persistedRecords()); + } +} diff --git a/tests/IntegrationTestCase.php b/tests/IntegrationTestCase.php new file mode 100644 index 0000000..cf1a8ab --- /dev/null +++ b/tests/IntegrationTestCase.php @@ -0,0 +1,37 @@ +connection(); + } + + protected static function registerTableForCleanup(string $tableName): void + { + self::$customTables[] = $tableName; + } + + protected function tearDown(): void + { + if (self::$connection->isTransactionActive()) { + self::$connection->rollBack(); + } + + foreach (self::$customTables as $tableName) { + self::$connection->executeStatement(sprintf('DROP TABLE IF EXISTS %s', $tableName)); + } + + self::$customTables = []; + } +} diff --git a/tests/Mocks/CustomOrderSnapshotSerializer.php b/tests/Mocks/CustomOrderSnapshotSerializer.php new file mode 100644 index 0000000..c76b11f --- /dev/null +++ b/tests/Mocks/CustomOrderSnapshotSerializer.php @@ -0,0 +1,22 @@ +event instanceof OrderPlaced; + } + + public function serialize(EventRecord $record): SerializedPayload + { + return SerializedPayload::from(payload: '{"fallback":true}'); + } +} diff --git a/tests/Mocks/InMemoryOutboxRepositoryMock.php b/tests/Mocks/InMemoryOutboxRepositoryMock.php new file mode 100644 index 0000000..d33f3b3 --- /dev/null +++ b/tests/Mocks/InMemoryOutboxRepositoryMock.php @@ -0,0 +1,106 @@ +transactionActive = true; + } + + public function commit(): void + { + $this->transactionActive = false; + } + + public function persistedRecords(): array + { + return $this->records; + } + + public function rollback(): void + { + $this->transactionActive = false; + $this->records = []; + $this->aggregateSequences = []; + } + + public function push(EventRecords $records): void + { + if (!$this->transactionActive) { + throw OutboxRequiresActiveTransaction::asMissing(); + } + + $records->each(actions: function (EventRecord $record): void { + $payloadSerializer = $this->payloadSerializers->findFor(record: $record); + + if (is_null($payloadSerializer)) { + throw new PayloadSerializerNotConfigured(eventClass: $record->event::class); + } + + $snapshotSerializer = $this->snapshotSerializers->findFor(record: $record); + + if (is_null($snapshotSerializer)) { + throw SnapshotSerializerNotConfigured::for(aggregateType: $record->aggregateType); + } + + $payloadSerializer->serialize(record: $record); + $snapshotSerializer->serialize(record: $record); + + $aggregateKey = sprintf( + '%s|%s|%d', + $record->aggregateType, + $record->identity->identityValue(), + $record->sequenceNumber->value + ); + + if (isset($this->aggregateSequences[$aggregateKey])) { + throw new DuplicateAggregateSequence( + aggregateId: (string)$record->identity->identityValue(), + aggregateType: $record->aggregateType, + sequenceNumber: $record->sequenceNumber->value + ); + } + + $eventId = (string)$record->id; + + if (isset($this->records[$eventId])) { + throw DuplicateOutboxEvent::forRecord( + eventId: $record->id, + previous: new UniqueConstraintViolationException( + new DriverExceptionStub('Duplicate entry for key PRIMARY'), + null + ) + ); + } + + $this->aggregateSequences[$aggregateKey] = true; + $this->records[$eventId] = $record; + }); + } +} diff --git a/tests/Mocks/InvalidPayloadSerializer.php b/tests/Mocks/InvalidPayloadSerializer.php new file mode 100644 index 0000000..14ca476 --- /dev/null +++ b/tests/Mocks/InvalidPayloadSerializer.php @@ -0,0 +1,23 @@ +event instanceof OrderPlaced; + } + + public function serialize(EventRecord $record): SerializedPayload + { + return SerializedPayload::from(payload: 'not json'); + } +} diff --git a/tests/Mocks/InvalidSnapshotSerializer.php b/tests/Mocks/InvalidSnapshotSerializer.php new file mode 100644 index 0000000..8f7315f --- /dev/null +++ b/tests/Mocks/InvalidSnapshotSerializer.php @@ -0,0 +1,22 @@ +event instanceof OrderPlaced; + } + + public function serialize(EventRecord $record): SerializedPayload + { + return SerializedPayload::from(payload: '{}'); + } +} diff --git a/tests/Mocks/RefundIssuedSerializer.php b/tests/Mocks/RefundIssuedSerializer.php new file mode 100644 index 0000000..525cef3 --- /dev/null +++ b/tests/Mocks/RefundIssuedSerializer.php @@ -0,0 +1,23 @@ +event instanceof RefundIssued; + } + + public function serialize(EventRecord $record): SerializedPayload + { + return SerializedPayload::from(payload: '{"type":"refund"}'); + } +} diff --git a/tests/Models/EventRecordFactory.php b/tests/Models/EventRecordFactory.php new file mode 100644 index 0000000..6c37b83 --- /dev/null +++ b/tests/Models/EventRecordFactory.php @@ -0,0 +1,46 @@ +toString()), + revision: $revision ?? Revision::initial(), + occurredOn: $occurredOn ?? Instant::now(), + snapshotData: new SnapshotData(payload: $snapshot ?? []), + aggregateType: $aggregateType, + sequenceNumber: $sequenceNumber ?? SequenceNumber::first() + ); + } +} diff --git a/tests/Models/Order.php b/tests/Models/Order.php new file mode 100644 index 0000000..4046e23 --- /dev/null +++ b/tests/Models/Order.php @@ -0,0 +1,29 @@ +push(event: new OrderPlaced()); + return $order; + } + + protected function snapshotState(): array + { + return ['status' => 'placed']; + } +} diff --git a/tests/Models/OrderId.php b/tests/Models/OrderId.php new file mode 100644 index 0000000..eaad4f3 --- /dev/null +++ b/tests/Models/OrderId.php @@ -0,0 +1,17 @@ +executeStatement( + sprintf( + $template, + $tableLayout->tableName, + $tableLayout->columns->id->name, + $tableLayout->columns->aggregateType, + $tableLayout->columns->aggregateId->name, + $tableLayout->columns->eventType, + $tableLayout->columns->revision, + $tableLayout->columns->sequenceNumber, + $tableLayout->columns->payload, + $tableLayout->columns->snapshot, + $tableLayout->columns->occurredAt, + $tableLayout->columns->createdAt, + $tableLayout->columns->aggregateId->name, + $tableLayout->uniqueConstraint->name, + $tableLayout->columns->aggregateType, + $tableLayout->columns->aggregateId->name, + $tableLayout->columns->sequenceNumber + ) + ); + } + + public static function createWithStringIdentities(Connection $connection, TableLayout $tableLayout): void + { + $template = <<executeStatement( + sprintf( + $template, + $tableLayout->tableName, + $tableLayout->columns->id->name, + $tableLayout->columns->aggregateType, + $tableLayout->columns->aggregateId->name, + $tableLayout->columns->eventType, + $tableLayout->columns->revision, + $tableLayout->columns->sequenceNumber, + $tableLayout->columns->payload, + $tableLayout->columns->snapshot, + $tableLayout->columns->occurredAt, + $tableLayout->columns->createdAt, + $tableLayout->columns->aggregateId->name, + $tableLayout->uniqueConstraint->name, + $tableLayout->columns->aggregateType, + $tableLayout->columns->aggregateId->name, + $tableLayout->columns->sequenceNumber + ) + ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..8a12f71 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,9 @@ +start();