Skip to content

Add simple option to assert entity contain relations. #116

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Mar 24, 2025
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
37 changes: 34 additions & 3 deletions docs/Model/Entity.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ If you are looking into non-entity approaches, consider [DTOs](https://github.co

## Entity read()
You want to read nested properties of your entity, but you do not want tons of !empty() checks?
```php
if ($entity->tags && !empty($entity->tags[2]->name)) {} else {}
```
if (!empty($entity->tags && !empty($entity->tags[2]->name)) {} else {}
```
With modern PHP `?->` can already overcome some of it, but in some cases the read() approach is still better.

Add the trait first:
```php
Expand All @@ -24,7 +25,37 @@ echo $entity->read('tags.2.name', $default);
```

This means, you are OK with part of the path being empty/null.
If you want the opposite, making sure all required fields in the path are present, check the next part about getOrFail().
If you want the opposite, making sure all required fields in the path are present, check the next part(s).

## Entity require()
```php
// in some service method at the beginning
public function buildPdf(Product $product): string {
$product->require('supplier.company.state.country');

//Render template
}
```
This allows you to define your required contains (relations) on the entity and otherwise results in a speaking and clear message.

It avoids the usual kind of hidden and not speaking

> warning: 2 :: Attempt to read property "sku" on null

> deprecated: 8192 :: strlen(): Passing null to parameter #1 ($string) of type string is deprecated

> warning: 2 :: foreach() argument must be of type array|object, null given

etc then inside the business logic or rendering when certain relations are expected but not present.

Add the trait and you are all set:
```php
use Shim\Model\Entity\RequireTrait;

class MyEntity extends Entity {

use RequireTrait;
```

## Entity get...OrFail()/set...OrFail()
You want to use "asserted return/param values" or "safe chaining" in your entities?
Expand Down
73 changes: 73 additions & 0 deletions src/Model/Entity/RequireTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

namespace Shim\Model\Entity;

use ArrayAccess;
use Cake\Datasource\EntityInterface;
use RuntimeException;

/**
* Trait to require (nested) entity properties in a speaking way up front.
*
* - require('supplier.company.state.country') will throw an exception if that nested property is null
*
* Note: This is primarily when passing data into a service method and you need to know you passed all contained relations.
*
* @mixin \Cake\ORM\Entity
*/
trait RequireTrait {

use ReadTrait;

/**
* Performant iteration over the path to nullable read the property path.
*
* Note: Hash::get($this->toArray(), $path, $default); would be simpler, but slower.
*
* @param array|string $path
* @return void
*/
public function require($path): void {
if (!is_array($path)) {
$parts = explode('.', $path);
} else {
$parts = $path;
}

$data = null;
$failed = null;
foreach ($parts as $key) {
if ($data === null && $this->$key === null) {
$failed = $key;

break;
}
if ($data === null) {
$data = $this->$key;

continue;
}

if ($data instanceof EntityInterface) {
$data = $data->toArray();
}

if ((is_array($data) || $data instanceof ArrayAccess) && isset($data[$key])) {
$data = $data[$key];

continue;
}

$failed = $key;

break;
}

if ($failed === null) {
return;
}

throw new RuntimeException('Require assertion failed for entity `' . static::class . '` and element `' . $failed . '`: `' . implode('.', $parts) . '`');
}

}
89 changes: 89 additions & 0 deletions tests/TestCase/Model/Entity/EntityRequireTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

namespace Shim\Test\TestCase\Model\Entity;

use Cake\ORM\Entity;
use RuntimeException;
use Shim\TestSuite\TestCase;
use TestApp\Model\Entity\TestEntity;

class EntityRequireTest extends TestCase {

/**
* @doesNotPerformAssertions
* @return void
*/
public function testRead(): void {
$entity = new TestEntity();
$entity->foo_bar = 'Foo Bar';

$entity->require('foo_bar');
}

/**
* @return void
*/
public function testReadAssoc(): void {
$entity = new TestEntity();

$entity->tag = new Entity([
'name' => 'foo',
'country' => new Entity([
'name' => 'country',
]),
]);

$entity->require('tag.name');
$entity->require('tag.country');
$entity->require('tag.country.name');

$this->expectException(RuntimeException::class);

$entity->require('tag.country.name_not_exists');
}

/**
* @return void
*/
public function testReadDeep(): void {
$entity = new TestEntity();

$entity->tags = [
new Entity(),
new Entity(),
new Entity(['name' => 'foo']),
];

$entity->require('tags.2.name');

$this->expectException(RuntimeException::class);
$this->expectExceptionMessage(
'Require assertion failed for entity `' . TestEntity::class . '` and element `name_not_exists`: `tags.2.name_not_exists`',
);

$entity->require('tags.2.name_not_exists');
}

/**
* @return void
*/
public function testReadPathElementInException(): void {
$entity = new TestEntity();

$entity->tag = new Entity([
'name' => 'foo',
'state' => new Entity([
'name' => 'state',
'country_id' => null,
]),
]);

$this->expectException(RuntimeException::class);
$this->expectExceptionMessage(
'Require assertion failed for entity `' . TestEntity::class . '` and element `country`: `tag.state.country.name_not_exists`',
);

$entity->require('tag.state.country.name_not_exists');
}

}
2 changes: 2 additions & 0 deletions tests/test_app/src/Model/Entity/TestEntity.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Shim\Model\Entity\GetSetTrait;
use Shim\Model\Entity\ModifiedTrait;
use Shim\Model\Entity\ReadTrait;
use Shim\Model\Entity\RequireTrait;

/**
* @property string|null $foo_bar
Expand All @@ -15,6 +16,7 @@ class TestEntity extends Entity {

use GetSetTrait;
use ReadTrait;
use RequireTrait;
use ModifiedTrait;

}