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
50 changes: 33 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,42 +216,58 @@ The plugin uses **compiled cache files** for zero-cost attribute caching. Discov

### Attribute Argument Requirements

The compiled cache uses `var_export()` to serialize attribute arguments. Most PHP types work seamlessly:
The compiled cache uses [brick/varexporter](https://github.com/brick/varexporter) to serialize attribute arguments. All common PHP types are fully supported:

**Supported Types** (no special handling needed):
**Supported Types:**
- Scalars: `string`, `int`, `float`, `bool`, `null`
- Arrays of supported types
- Enums (PHP 8.1+, natively supported)
- Arrays of any supported types
- Enums (PHP 8.1+)
- Objects (automatically handled via reflection)

**Object Arguments** require `__set_state()` implementation:
**Object Arguments** work seamlessly without requiring any special methods:

```php
class MyArgument
class Translation
{
public function __construct(
public string $value,
public string $key,
public string $domain = 'default',
public ?string $locale = null,
) {}
}

// Required for cache serialization
public static function __set_state(array $data): self
{
return new self(
value: $data['value'],
);
}
class ValidationRule
{
public function __construct(
public string $rule,
public array $options = [],
) {}
}

#[Attribute]
class MyAttribute
class Translatable
{
public function __construct(
public MyArgument $arg, // Object argument - needs __set_state()
public Translation $translation,
public ValidationRule $validation,
) {}
}

// Usage on entity property
class Article
{
#[Translatable(
translation: new Translation('article.title', 'cms', 'en_US'),
validation: new ValidationRule('maxLength', ['max' => 255])
)]
public string $title;
}
```

Objects are serialized using reflection-based strategies automatically.

> [!TIP]
> If caching fails because an object lacks `__set_state()`, an error will be logged at `logs/error.log` and the attribute will be skipped from the cache.
> If caching fails due to unsupported types (resources, closures), an error will be logged at `logs/error.log` and the attribute will be skipped from the cache.

## Usage

Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"license": "MIT",
"require": {
"php": ">=8.2",
"brick/varexporter": "^0.7.0",
"cakephp/cakephp": "^5.2"
},
"require-dev": {
Expand Down
129 changes: 9 additions & 120 deletions src/Service/CompiledCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
use AttributeRegistry\Enum\AttributeTargetType;
use AttributeRegistry\ValueObject\AttributeInfo;
use AttributeRegistry\ValueObject\AttributeTarget;
use Brick\VarExporter\VarExporter;
use Cake\Log\Log;
use Closure;
use RuntimeException;
use Throwable;
use UnitEnum;

/**
* Compiled cache service for zero-cost attribute caching.
Expand Down Expand Up @@ -265,21 +265,21 @@ private function generateAttributeInfo(AttributeInfo $attr): string
'%s)',
$indent,
$indent,
$this->exportString($attr->className),
VarExporter::export($attr->className),
$indent,
$this->exportString($attr->attributeName),
VarExporter::export($attr->attributeName),
$indent,
$this->exportArray($attr->arguments, 2),
VarExporter::export($attr->arguments, indentLevel: 2),
$indent,
$this->exportString($attr->filePath),
VarExporter::export($attr->filePath),
$indent,
$attr->lineNumber,
$indent,
$this->generateAttributeTarget($attr->target, 2),
$indent,
$attr->fileTime,
$indent,
$attr->pluginName === null ? 'null' : $this->exportString($attr->pluginName),
$attr->pluginName === null ? 'null' : VarExporter::export($attr->pluginName),
$indent,
);
}
Expand All @@ -305,124 +305,13 @@ private function generateAttributeTarget(AttributeTarget $target, int $level): s
$innerIndent,
$target->type->name,
$innerIndent,
$this->exportString($target->targetName),
VarExporter::export($target->targetName),
$innerIndent,
$this->exportValue($target->parentClass, $level),
VarExporter::export($target->parentClass, indentLevel: $level),
$indent,
);
}

/**
* Export a string value as PHP code.
*
* @param string $value String to export
* @return string Exported code
*/
private function exportString(string $value): string
{
// Use var_export for safety with special characters
return var_export($value, true);
}

/**
* Export any value as PHP code.
*
* @param mixed $value Value to export
* @param int $level Indentation level for nested structures
* @return string Exported code
*/
private function exportValue(mixed $value, int $level = 0): string
{
if ($value === null) {
return 'null';
}

if (is_string($value)) {
return $this->exportString($value);
}

if (is_bool($value)) {
return $value ? 'true' : 'false';
}

if (is_int($value)) {
return (string)$value;
}

if (is_float($value)) {
// Handle special float values
if (is_infinite($value)) {
return $value > 0 ? 'INF' : '-INF';
}

if (is_nan($value)) {
return 'NAN';
}

return var_export($value, true);
}

if (is_array($value)) {
return $this->exportArray($value, $level);
}

if (is_object($value)) {
// Enums are natively supported by var_export (PHP 8.1+)
if ($value instanceof UnitEnum) {
return var_export($value, true);
}

// For other objects, only allow those that can be reconstructed via __set_state
if (!method_exists($value, '__set_state')) {
throw new RuntimeException(
sprintf(
'Unsupported object type for export: %s must implement __set_state().',
get_debug_type($value),
),
);
}

return var_export($value, true);
}

throw new RuntimeException('Unsupported value type: ' . get_debug_type($value));
}

/**
* Export an array as PHP code.
*
* @param array<mixed> $array Array to export
* @param int $level Indentation level
* @return string Exported code
*/
private function exportArray(array $array, int $level): string
{
if ($array === []) {
return '[]';
}

$indent = str_repeat(' ', $level + 1);
$closeIndent = str_repeat(' ', $level);

$isAssoc = array_keys($array) !== range(0, count($array) - 1);

$items = [];
foreach ($array as $key => $value) {
if ($isAssoc) {
$items[] = sprintf(
'%s%s => %s',
$indent,
$this->exportValue($key, $level + 1),
$this->exportValue($value, $level + 1),
);
} else {
$items[] = $indent . $this->exportValue($value, $level + 1);
}
}

return "[\n" . implode(",\n", $items) . ",\n{$closeIndent}]";
}

/**
* Build complete file content with header and metadata.
*
Expand Down Expand Up @@ -553,7 +442,7 @@ private function validateValue(mixed $value, string $context): void
}
}

// Allow objects - var_export will handle them
// VarExporter handles all other types including objects
}

/**
Expand Down
19 changes: 15 additions & 4 deletions tests/TestCase/Service/CompiledCacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -362,22 +362,33 @@ className: 'Test\\MyClass',
}

/**
* Test that objects without __set_state are rejected
* Test that objects can be exported without __set_state using brick/varexporter
*/
public function testSetRejectsObjectsWithoutSetState(): void
public function testSetHandlesObjectsWithoutSetState(): void
{
$target = new AttributeTarget(AttributeTargetType::CLASS_TYPE, 'Test\\MyClass');
$obj = new stdClass();
$obj->property = 'value';
$obj->number = 42;

$attr = new AttributeInfo(
className: 'Test\\MyClass',
attributeName: 'Test\\MyAttribute',
arguments: ['obj' => new stdClass()],
arguments: ['obj' => $obj],
filePath: '/test/file.php',
lineNumber: 10,
target: $target,
);

$result = $this->cache->set('test', [$attr]);
$this->assertFalse($result, 'set() should return false when attributes contain objects without __set_state');
$this->assertTrue($result, 'set() should succeed with objects without __set_state (using brick/varexporter)');

$loaded = $this->cache->get('test');
$this->assertIsArray($loaded);
$this->assertCount(1, $loaded);
$this->assertInstanceOf(stdClass::class, $loaded[0]->arguments['obj']);
$this->assertEquals('value', $loaded[0]->arguments['obj']->property);
$this->assertEquals(42, $loaded[0]->arguments['obj']->number);
}

/**
Expand Down
Loading