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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Overview

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+.

## Commands

```bash
# Lint (PHP_CodeSniffer with PSR-2 + Slevomat rules)
vendor/bin/phpcs

# Auto-fix lint issues
vendor/bin/phpcbf

# Run tests (must run inside DDEV for database access)
ddev exec vendor/bin/phpunit vendor/chrispenny/silverstripe-data-object-to-fixture/tests/
```

Note: This module lives inside a larger Silverstripe project. Run composer commands from the project root, not from within this package directory.

## Architecture

**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()`.

The processing is stack-based (not recursive): `addDataObject()` pushes related DataObjects onto `$dataObjectStack` and processes them in a loop, preventing deep call stacks.

**Two manifests** track state during processing:
- **FixtureManifest** — maps class names to `Group` objects, each containing `Record` objects (the fixture data for individual DataObjects)
- **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

**ORM layer** (`src/ORM/`):
- `Group` — represents a single class in the fixture (keyed by FQCN, contains Records)
- `Record` — represents a single DataObject instance (keyed by ID, contains field key/value pairs)

**Configuration hooks** (via Silverstripe YAML config):
- `exclude_from_fixture_relationships: 1` on a class — omits it entirely from traversal
- `excluded_fixture_relationships` array on a class — omits specific relationship names
- `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)

**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.

## Coding Standards

PSR-2 base with Slevomat rules. Key exceptions to be aware of:
- Method names can be PascalCase or snake_case (Silverstripe convention)
- `private static` properties are used for Silverstripe config (don't flag as unused)
- Late static binding is allowed
- `new Class()` with parentheses (not `new Class`)
- Null-safe operators (`?->`) are enforced

## Testing

### Setup

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:

```bash
vendor/bin/phpunit
```

### Test directory structure

```
tests/
├── Helper/ # Unit tests for helper classes
├── Manifest/ # Unit tests for manifest classes
├── ORM/ # Unit tests for ORM value objects
├── Service/ # Integration tests for FixtureService
│ └── *.yml # YAML fixture files alongside test classes
└── Mocks/ # Test-only DataObject classes
├── Models/ # Mock DataObjects (has_one targets, children, etc.)
├── Pages/ # Mock Page subclasses (top-level test subjects)
└── Relations/ # Mock junction/through tables for many_many
```

Test class namespace mirrors `src/`: `ChrisPenny\DataObjectToFixture\Tests\{subdirectory}`.

### Writing tests

All test classes extend `SapphireTest`:

```php
use SilverStripe\Dev\SapphireTest;

class FixtureServiceTest extends SapphireTest
{
protected static $fixture_file = 'FixtureServiceTest.yml';

protected static $extra_dataobjects = [
MockPage::class,
MockChild::class,
];
}
```

Key conventions:
- **`$extra_dataobjects`** — register mock DataObject classes so SapphireTest builds their tables in the test database
- **`$fixture_file`** — path to a YAML fixture file (relative to the test class file) that populates the test database before each test
- **`$usesDatabase = true`** — use instead of `$fixture_file` when you need a database but want to create records programmatically
- **`objFromFixture(ClassName::class, 'identifier')`** — retrieve a record loaded from the fixture file

### Mock DataObject classes

Place mock classes in `tests/Mocks/`. Every mock must implement `TestOnly` and declare a unique `$table_name`:

```php
namespace ChrisPenny\DataObjectToFixture\Tests\Mocks\Pages;

use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;

class MockPage extends DataObject implements TestOnly
{
private static string $table_name = 'DOToFixture_MockPage';

private static array $db = [
'Title' => 'Varchar',
];

private static array $has_many = [
'Children' => MockChild::class,
];
}
```

Prefix table names with `DOToFixture_` to avoid collisions with other modules in the test database.

After adding new mock DataObject classes, flush the Silverstripe manifest cache by appending `flush=1` to the test command:

```bash
ddev exec vendor/bin/phpunit vendor/chrispenny/silverstripe-data-object-to-fixture/tests/ '' flush=1
```

### Fixture files (YAML)

Fixture files use fully-qualified class names as keys and `=>ClassName.identifier` syntax for relationships:

```yaml
ChrisPenny\DataObjectToFixture\Tests\Mocks\Pages\MockPage:
page1:
Title: Page 1
Children:
- =>ChrisPenny\DataObjectToFixture\Tests\Mocks\Models\MockChild.child1

ChrisPenny\DataObjectToFixture\Tests\Mocks\Models\MockChild:
child1:
Title: Child 1
```

### Test categories

- **Pure unit tests** (Record, Group, FixtureManifest, KahnSorter) — test logic in isolation, may not need a database at all
- **Integration tests** (FixtureService, RelationshipManifest) — need mock DataObjects in the DB to exercise relationship traversal, config exclusions, and YAML output
- **`GenerateFixtureFromDataObject`** is marked `@codeCoverageIgnore` — skip it

### Testing private methods

Use Reflection when a private method contains logic worth testing directly:

```php
$reflectionClass = new ReflectionClass(FixtureService::class);
$method = $reflectionClass->getMethod('addDataObjectDbFields');
$method->setAccessible(true);
$method->invoke($service, $dataObject);
```

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.
160 changes: 160 additions & 0 deletions tests/Helper/KahnSorterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,164 @@ public function testEmptySort(): void
$this->assertEquals([], $results);
}

