diff --git a/src/Models/PdbReturn.php b/src/Models/PdbReturn.php index acbdd29..494aa40 100644 --- a/src/Models/PdbReturn.php +++ b/src/Models/PdbReturn.php @@ -12,6 +12,7 @@ use karmabunny\kb\DataObject; use karmabunny\pdb\Exceptions\RowMissingException; use karmabunny\pdb\Pdb; +use karmabunny\pdb\PdbModelInterface; use karmabunny\pdb\PdbReturnInterface; use PDO; use PDOStatement; @@ -339,8 +340,15 @@ public function format(PDOStatement $rs) public function buildClass($result) { if ($class = $this->class) { - if (is_subclass_of($class, Configurable::class)) { - $create = function($item) use ($class) { + if (is_subclass_of($class, PdbModelInterface::class)) { + $create = function(array $item) use ($class) { + $object = new $class(); + $class::populate($object, $item); + return $object; + }; + } + else if (is_subclass_of($class, Configurable::class)) { + $create = function(array $item) use ($class) { $object = new $class(); $object->update($item); @@ -352,7 +360,7 @@ public function buildClass($result) }; } else { - $create = function($item) use ($class) { + $create = function(array $item) use ($class) { $object = new $class(); foreach ($item as $key => $value) { $object->$key = $value; diff --git a/src/Pdb.php b/src/Pdb.php index fc57845..0feef57 100644 --- a/src/Pdb.php +++ b/src/Pdb.php @@ -2189,6 +2189,8 @@ protected static function bindParams(PDOStatement $st, array $params) if (is_int($val)) { $st->bindValue($key, $val, PDO::PARAM_INT); + } else if (is_bool($val)) { + $st->bindValue($key, (int) $val, PDO::PARAM_INT); } else { $st->bindValue($key, $val, PDO::PARAM_STR); } diff --git a/src/PdbModelInterface.php b/src/PdbModelInterface.php index 7d90a3c..ede96b8 100644 --- a/src/PdbModelInterface.php +++ b/src/PdbModelInterface.php @@ -81,4 +81,14 @@ public static function findOne(array $conditions); * @return static[] */ public static function findAll(array $conditions = []); + + + /** + * Populate a model with a DB row. + * + * @param static $instance + * @param array $config + * @return void + */ + public static function populate($instance, array $config); } diff --git a/src/PdbModelTrait.php b/src/PdbModelTrait.php index 1a7f6bd..2e24db9 100644 --- a/src/PdbModelTrait.php +++ b/src/PdbModelTrait.php @@ -6,8 +6,11 @@ namespace karmabunny\pdb; +use DateTimeInterface; use InvalidArgumentException; +use karmabunny\kb\ConfigurableInit; use karmabunny\kb\Configure; +use karmabunny\kb\Json; use karmabunny\pdb\Exceptions\RowMissingException; use ReflectionClass; use ReflectionException; @@ -16,6 +19,8 @@ use karmabunny\kb\Reflect; use karmabunny\pdb\Exceptions\ConnectionException; use karmabunny\pdb\Exceptions\QueryException; +use karmabunny\pdb\Models\PdbColumn; +use ReflectionProperty; /** * This implements basic methods for {@see PdbModelInterface}. @@ -145,6 +150,7 @@ public function populateDefaults() // Here we set these immediately. // @phpstan-ignore-next-line : phpstan runs on 7.1. if (PHP_VERSION_ID >= 70400 and !$property->isInitialized($this)) { + // @phpstan-ignore-next-line : already guarded. $type = $property->getType(); if ($value instanceof PdbSetDefaults || $value instanceof PdbJsonDefault) { if ($type instanceof ReflectionNamedType && $type->getName() === 'array') { @@ -232,6 +238,33 @@ public static function findOrCreate(array $conditions, bool $update = true) } + /** + * Populate a model with a DB row. + * + * Extend this to implement custom logic, e.g. dirty property behaviour. + * + * @param static $instance + * @param array $config + * @return void + */ + public static function populate($instance, array $config) + { + foreach ($config as $key => $value) { + if (!property_exists($instance, $key)) { + continue; + } + + static::typeCastValue($key, $value); + $instance->$key = $value; + } + + // Preserve init() behaviour. + if ($instance instanceof ConfigurableInit) { + $instance->init(); + } + } + + /** * Data to be inserted or updated. * @@ -244,8 +277,38 @@ public static function findOrCreate(array $conditions, bool $update = true) */ public function getSaveData(): array { - $data = Reflect::getProperties($this); + $data = Reflect::getProperties($this, null); + + foreach ($data as $key => &$value) { + + // Convert arrays to SET or JSON strings. + if (is_array($value)) { + if (static::getFieldType($key) === 'set') { + $value = implode(',', $value); + } + else { + $value = Json::encode($value); + } + + continue; + } + + // Ensure booleans are integers. + if (is_bool($value)) { + $value = (int) $value; + continue; + } + + if ($value instanceof DateTimeInterface) { + $value = $value->format(Pdb::FORMAT_DATE_TIME); + continue; + } + } + + unset($value); + unset($data['id']); + return $data; } @@ -329,9 +392,7 @@ protected function _internalSave(array &$data) */ protected function _afterSave(array $data) { - if (isset($data['id'])) { - $this->id = $data['id']; - } + self::populate($this, $data); } @@ -350,4 +411,189 @@ public function delete(): bool return (bool) $pdb->delete($table, ['id' => $this->id]); } + + /** + * Get the field definitions for the table that holds this record + * + * @return array + */ + protected static function getFieldList(): array + { + static $fieldList = []; + + $table = static::getTableName(); + + if (!isset($fieldList[$table])) { + $pdb = static::getConnection(); + $fieldList[$table] = $pdb->fieldList($table); + } + + return $fieldList[$table]; + } + + + /** + * Test for a field type. + * + * @param string $field_name + * @return string|null + */ + protected static function getFieldType(string $field_name) + { + $fields = static::getFieldList(); + $field_defn = $fields[$field_name] ?? null; + + if ($field_defn === null) { + return null; + } + + if (stripos($field_defn->type, 'set(') === 0) { + return 'set'; + } + + if (stripos($field_defn->type, 'enum(') === 0) { + return 'enum'; + } + + return strtolower($field_defn->type); + } + + + /** + * Convert a property value to it's appropriate type. + * + * Supported types: + * - array: from JSON-encoded string + * - array: from comma-separated SET + * - bool: from '0' or '1' + * - int: from numeric string + * - float: from numeric string + * - datetime: from string + * - string: from anything else + * + * @param string $property + * @param mixed $value + * @return void + */ + protected static function typeCastValue(string $property, &$value): void + { + // We'll drop old PHP very soon. Promise. + if (PHP_VERSION_ID < 74000) { + return; + } + + // @phpstan-ignore-next-line : already guarded. + $type = (new ReflectionProperty(static::class, $property))->getType(); + + // Can't do anything with this. + if (!$type instanceof ReflectionNamedType) { + return; + } + + // Strict empty, not PHP empty. + $is_empty = ($value === '' or $value === null); + + if ($type->allowsNull() and $is_empty) { + $value = null; + return; + } + + if (!is_array($value) and $type->getName() === 'array') { + if ($is_empty or !is_string($value)) { + $value = []; + return; + } + + if (static::getFieldType($property) === 'set') { + $value = explode(',', $value); + return; + } + + // N.B. data source (e.g. MySQL JSON column) should always provide + // valid JSON, so Json::decode should never throw an exception, + // outside of memory/depth constraints + $value = Json::decode($value); + + // Gracefully handle change from single value to multi-value column + if (is_scalar($value)) { + $value = [$value]; + } + + // Anything else. + if ($value === null or !is_array($value)) { + $value = $type->allowsNull() ? null : []; + } + + return; + } + + // Converting booleans from scalar types. + if (!is_bool($value) and $type->getName() === 'bool') { + if ($is_empty or !is_scalar($value)) { + $value = false; + return; + } + + $value = filter_var($value, FILTER_VALIDATE_BOOLEAN); + return; + } + + // Converting integers from numeric types. + if (!is_int($value) and $type->getName() === 'int') { + if ($is_empty or !is_numeric($value)) { + $value = 0; + return; + } + + $value = (int) $value; + return; + } + + // Converting floats from numeric types. + if (!is_float($value) and $type->getName() === 'float') { + if ($is_empty or !is_numeric($value)) { + $value = 0.0; + return; + } + + $value = (float) $value; + return; + } + + // Converting datetimes from strings or numeric types. + // Numeric assumes a timestamp from the Unix epoch. + // String is anything date-ish looking. + $type_name = $type->getName(); + if ( + !$value instanceof DateTimeInterface + and ( + $type_name === DateTimeInterface::class + or is_subclass_of($type_name, DateTimeInterface::class) + ) + ) { + $class = ($type_name === DateTimeInterface::class) + ? \DateTimeImmutable::class + : $type_name; + $tz = static::getConnection()->getTimezone(); + + if ($is_empty) { + $value = new $class('@0', $tz); + return; + } + + if (is_numeric($value)) { + $value = new $class('@' . $value, $tz); + return; + } + + if (is_string($value)) { + $value = new $class($value, $tz); + return; + } + + // Invalid type, fallback to 0. + $value = new $class('@0', $tz); + return; + } + } } diff --git a/tests/Models/TypedModel.php b/tests/Models/TypedModel.php new file mode 100644 index 0000000..1116717 --- /dev/null +++ b/tests/Models/TypedModel.php @@ -0,0 +1,70 @@ + */ + public array $json_db_default; + + /** @var array */ + public array $json_model_default = ['trash' => 'panda', 123]; + + /** @var array (not actually nullable; only in the DB) */ + public array $json_nullable; + + public string $non_json; + + public static function getTableName(): string + { + return 'model_property_tests'; + } + + public function getSaveData(): array + { + $data = parent::getSaveData(); + + $pdb = static::getConnection(); + $now = $pdb->now(); + + // Include the uuid if it's not already set. + $data['uid'] ??= Uuid::uuid4(); + + $data['date_modified'] ??= $now; + + if (!$this->id) { + $data['date_added'] = $now; + } + + return $data; + } +} diff --git a/tests/PdbModelTest.php b/tests/PdbModelTest.php index 6efeac9..6d6e2c3 100644 --- a/tests/PdbModelTest.php +++ b/tests/PdbModelTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase; use kbtests\Models\Club; use kbtests\Models\DirtyClub; +use kbtests\Models\TypedModel; use kbtests\Models\SproutItem; class PdbModelTest extends TestCase @@ -34,6 +35,7 @@ public function setUp(): void if (!Database::isConnected()) $this->markTestSkipped(); $pdb->query('DROP TABLE IF EXISTS ~clubs', [], 'null'); + $pdb->query('DROP TABLE IF EXISTS ~model_property_tests', [], 'null'); $sync = new PdbSync($pdb); @@ -249,4 +251,113 @@ public function testSproutModel() $this->assertEquals($model->date_added, $other->date_added); $this->assertEquals($model->name, $other->name); } + + + /** + * Test that saving and loading data using native types works, + * including SET and JSON fields with default values + */ + public function testTypedSaveLoad(): void + { + $name = 'Test save and load data'; + $bool_val = false; + $int_val = 3; + $currency_val = 13.95; + $float_val = 1234.56789; + + $dummy = new TypedModel(); + $dummy->name = $name; + $dummy->bool_val = $bool_val; + $dummy->int_val = $int_val; + $dummy->currency_val = $currency_val; + $dummy->float_val = $float_val; + $dummy->save(); + + $models = TypedModel::findAll(); + $this->assertEquals(count($models), 1); + + // Confirm that all data saved and loaded correctly from native types + /** @var TypedModel $dummy */ + $dummy = reset($models); + $this->assertEquals($dummy->name, $name); + $this->assertEquals($dummy->bool_val, $bool_val); + $this->assertEquals($dummy->bool_default_false, false); // from default defined in DB + $this->assertEquals($dummy->bool_default_true, true); // from default defined in DB + $this->assertEquals($dummy->int_val, $int_val); + $this->assertEquals($dummy->int_default_zero, 0); // from default defined in DB + $this->assertEquals($dummy->int_default_one, 1); // from default defined in DB + $this->assertEquals($dummy->currency_val, $currency_val); + $this->assertEquals($dummy->float_val, $float_val); + $this->assertEquals($dummy->options_db_default, ['a', 'b']); // from default defined in DB + $this->assertEquals($dummy->options_model_default, ['c', 'd']); // from default defined in model + $this->assertEquals($dummy->json_db_default, [1, 2, ['a' => 'b', 3 => 9]]); // from default defined in DB + $this->assertEquals($dummy->json_model_default, ['trash' => 'panda', 123]); // from default defined in model + $this->assertEquals($dummy->non_json, '{"do not parse JSON": "for a non-array attribute"}'); + + $new_bool = true; + $new_int = 5; + $new_options = ['a', 'b', 'c']; + $new_json = ['x' => 1, 'y' => 543.21, 'z' => ['ciao' => 'hola']]; + $dummy->bool_val = $new_bool; + $dummy->int_val = $new_int; + $dummy->options_db_default = $new_options; + $dummy->options_model_default = $new_options; + $dummy->json_db_default = $new_json; + $dummy->json_model_default = $new_json; + $dummy->save(); + + // Confirm that new data saved and loaded correctly from native types + /** @var TypedModel $dummy */ + $dummy = TypedModel::find(['id' => $dummy->id])->one(); + $this->assertEquals($dummy->bool_val, $new_bool); + $this->assertEquals($dummy->int_val, $new_int); + $this->assertEquals($dummy->options_db_default, $new_options); + $this->assertEquals($dummy->options_model_default, $new_options); + $this->assertEquals($dummy->json_db_default, $new_json); + $this->assertEquals($dummy->json_model_default, $new_json); + } + + + /** + * Test that overriding defaults works sanely + */ + public function testOverrideDefaults(): void + { + $dummy = new TypedModel(); + $dummy->name = 'Test override defaults'; + $dummy->bool_val = true; + $dummy->int_val = 100; + $dummy->currency_val = 1.0; + $dummy->float_val = 123.45; + $dummy->bool_default_false = true; + $dummy->bool_default_true = false; + $dummy->int_default_zero = 1; + $dummy->int_default_one = 0; + $dummy->save(); + + $models = TypedModel::findAll(); + $this->assertEquals(count($models), 1); + + /** @var TypedModel $dummy */ + $dummy = reset($models); + $this->assertEquals($dummy->bool_default_false, true); + $this->assertEquals($dummy->bool_default_true, false); + $this->assertEquals($dummy->int_default_zero, 1); + $this->assertEquals($dummy->int_default_one, 0); + } + + + /** + * Test that creation using DB defaults doesn't cause a TypeError + */ + public function testFindOrCreate(): void + { + $dummy = TypedModel::findOrCreate([ + 'name' => 'Test by findOrCreate' + ]); + + // Useless assertion just to ensure no TypeError occurs + $this->assertInstanceOf(TypedModel::class, $dummy); + } } + diff --git a/tests/db_struct.xml b/tests/db_struct.xml index 814d572..913dee1 100644 --- a/tests/db_struct.xml +++ b/tests/db_struct.xml @@ -72,4 +72,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +