|
| 1 | +# CLAUDE.md |
| 2 | + |
| 3 | +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +Silverstripe module that generates YAML fixture files from existing DataObjects in the database. Intended as a developer tool for creating test fixtures and for use with the [Populate](https://github.com/silverstripe/silverstripe-populate) module. Requires Silverstripe CMS 5 and PHP 8.1+. |
| 8 | + |
| 9 | +## Commands |
| 10 | + |
| 11 | +```bash |
| 12 | +# Lint (PHP_CodeSniffer with PSR-2 + Slevomat rules) |
| 13 | +vendor/bin/phpcs |
| 14 | + |
| 15 | +# Auto-fix lint issues |
| 16 | +vendor/bin/phpcbf |
| 17 | + |
| 18 | +# Run tests (must run inside DDEV for database access) |
| 19 | +ddev exec vendor/bin/phpunit vendor/chrispenny/silverstripe-data-object-to-fixture/tests/ |
| 20 | +``` |
| 21 | + |
| 22 | +Note: This module lives inside a larger Silverstripe project. Run composer commands from the project root, not from within this package directory. |
| 23 | + |
| 24 | +## Architecture |
| 25 | + |
| 26 | +**FixtureService** (`src/Service/FixtureService.php`) is the public API. It accepts DataObjects via `addDataObject()`, recursively walks all relationships (has_one, has_many, many_many, many_many through), and outputs a sorted YAML fixture via `outputFixture()`. |
| 27 | + |
| 28 | +The processing is stack-based (not recursive): `addDataObject()` pushes related DataObjects onto `$dataObjectStack` and processes them in a loop, preventing deep call stacks. |
| 29 | + |
| 30 | +**Two manifests** track state during processing: |
| 31 | +- **FixtureManifest** — maps class names to `Group` objects, each containing `Record` objects (the fixture data for individual DataObjects) |
| 32 | +- **RelationshipManifest** — tracks class-to-class dependency edges and exclusions. Uses `KahnSorter` (Kahn's topological sort) to order groups so dependencies appear before dependents in the output YAML |
| 33 | + |
| 34 | +**ORM layer** (`src/ORM/`): |
| 35 | +- `Group` — represents a single class in the fixture (keyed by FQCN, contains Records) |
| 36 | +- `Record` — represents a single DataObject instance (keyed by ID, contains field key/value pairs) |
| 37 | + |
| 38 | +**Configuration hooks** (via Silverstripe YAML config): |
| 39 | +- `exclude_from_fixture_relationships: 1` on a class — omits it entirely from traversal |
| 40 | +- `excluded_fixture_relationships` array on a class — omits specific relationship names |
| 41 | +- `field_classname_map` on a class — maps polymorphic `has_one` ID fields to the field storing the actual class name (required for `DataObject::class` relationships) |
| 42 | + |
| 43 | +**Dev task** (`src/Task/GenerateFixtureFromDataObject.php`) — web UI at `/dev/tasks/generate-fixture-from-dataobject` for selecting a DataObject class and record, then viewing the generated fixture. |
| 44 | + |
| 45 | +## Coding Standards |
| 46 | + |
| 47 | +PSR-2 base with Slevomat rules. Key exceptions to be aware of: |
| 48 | +- Method names can be PascalCase or snake_case (Silverstripe convention) |
| 49 | +- `private static` properties are used for Silverstripe config (don't flag as unused) |
| 50 | +- Late static binding is allowed |
| 51 | +- `new Class()` with parentheses (not `new Class`) |
| 52 | +- Null-safe operators (`?->`) are enforced |
| 53 | + |
| 54 | +## Testing |
| 55 | + |
| 56 | +### Setup |
| 57 | + |
| 58 | +Tests use **PHPUnit** via Silverstripe's `SapphireTest` base class, bootstrapped through `vendor/silverstripe/framework/tests/bootstrap.php` (configured in `phpunit.xml.dist`). Run tests from the project root: |
| 59 | + |
| 60 | +```bash |
| 61 | +vendor/bin/phpunit |
| 62 | +``` |
| 63 | + |
| 64 | +### Test directory structure |
| 65 | + |
| 66 | +``` |
| 67 | +tests/ |
| 68 | +├── Helper/ # Unit tests for helper classes |
| 69 | +├── Manifest/ # Unit tests for manifest classes |
| 70 | +├── ORM/ # Unit tests for ORM value objects |
| 71 | +├── Service/ # Integration tests for FixtureService |
| 72 | +│ └── *.yml # YAML fixture files alongside test classes |
| 73 | +└── Mocks/ # Test-only DataObject classes |
| 74 | + ├── Models/ # Mock DataObjects (has_one targets, children, etc.) |
| 75 | + ├── Pages/ # Mock Page subclasses (top-level test subjects) |
| 76 | + └── Relations/ # Mock junction/through tables for many_many |
| 77 | +``` |
| 78 | + |
| 79 | +Test class namespace mirrors `src/`: `ChrisPenny\DataObjectToFixture\Tests\{subdirectory}`. |
| 80 | + |
| 81 | +### Writing tests |
| 82 | + |
| 83 | +All test classes extend `SapphireTest`: |
| 84 | + |
| 85 | +```php |
| 86 | +use SilverStripe\Dev\SapphireTest; |
| 87 | + |
| 88 | +class FixtureServiceTest extends SapphireTest |
| 89 | +{ |
| 90 | + protected static $fixture_file = 'FixtureServiceTest.yml'; |
| 91 | + |
| 92 | + protected static $extra_dataobjects = [ |
| 93 | + MockPage::class, |
| 94 | + MockChild::class, |
| 95 | + ]; |
| 96 | +} |
| 97 | +``` |
| 98 | + |
| 99 | +Key conventions: |
| 100 | +- **`$extra_dataobjects`** — register mock DataObject classes so SapphireTest builds their tables in the test database |
| 101 | +- **`$fixture_file`** — path to a YAML fixture file (relative to the test class file) that populates the test database before each test |
| 102 | +- **`$usesDatabase = true`** — use instead of `$fixture_file` when you need a database but want to create records programmatically |
| 103 | +- **`objFromFixture(ClassName::class, 'identifier')`** — retrieve a record loaded from the fixture file |
| 104 | + |
| 105 | +### Mock DataObject classes |
| 106 | + |
| 107 | +Place mock classes in `tests/Mocks/`. Every mock must implement `TestOnly` and declare a unique `$table_name`: |
| 108 | + |
| 109 | +```php |
| 110 | +namespace ChrisPenny\DataObjectToFixture\Tests\Mocks\Pages; |
| 111 | + |
| 112 | +use SilverStripe\Dev\TestOnly; |
| 113 | +use SilverStripe\ORM\DataObject; |
| 114 | + |
| 115 | +class MockPage extends DataObject implements TestOnly |
| 116 | +{ |
| 117 | + private static string $table_name = 'DOToFixture_MockPage'; |
| 118 | + |
| 119 | + private static array $db = [ |
| 120 | + 'Title' => 'Varchar', |
| 121 | + ]; |
| 122 | + |
| 123 | + private static array $has_many = [ |
| 124 | + 'Children' => MockChild::class, |
| 125 | + ]; |
| 126 | +} |
| 127 | +``` |
| 128 | + |
| 129 | +Prefix table names with `DOToFixture_` to avoid collisions with other modules in the test database. |
| 130 | + |
| 131 | +After adding new mock DataObject classes, flush the Silverstripe manifest cache by appending `flush=1` to the test command: |
| 132 | + |
| 133 | +```bash |
| 134 | +ddev exec vendor/bin/phpunit vendor/chrispenny/silverstripe-data-object-to-fixture/tests/ '' flush=1 |
| 135 | +``` |
| 136 | + |
| 137 | +### Fixture files (YAML) |
| 138 | + |
| 139 | +Fixture files use fully-qualified class names as keys and `=>ClassName.identifier` syntax for relationships: |
| 140 | + |
| 141 | +```yaml |
| 142 | +ChrisPenny\DataObjectToFixture\Tests\Mocks\Pages\MockPage: |
| 143 | + page1: |
| 144 | + Title: Page 1 |
| 145 | + Children: |
| 146 | + - =>ChrisPenny\DataObjectToFixture\Tests\Mocks\Models\MockChild.child1 |
| 147 | + |
| 148 | +ChrisPenny\DataObjectToFixture\Tests\Mocks\Models\MockChild: |
| 149 | + child1: |
| 150 | + Title: Child 1 |
| 151 | +``` |
| 152 | +
|
| 153 | +### Test categories |
| 154 | +
|
| 155 | +- **Pure unit tests** (Record, Group, FixtureManifest, KahnSorter) — test logic in isolation, may not need a database at all |
| 156 | +- **Integration tests** (FixtureService, RelationshipManifest) — need mock DataObjects in the DB to exercise relationship traversal, config exclusions, and YAML output |
| 157 | +- **`GenerateFixtureFromDataObject`** is marked `@codeCoverageIgnore` — skip it |
| 158 | + |
| 159 | +### Testing private methods |
| 160 | + |
| 161 | +Use Reflection when a private method contains logic worth testing directly: |
| 162 | + |
| 163 | +```php |
| 164 | +$reflectionClass = new ReflectionClass(FixtureService::class); |
| 165 | +$method = $reflectionClass->getMethod('addDataObjectDbFields'); |
| 166 | +$method->setAccessible(true); |
| 167 | +$method->invoke($service, $dataObject); |
| 168 | +``` |
| 169 | + |
| 170 | +Prefer testing through public methods when practical. Only reach for Reflection when the private method has complex branching that's hard to exercise through the public API alone. |
0 commit comments