Skip to content

2.0 — Feature: Built-in system for converting from query data #19

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

Open
wants to merge 31 commits into
base: release/2.0.0
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0bfba41
refactor: switches to static what can be
JasonTheAdams Mar 8, 2025
3bed92a
test: updates tests to use static properties/methods
JasonTheAdams Mar 8, 2025
4c05c26
refactor: switch more to static
JasonTheAdams Mar 8, 2025
24ba49b
test: updates remaining tests
JasonTheAdams Mar 8, 2025
b8e8831
feat: adds native model buildings from query data
JasonTheAdams Mar 8, 2025
6541267
chore: adds additional docs
JasonTheAdams Mar 8, 2025
2c2a2d4
chore: updates docs
JasonTheAdams Apr 9, 2025
0dcc15b
chore: updates outdated flows
JasonTheAdams Apr 9, 2025
dc52d77
Merge branch 'main' into refactor/static-methods-properties
JasonTheAdams Apr 9, 2025
7ff61d8
test: updates properties in tests
JasonTheAdams Apr 9, 2025
3bdedb7
chore: fixes syntax in readme
JasonTheAdams Apr 9, 2025
90b7a08
Merge branch 'refactor/static-methods-properties' into feature/from-q…
JasonTheAdams Apr 9, 2025
235d54e
fix: fetches relationship statically
JasonTheAdams Apr 9, 2025
cff40fb
Merge branch 'refactor/static-methods-properties' into feature/from-q…
JasonTheAdams Apr 9, 2025
99769e0
refactor: cleans up ModelQueryBuilder
JasonTheAdams Apr 9, 2025
254b408
fix: corrects static syntax
JasonTheAdams Apr 10, 2025
a7c7c3b
Merge branch 'refactor/static-methods-properties' into feature/from-q…
JasonTheAdams Apr 10, 2025
676dff6
feat: improves data casting
JasonTheAdams Apr 10, 2025
8a3bc18
refactor: corrects spacing
JasonTheAdams Apr 10, 2025
cf435ab
Merge branch 'release/2.0.0' into feature/from-query-data
JasonTheAdams Apr 12, 2025
8ec57b7
refactor: changes to fromData
JasonTheAdams Apr 12, 2025
dafd8ef
feat: skips casting if value is correct type
JasonTheAdams Apr 12, 2025
90a6def
fix: corrects changed interface
JasonTheAdams Apr 12, 2025
f433793
test: tries fixing syntax error
JasonTheAdams Apr 12, 2025
b99d67f
refactor: removes sttaic return type as it is not 7.4 compat
JasonTheAdams Apr 12, 2025
29359f3
chore: bumps min PHP version to 7.4
JasonTheAdams Apr 12, 2025
b838c79
test: adds fromData model test
JasonTheAdams Apr 12, 2025
77f50f2
test: adds fromModel exception test
JasonTheAdams Apr 12, 2025
8dd2f82
test: ignores extra data
JasonTheAdams Apr 12, 2025
7be8ba8
test: ignore missing
JasonTheAdams Apr 12, 2025
64bf370
test: marks tests as skipped for now
JasonTheAdams Apr 12, 2025
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
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"config": {
"preferred-install": "dist",
"platform": {
"php": "7.0"
"php": "7.4"
},
"allow-plugins": {
"kylekatarnls/update-helper": true
Expand Down
11 changes: 10 additions & 1 deletion src/Models/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,16 @@ public static function setInvalidArgumentException( string $class ) {
static::$invalidArgumentException = $class;
}

public static function throwInvalidArgumentException( string $message ) {
/**
* Convenience method for throwing the InvalidArgumentException.
*
* @since 2.0.0
*
* @param string $message
*
* @return void
*/
public static function throwInvalidArgumentException( string $message ): void {
throw new static::$invalidArgumentException( $message );
}
}
2 changes: 1 addition & 1 deletion src/Models/Contracts/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

use RuntimeException;

interface Model {
interface Model extends ModelBuildsFromData {
/**
* Constructor.
*
Expand Down
19 changes: 19 additions & 0 deletions src/Models/Contracts/ModelBuildsFromData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace StellarWP\Models\Contracts;

use StellarWP\Models\ModelQueryBuilder;

/**
* @since 2.0.0
*/
interface ModelBuildsFromData {
/**
* @since 2.0.0
*
* @param array|object $data
*
* @return Model
*/
public static function fromData( $data );
}
19 changes: 0 additions & 19 deletions src/Models/Contracts/ModelFromQueryBuilderObject.php

This file was deleted.

11 changes: 1 addition & 10 deletions src/Models/Contracts/ModelReadOnly.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
/**
* @since 1.0.0
*/
interface ModelReadOnly {
interface ModelReadOnly extends ModelBuildsFromQueryData {
/**
* @since 1.0.0
*
Expand All @@ -23,13 +23,4 @@ public static function find( $id );
* @return ModelQueryBuilder
*/
public static function query();

/**
* @since 1.0.0
*
* @param $object
*
* @return Model
*/
public static function fromQueryBuilderObject( $object );
}
80 changes: 80 additions & 0 deletions src/Models/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
use StellarWP\Models\ValueObjects\Relationship;

abstract class Model implements ModelInterface, Arrayable, JsonSerializable {
public const BUILD_MODE_STRICT = 0;
public const BUILD_MODE_IGNORE_MISSING = 1;
public const BUILD_MODE_IGNORE_EXTRA = 2;

/**
* The model's attributes.
*
Expand Down Expand Up @@ -57,6 +61,39 @@ public function __construct( array $attributes = [] ) {
$this->syncOriginal();
}

/**
* Casts the value for the type, used when constructing a model from query data. If the model needs to support
* additional types, especially class types, this method can be overridden.
*
* @since 2.0.0 changed to static
*
* @param string $type
* @param mixed $value The query data value to cast, probably a string.
* @param string $property The property being casted.
*
* @return mixed
*/
protected static function castValueForProperty( string $type, $value, string $property ) {
if ( static::isPropertyTypeValid( $property, $value ) || $value === null ) {
return $value;
}

switch ( $type ) {
case 'int':
return (int) $value;
case 'string':
return (string) $value;
case 'bool':
return (bool) filter_var( $value, FILTER_VALIDATE_BOOLEAN );
case 'array':
return (array) $value;
case 'float':
return (float) filter_var( $value, FILTER_SANITIZE_NUMBER_FLOAT,FILTER_FLAG_ALLOW_FRACTION );
default:
Config::throwInvalidArgumentException( "Unexpected type: '$type'. To support additional types, implement a custom castValueForProperty() method." );

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this could happen up the chain before we get into casting, doing something like $this->isPropertyTypeValid($key, $value)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I purposely want to do this here so all someone has to do is overload this function to support additional casting of types.

}
}

/**
* Fills the model with an array of attributes.
*
Expand Down Expand Up @@ -354,6 +391,49 @@ public function jsonSerialize() {
return get_object_vars( $this );
}

/**
* Constructs a model instance from database query data.
*
* @param object|array $queryData
* @param int $mode The level of strictness to take when constructing the object, by default it will ignore extra keys but error on missing keys.
* @return static
*/
public static function fromData($data, $mode = self::BUILD_MODE_IGNORE_EXTRA) {
if ( ! is_object( $data ) && ! is_array( $data ) ) {
Config::throwInvalidArgumentException( 'Query data must be an object or array' );
}

$data = (array) $data;

// If we're not ignoring extra keys, check for them and throw an exception if any are found.
if ( ! ($mode & self::BUILD_MODE_IGNORE_EXTRA) ) {
$extraKeys = array_diff_key( (array) $data, static::$properties );
if ( ! empty( $extraKeys ) ) {
Config::throwInvalidArgumentException( 'Query data contains extra keys: ' . implode( ', ', array_keys( $extraKeys ) ) );
}
}

if ( ! ($mode & self::BUILD_MODE_IGNORE_MISSING) ) {
$missingKeys = array_diff_key( static::$properties, (array) $data );
if ( ! empty( $missingKeys ) ) {
Config::throwInvalidArgumentException( 'Query data is missing keys: ' . implode( ', ', array_keys( $missingKeys ) ) );
}
}

$instance = new static();

foreach (static::$properties as $key => $type) {
if ( ! array_key_exists( $key, $data ) ) {
Config::throwInvalidArgumentException( "Property '$key' does not exist." );
}

// Remember not to use $type, as it may be an array that includes the default value. Safer to use getPropertyType().
$instance->setAttribute($key, static::castValueForProperty(static::getPropertyType($key), $data[$key], $key));
}

return $instance;
}

/**
* Returns the property keys.
*
Expand Down
77 changes: 21 additions & 56 deletions src/Models/ModelQueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@
use StellarWP\DB\DB;
use StellarWP\DB\QueryBuilder\QueryBuilder;
use StellarWP\DB\QueryBuilder\Clauses\RawSQL;
use StellarWP\Models\Model;

use StellarWP\Models\Contracts\Model;
use StellarWP\Models\Contracts\ModelBuildsFromData;
/**
* @since 1.2.2 improve model generic
* @since 1.0.0
*
* @template M of Model
* @template M of ModelBuildsFromQueryData
*/
class ModelQueryBuilder extends QueryBuilder {
public const MODEL = 'model';

/**
* @var class-string<M>
*/
Expand All @@ -24,8 +26,8 @@ class ModelQueryBuilder extends QueryBuilder {
* @param class-string<M> $modelClass
*/
public function __construct( string $modelClass ) {
if ( ! is_subclass_of( $modelClass, Model::class ) ) {
throw new InvalidArgumentException( "$modelClass must be an instance of " . Model::class );
if ( ! is_subclass_of( $modelClass, ModelBuildsFromData::class ) ) {
throw new InvalidArgumentException( "$modelClass must implement " . ModelBuildsFromData::class );
}

$this->model = $modelClass;
Expand Down Expand Up @@ -58,14 +60,18 @@ public function count( $column = null ) : int {
*
* @return M|null
*/
public function get( $output = OBJECT ): ?Model {
$row = DB::get_row( $this->getSQL(), OBJECT );
public function get( $output = self::MODEL ): ?Model {
if ( $output !== self::MODEL ) {
return parent::get( $output );
}

$row = DB::get_row( $this->getSQL() );

if ( ! $row ) {
return null;
}

return $this->getRowAsModel( $row );
return $this->model::fromQueryData( $row );
}

/**
Expand All @@ -75,58 +81,17 @@ public function get( $output = OBJECT ): ?Model {
*
* @return M[]|null
*/
public function getAll( $output = OBJECT ) : ?array {
$results = DB::get_results( $this->getSQL(), OBJECT );

if ( ! $results ) {
return null;
}

if ( isset( $this->model ) ) {
return $this->getAllAsModel( $results );
}

return $results;
}

/**
* Get row as model
*
* @since 1.0.0
*
* @param object|null $row
*
* @return M|null
*/
protected function getRowAsModel( $row ) {
$model = $this->model;

if ( ! method_exists( $model, 'fromQueryBuilderObject' ) ) {
throw new InvalidArgumentException( "fromQueryBuilderObject missing from $model" );
public function getAll( $output = self::MODEL ) : ?array {
if ( $output !== self::MODEL ) {
return parent::getAll( $output );
}

return $model::fromQueryBuilderObject( $row );
}

/**
* Get results as models
*
* @since 1.0.0
*
* @param object[] $results
*
* @return M[]|null
*/
protected function getAllAsModel( array $results ) {
/** @var Contracts\ModelCrud $model */
$model = $this->model;
$results = DB::get_results( $this->getSQL() );

if ( ! method_exists( $model, 'fromQueryBuilderObject' ) ) {
throw new InvalidArgumentException( "fromQueryBuilderObject missing from $model" );
if ( ! $results ) {
return null;
}

return array_map( static function( $object ) use ( $model ) {
return $model::fromQueryBuilderObject( $object );
}, $results );
return array_map( [ $this->model, 'fromQueryData' ], $results );
}
}
3 changes: 2 additions & 1 deletion tests/_support/Helper/MockModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class MockModel extends Model {
'lastName' => 'string',
'emails' => [ 'array', [] ],
'microseconds' => 'float',
'number' => 'number',
'number' => 'int',
'date' => \DateTime::class,
];
}
6 changes: 4 additions & 2 deletions tests/wpunit/ModelFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,12 @@ public function definition(): array {
};

$nestedFactory = new class(MockModel::class) extends ModelFactory {
private $counter = 0;

public function definition(): array {
static $counter = 1;
$this->counter++;
return [
'id' => $counter++,
'id' => $this->counter,
];
}
};
Expand Down
40 changes: 37 additions & 3 deletions tests/wpunit/ModelTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -275,10 +275,8 @@ public function testShouldSetMultipleAttributes() {

/**
* @since 1.0.0
*
* @return void
*/
public function testIsSet() {
public function testIsSet(): void {
$model = new MockModel();

// This has a default so we should see as set.
Expand All @@ -292,6 +290,42 @@ public function testIsSet() {
$this->assertTrue( $model->isSet( 'lastName' ) );
}

/**
* @since 2.0.0
*/
public function testFromDataShouldCreateInstanceWithCorrectTypes(): void {
self::markTestSkipped( 'This is not finished yet.' );

$model = MockModel::fromData( [
'id' => '1',
'firstName' => 'Bill',
'lastName' => 'Murray',
'emails' => [ 'billMurray@givewp.com' ],
'microseconds' => '1234567890',
'number' => '1234567890',
], MockModel::BUILD_MODE_IGNORE_EXTRA & MockModel::BUILD_MODE_IGNORE_MISSING );

$this->assertEquals( 1, $model->id );
$this->assertEquals( 'Bill', $model->firstName );
$this->assertEquals( 'Murray', $model->lastName );
$this->assertEquals( [ 'billMurray@givewp.com' ], $model->emails );
$this->assertEquals( 1234567890, $model->microseconds );
$this->assertEquals( 1234567890, $model->number );
}

/**
* @since 2.0.0
*/
public function testFromDataShouldThrowExceptionForNonPrimitiveTypes(): void {
self::markTestSkipped( 'This is not finished yet.' );

$this->expectException( Config::getInvalidArgumentException() );

MockModel::fromData( [
'date' => '123',
] );
}

/**
* @since 1.0.0
*
Expand Down