public function testCircularDependency(): void
{
$items = [
'A' => ['B'],
'B' => ['A'],
];

$sorter = new KahnSorter();
$results = $sorter->process($items);

// Both nodes should still be in the output
$this->assertContains('A', $results);
$this->assertContains('B', $results);

// Both nodes have 1 left over dependency due to the circular reference
$this->assertEqualsCanonicalizing(
[
'Node `A` has `1` left over dependencies, and so could not be sorted',
'Node `B` has `1` left over dependencies, and so could not be sorted',
],
$sorter->getWarnings()
);
}

public function testSingleNode(): void
{
$items = [
'A' => [],
];

$sorter = new KahnSorter();
$results = $sorter->process($items);

$this->assertSame(['A'], $results);
$this->assertEmpty($sorter->getWarnings());
}

public function testConvergentDependencies(): void
{
// Both A and B depend on C
$items = [
'A' => ['C'],
'B' => ['C'],
'C' => [],
];

$sorter = new KahnSorter();
$results = $sorter->process($items);

// C must appear before both A and B; the relative order of A and B doesn't matter
$cIndex = array_search('C', $results, true);
$aIndex = array_search('A', $results, true);
$bIndex = array_search('B', $results, true);

$this->assertLessThan($aIndex, $cIndex);
$this->assertLessThan($bIndex, $cIndex);
$this->assertSame([], $sorter->getWarnings());
}

public function testLinearChain(): void
{
$items = [
'A' => ['B'],
'B' => ['C'],
'C' => ['D'],
'D' => [],
];

$sorter = new KahnSorter();
$results = $sorter->process($items);

$this->assertSame(['D', 'C', 'B', 'A'], $results);
$this->assertEmpty($sorter->getWarnings());
}

public function testHasProcessed(): void
{
$sorter = new KahnSorter();

$this->assertFalse($sorter->hasProcessed());

$sorter->process([]);

$this->assertTrue($sorter->hasProcessed());
}

public function testGetSortedNodesAfterProcess(): void
{
$sorter = new KahnSorter();
$sorter->process(['A' => ['B'], 'B' => []]);

$this->assertSame(['B', 'A'], $sorter->getSortedNodes());
}

public function testGetMessages(): void
{
$sorter = new KahnSorter();
$sorter->process(['A' => ['B'], 'B' => []]);

$this->assertEqualsCanonicalizing(
[
'[Dependency resolution] A depends on [B]',
'[Dependency resolution] B depends on []',
],
$sorter->getMessages()
);
}

public function testUnspecifiedDependenciesAreCreated(): void
{
// 'B' is referenced as a dependency but not defined as a key
$items = [
'A' => ['B'],
];

$sorter = new KahnSorter();
$results = $sorter->process($items);

// Both should appear in output, with B before A
$this->assertSame(['B', 'A'], $results);
$this->assertEmpty($sorter->getWarnings());
}

public function testThreeNodeCycle(): void
{
$items = [
'A' => ['B'],
'B' => ['C'],
'C' => ['A'],
];

$sorter = new KahnSorter();
$results = $sorter->process($items);

// All nodes are in a cycle, so none can be sorted normally — they're all present but in arbitrary order
$this->assertEqualsCanonicalizing(['A', 'B', 'C'], $results);

$this->assertEqualsCanonicalizing(
[
'Node `A` has `1` left over dependencies, and so could not be sorted',
'Node `B` has `1` left over dependencies, and so could not be sorted',
'Node `C` has `1` left over dependencies, and so could not be sorted',
],
$sorter->getWarnings()
);
}

public function testProcessResetsState(): void
{
$sorter = new KahnSorter();

// First process with warnings
$sorter->process(['A' => ['B'], 'B' => ['A']]);
$this->assertNotEmpty($sorter->getWarnings());

// Second process without warnings — state should be reset
$sorter->process(['A' => ['B'], 'B' => []]);
$this->assertEmpty($sorter->getWarnings());
}

}
Loading
Loading