Skip to content
4 changes: 2 additions & 2 deletions src/Models/Contracts/ModelPersistable.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ interface ModelPersistable extends Model {
*
* @param int $id
*
* @return Model
* @return ?Model
*/
public static function find( $id ): Model;
public static function find( $id ): ?Model;

/**
* @since 1.0.0
Expand Down
2 changes: 1 addition & 1 deletion src/Models/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ protected function purgeRelationship( string $key ): void {
*
* @return void
*/
protected function setCachedRelationship( string $key, $value ): void {
public function setCachedRelationship( string $key, $value ): void {
$relationship = $this->relationshipCollection->get( $key );

if ( ! $relationship ) {
Expand Down
57 changes: 45 additions & 12 deletions src/Models/ModelRelationship.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class ModelRelationship {
/**
* The relationship value.
*
* @var Model|list<Model>|null
* @var mixed
*/
private $value;

Expand Down Expand Up @@ -79,19 +79,56 @@ public function getKey(): string {
public function getValue( callable $loader ) {
// If caching is disabled, always load fresh
if ( ! $this->definition->hasCachingEnabled() ) {
return $loader();
return $this->hydrate( $loader() );
}

// If already loaded and caching is enabled, return cached value
if ( $this->isLoaded ) {
return $this->value;
return $this->hydrate( $this->value );
}

// Load and cache the value
$this->setValue( $loader() );
return $this->hydrate( $this->value );
}

/**
* Get the raw value of the relationship.
*
* @since 2.0.0
*
* @param callable():( Model|list<Model>|null ) $loader A callable that loads the relationship value.
*
* @return mixed
*/
public function getRawValue( callable $loader ) {
$this->getValue( $loader );
return $this->value;
}

/**
* Hydrate the relationship value.
*
* @since 2.0.0
*
* @param mixed $value The relationship value.
*
* @return Model|list<Model>|null
*/
private function hydrate( $value ) {
if ( null === $value ) {
return null;
}

$hydrator = $this->definition->getHydrateWith();

if ( is_array( $value ) ) {
return array_values( array_map( fn( $item ) => $hydrator( $item ), $value ) );
}

return $hydrator( $value );
}

/**
* Returns whether the relationship has been loaded.
*
Expand Down Expand Up @@ -121,22 +158,18 @@ public function purge(): void {
* @throws InvalidArgumentException When the value is invalid.
*/
public function setValue( $value ): self {
// Validate the value
if ( $value !== null ) {
if ( $this->definition->isSingle() && ! $value instanceof Model ) {
throw new InvalidArgumentException( 'Single relationship value must be a Model instance or null.' );
if ( $this->definition->isSingle() ) {
$value = $this->definition->getValidateSanitizeRelationshipWith()( $value );
}

if ( $this->definition->isMultiple() ) {
if ( ! is_array( $value ) ) {
throw new InvalidArgumentException( 'Multiple relationship value must be an array or null.' );
Config::throwInvalidArgumentException( 'Multiple relationship value must be an array or null.' );
}

foreach ( $value as $item ) {
if ( ! $item instanceof Model ) {
throw new InvalidArgumentException( 'Multiple relationship value must be an array of Model instances.' );
}
}
$sanitizer = $this->definition->getValidateSanitizeRelationshipWith();
$value = array_map( $sanitizer, $value );
}
}

Expand Down
78 changes: 78 additions & 0 deletions src/Models/ModelRelationshipDefinition.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,24 @@ class ModelRelationshipDefinition {
*/
private bool $cachingEnabled = true;

/**
* The callable to hydrate the relationship with.
*
* @since 2.0.0
*
* @var ?callable
*/
private $hydrateWith = null;

/**
* The callable to validate and sanitize the relationship with.
*
* @since 2.0.0
*
* @var ?callable
*/
private $validateSanitizeRelationshipWith = null;

/**
* Whether the definition is locked. Once locked, the definition cannot be changed.
*
Expand Down Expand Up @@ -79,6 +97,66 @@ public function belongsTo(): self {
return $this;
}

/**
* Set the callable to hydrate the relationship with.
*
* @since 2.0.0
*
* @param callable $hydrateWith The callable to hydrate the relationship with.
*/
public function setHydrateWith( callable $hydrateWith ): self {
$this->checkLock();

$this->hydrateWith = $hydrateWith;

return $this;
}

/**
* Get the callable to hydrate the relationship with.
*
* @since 2.0.0
*
* @return callable( mixed $value ): ( Model )
*/
public function getHydrateWith(): callable {
// By default, it returns whats given.
return $this->hydrateWith ?? static fn( $value ) => $value;
}

/**
* Set the callable to validate the relationship with.
*
* @since 2.0.0
*
* @param callable $validateSanitizeRelationshipWith The callable to validate the relationship with.
*/
public function setValidateSanitizeRelationshipWith( callable $validateSanitizeRelationshipWith ): self {
$this->checkLock();

$this->validateSanitizeRelationshipWith = $validateSanitizeRelationshipWith;

return $this;
}

/**
* Get the callable to validate the relationship with.
*
* @since 2.0.0
*
* @return callable( mixed $thing ): ( Model | null )
*/

public function getValidateSanitizeRelationshipWith(): callable {
return $this->validateSanitizeRelationshipWith ?? static function( $thing ): ?Model {
if ( null !== $thing && ! $thing instanceof Model ) {
throw new InvalidArgumentException( 'Relationship value must be a valid value.' );
}

return $thing;
};
}

/**
* Set the relationship as belongs-to-many.
*
Expand Down
18 changes: 18 additions & 0 deletions tests/_support/Helper/BadMockModel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace StellarWP\Models\Tests;

use StellarWP\Models\Model;
use DateTime;

class BadMockModel extends Model {
protected static array $properties = [
'id' => 'int',
'firstName' => [ 'string', 'Michael' ],
'lastName' => 'string',
'emails' => [ 'array', [] ],
'microseconds' => 'float',
'number' => 'int',
'date' => DateTime::class,
];
}
13 changes: 12 additions & 1 deletion tests/_support/Helper/MockModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
namespace StellarWP\Models\Tests;

use StellarWP\Models\Model;
use StellarWP\Models\ModelPropertyDefinition;
use DateTime;

class MockModel extends Model {
protected static array $properties = [
Expand All @@ -12,6 +14,15 @@ class MockModel extends Model {
'emails' => [ 'array', [] ],
'microseconds' => 'float',
'number' => 'int',
'date' => \DateTime::class,
];

protected static function properties(): array {
return [
'date' => ( new ModelPropertyDefinition() )
->type('object')
->castWith(
fn($value) => DateTime::createFromFormat('Y-m-d H:i:s', $value)
),
];
}
}
4 changes: 2 additions & 2 deletions tests/_support/Helper/MockModelWithRelationship.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@ class MockModelWithRelationship extends Model {
* @return ModelQueryBuilder<MockModel>
*/
public function relatedAndCallableHasOne(): ModelQueryBuilder {
return ( new ModelQueryBuilder( MockModel::class ) )->from( 'posts' );
return ( new ModelQueryBuilder( MockModel::class ) )->select( 'ID as id', 'post_title as firstName', 'post_content as lastName', 'post_status as emails', 'post_date as microseconds', 'post_date_gmt as number', 'post_date as date' )->from( 'posts' );
}

/**
* @return ModelQueryBuilder<MockModel>
*/
public function relatedAndCallableHasMany(): ModelQueryBuilder {
return ( new ModelQueryBuilder( MockModel::class ) )->from( 'posts' );
return ( new ModelQueryBuilder( MockModel::class ) )->select( 'ID as id', 'post_title as firstName', 'post_content as lastName', 'post_status as emails', 'post_date as microseconds', 'post_date_gmt as number', 'post_date as date' )->from( 'posts' );
}
}
3 changes: 3 additions & 0 deletions tests/_support/_generated/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
*
!.gitignore
!.gitkeep
Empty file.
Loading