From 79a51325b27308193ab3b4971e67c455646769ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20Miguel=20P=C3=A9rez?= Date: Thu, 10 Sep 2026 04:26:16 +0200 Subject: [PATCH] Add first-class JSON columns and fix the SQLite test run JSON columns (MySQL/MariaDB JSON, PostgreSQL json and jsonb, SQLite JSON) were mapped to STRING: reads returned raw text, assigning an array stored "Array", and to_json() double-encoded the document. They now become Column::JSON and hold an ActiveRecord\Json value object. - Json implements ArrayAccess, Countable, IteratorAggregate and JsonSerializable. Nested elements are returned by reference so writes like $doc->payload['tags'][] = 'x' land in the document; top-level writes flag the model dirty at once and in-place nested changes are detected on save() by comparing against a clean snapshot. A top-level {} survives the round trip; reading a missing key does not alter the document. - Column::cast() parses strings as JSON text, so code that assigned json_encode()'d strings keeps working, and wraps arrays, scalars and objects. cast_default() keeps expression defaults from breaking metadata loading. - Model casts objects for JSON columns, copies a document assigned from another model or attribute, gives each new record its own copy of a JSON default, and folds document changes into is_dirty(), dirty_attributes() and attribute_is_dirty(). Table encodes documents on insert and update. - static $json_attributes on a model opts text columns into the same handling. - The MySQL adapter detects MariaDB JSON columns, which are reported as longtext, through their json_valid() check constraints. - to_json() and to_array() nest documents; XML writes them as elements and CSV as JSON text. The suite also passes with SQLite as the default adapter now: - The SQLite adapter only claims ORDER BY/LIMIT support on UPDATE and DELETE when the build has SQLITE_ENABLE_UPDATE_DELETE_LIMIT. - Connection::query() binds values with a PDO type matching the PHP type. execute() bound everything as text, and SQLite does not convert text back to a number against an expression with no affinity, so an association condition like "length(title) = ?" never matched. - DatabaseTest rebuilds the SQLite schema when a previous class deleted the file, and two tests that assumed a network URL or a database that rejects IN() with no values handle SQLite explicitly. New tests: JsonTest covers the value object and casts without a database; MysqlJsonTest, PgsqlJsonTest and SqliteJsonTest run the same model-level cases against each database through a new documents table. Verified on MySQL 5.7 and 8.4, MariaDB 11.8, PostgreSQL 18 and SQLite. --- README.md | 34 +++ lib/Adapters/MysqlAdapter.php | 60 ++++- lib/Adapters/PgsqlAdapter.php | 2 +- lib/Adapters/SqliteAdapter.php | 18 +- lib/Column.php | 57 +++++ lib/Connection.php | 32 ++- lib/Json.php | 306 ++++++++++++++++++++++ lib/Model.php | 75 +++++- lib/Serialization.php | 3 + lib/Serializers/CsvSerializer.php | 7 + lib/Serializers/XmlSerializer.php | 3 + lib/Table.php | 22 ++ test/ActiveRecordFindTest.php | 7 + test/JsonTest.php | 299 +++++++++++++++++++++ test/MysqlJsonTest.php | 17 ++ test/PgsqlJsonTest.php | 17 ++ test/SqliteJsonTest.php | 23 ++ test/fixtures/documents.csv | 3 + test/helpers/AdapterTest.php | 10 +- test/helpers/DatabaseTest.php | 9 + test/helpers/JsonModelTestCase.php | 399 +++++++++++++++++++++++++++++ test/models/Document.php | 11 + test/sql/mysql.sql | 9 +- test/sql/pgsql-after-fixtures.sql | 1 + test/sql/pgsql.sql | 8 + test/sql/sqlite.sql | 8 + 26 files changed, 1422 insertions(+), 18 deletions(-) create mode 100644 lib/Json.php create mode 100644 test/JsonTest.php create mode 100644 test/MysqlJsonTest.php create mode 100644 test/PgsqlJsonTest.php create mode 100644 test/SqliteJsonTest.php create mode 100644 test/fixtures/documents.csv create mode 100644 test/helpers/JsonModelTestCase.php create mode 100644 test/models/Document.php diff --git a/README.md b/README.md index 05f0cf99c..922cef4e9 100755 --- a/README.md +++ b/README.md @@ -164,6 +164,40 @@ $post->delete(); echo $post->title; # 'New real title' ``` +## JSON columns ## + +Columns with a native JSON type (MySQL and MariaDB `JSON`, PostgreSQL `json` and `jsonb`, +SQLite `JSON`) are detected automatically. Their attributes hold an `ActiveRecord\Json` +document that behaves like an array and is encoded back to JSON text when the record is saved. +Changes made through the array syntax, including nested ones, mark the attribute as dirty. + +```php +$doc = Document::find(1); +$doc->payload['theme']; # 'dark' +$doc->payload['tags'][] = 'php'; # nested change +$doc->payload['nested']['count']++; # also picked up +$doc->save(); +# UPDATE `documents` SET payload='{"theme":"dark","tags":["php"],"nested":{"count":2}}' WHERE id=1 + +$doc->payload = ['theme' => 'light']; # arrays and objects are wrapped for you +$doc->payload = '{"theme": "light"}'; # so is JSON text, as before +$doc->payload->to_array(); # ['theme' => 'light'] +echo $doc->payload; # {"theme":"light"} +$doc->to_json(); # nests the document instead of double-encoding it +``` + +Text columns that hold JSON can get the same treatment by listing them in the model: + +```php +class Document extends ActiveRecord\Model +{ + static $json_attributes = ['settings']; +} +``` + +Documents are decoded to associative arrays, so a nested empty object is written back as +`[]`. Only the top-level `{}` is preserved. + ## Contributing ## Please refer to [CONTRIBUTING.md](https://github.com/jpfuentes2/php-activerecord/blob/master/CONTRIBUTING.md) for information on how to contribute to PHP ActiveRecord. diff --git a/lib/Adapters/MysqlAdapter.php b/lib/Adapters/MysqlAdapter.php index 39e2c2144..8cf849f59 100644 --- a/lib/Adapters/MysqlAdapter.php +++ b/lib/Adapters/MysqlAdapter.php @@ -6,9 +6,11 @@ namespace ActiveRecord\Adapters; +use PDO; use ActiveRecord\Column; use ActiveRecord\Inflector; use ActiveRecord\Connection; +use ActiveRecord\Exceptions\DatabaseException; /** * Adapter for MySQL. @@ -36,6 +38,62 @@ public function query_for_tables() return $this->query('SHOW TABLES'); } + public function columns($table) + { + $columns = parent::columns($table); + + if ($this->is_mariadb()) { + $this->detect_mariadb_json_columns($table, $columns); + } + + return $columns; + } + + /** + * True when the server is MariaDB rather than MySQL. + * @return boolean + */ + public function is_mariadb() + { + return stripos((string)$this->connection->getAttribute(PDO::ATTR_SERVER_VERSION), 'mariadb') !== false; + } + + /** + * MariaDB stores JSON columns as LONGTEXT with a json_valid() check constraint + * and reports them as longtext, so the constraints are what identify them. + * + * @param string $table Possibly quoted and schema-qualified table name + * @param array $columns Column objects indexed by name, updated in place + */ + private function detect_mariadb_json_columns($table, array $columns) + { + $parts = explode('.', str_replace('`', '', $table)); + $name = array_pop($parts); + $schema = array_pop($parts); + + $sql = 'SELECT CHECK_CLAUSE FROM information_schema.CHECK_CONSTRAINTS' + . ' WHERE CONSTRAINT_SCHEMA = COALESCE(?, DATABASE()) AND TABLE_NAME = ?'; + $values = [$schema, $name]; + + try { + $sth = $this->query($sql, $values); + } catch (DatabaseException $e) { + // MariaDB before 10.3.10 has no CHECK_CONSTRAINTS table; leave the columns as text + return; + } + + while (($row = $sth->fetch())) { + $clause = $row['CHECK_CLAUSE'] ?? $row['check_clause'] ?? ''; + + if (preg_match('/^json_valid\(`?([^`)]+)`?\)$/i', $clause, $matches) && isset($columns[$matches[1]])) { + $column = $columns[$matches[1]]; + $column->raw_type = 'json'; + $column->map_raw_type(); + $column->default = $column->cast_default($column->default, $this); + } + } + } + public function create_column(&$column) { $c = new Column(); @@ -65,7 +123,7 @@ public function create_column(&$column) } $c->map_raw_type(); - $c->default = $c->cast($column['default'], $this); + $c->default = $c->cast_default($column['default'], $this); return $c; } diff --git a/lib/Adapters/PgsqlAdapter.php b/lib/Adapters/PgsqlAdapter.php index d92642a8e..a3a437814 100644 --- a/lib/Adapters/PgsqlAdapter.php +++ b/lib/Adapters/PgsqlAdapter.php @@ -109,7 +109,7 @@ public function create_column(&$column) if (count($matches) == 2) { $c->sequence = $matches[1]; } else { - $c->default = $c->cast($column['default'], $this); + $c->default = $c->cast_default($column['default'], $this); } } return $c; diff --git a/lib/Adapters/SqliteAdapter.php b/lib/Adapters/SqliteAdapter.php index 7196ea95f..fc3533783 100644 --- a/lib/Adapters/SqliteAdapter.php +++ b/lib/Adapters/SqliteAdapter.php @@ -21,6 +21,11 @@ */ class SqliteAdapter extends Connection { + /** + * Whether this build accepts ORDER BY/LIMIT on UPDATE and DELETE; detected on first use. + */ + private $update_delete_limit = null; + protected function __construct($info) { if (!file_exists($info->host)) { @@ -87,7 +92,7 @@ public function create_column($column) $c->length = 8; } - $c->default = $c->cast($column['dflt_value'], $this); + $c->default = $c->cast_default($column['dflt_value'], $this); return $c; } @@ -97,9 +102,18 @@ public function set_encoding($charset) throw new ActiveRecordException("SqliteAdapter::set_charset not supported."); } + /** + * SQLite only parses ORDER BY and LIMIT on UPDATE and DELETE when built with + * SQLITE_ENABLE_UPDATE_DELETE_LIMIT, which distributions differ on. + */ public function accepts_limit_and_order_for_update_and_delete() { - return true; + if ($this->update_delete_limit === null) { + $options = $this->query('PRAGMA compile_options')->fetchAll(PDO::FETCH_COLUMN); + $this->update_delete_limit = in_array('ENABLE_UPDATE_DELETE_LIMIT', $options); + } + + return $this->update_delete_limit; } public function native_database_types() diff --git a/lib/Column.php b/lib/Column.php index 32a1d370c..4236258fb 100644 --- a/lib/Column.php +++ b/lib/Column.php @@ -20,6 +20,7 @@ class Column const DATETIME = 4; const DATE = 5; const TIME = 6; + const JSON = 7; /** * Map a type to an column type. @@ -32,6 +33,9 @@ class Column 'date' => self::DATE, 'time' => self::TIME, + 'json' => self::JSON, + 'jsonb' => self::JSON, + 'tinyint' => self::INTEGER, 'smallint' => self::INTEGER, 'mediumint' => self::INTEGER, @@ -180,10 +184,63 @@ public function cast($value, $connection) } return $connection->string_to_datetime($value); + case self::JSON: + return static::cast_json($value); } return $value; } + /** + * Casts a column default as reported by the database. + * + * A JSON default that is not a document (an expression such as + * json_object(), or a MySQL 8 literal shown in its charset-prefixed form) + * becomes null rather than failing to load the table's metadata. + * + * @param mixed $value The default value reported by the adapter + * @param Connection $connection The Connection this column belongs to + * @return mixed type-casted default + */ + public function cast_default($value, $connection) + { + if ($this->type == self::JSON && is_string($value)) { + // SQLite reports the default as written in the DDL, quotes included + $value = preg_replace("/^'(.*)'$/s", '$1', $value); + + try { + return static::cast_json($value); + } catch (\JsonException $e) { + return null; + } + } + + return $this->cast($value, $connection); + } + + /** + * Casts a value to a {@link Json} document. + * + * Strings are parsed as JSON text, which keeps code that assigned + * json_encode()'d strings to these columns working. Arrays, scalars and + * objects (stdClass, JsonSerializable) are wrapped as they are. + * + * @param mixed $value The value to cast + * @return Json + * @throws \JsonException if $value is a string that is not valid JSON + */ + public static function cast_json($value) + { + if ($value instanceof Json) { + return $value; + } + + if (is_string($value)) { + return Json::decode($value); + } + + return new Json($value); + } + /** * Sets the $type member variable. * @return mixed diff --git a/lib/Connection.php b/lib/Connection.php index d54901a4e..a341918f3 100644 --- a/lib/Connection.php +++ b/lib/Connection.php @@ -349,7 +349,9 @@ public function query($sql, &$values = []) $sth->setFetchMode(PDO::FETCH_ASSOC); try { - if (!$sth->execute($values)) { + $this->bind_values($sth, $values); + + if (!$sth->execute()) { throw new DatabaseException($this); } } catch (PDOException $e) { @@ -358,6 +360,34 @@ public function query($sql, &$values = []) return $sth; } + /** + * Binds the values with a PDO type matching the PHP type. Passing them to + * execute() binds everything as text, and SQLite does not convert text back + * to a number when the other side has no column affinity, so a condition + * such as "length(title) = ?" bound to 14 never matched there. + * + * @param \PDOStatement $sth Prepared statement + * @param array|null $values Positional (list) or named values + */ + private function bind_values($sth, $values) + { + $position = 0; + + foreach ((array)$values as $key => $value) { + $param = is_int($key) ? ++$position : $key; + + if (is_int($value)) { + $type = PDO::PARAM_INT; + } elseif (is_null($value)) { + $type = PDO::PARAM_NULL; + } else { + $type = PDO::PARAM_STR; + } + + $sth->bindValue($param, $value, $type); + } + } + /** * Execute a query that returns maximum of one row with one field and return it. * diff --git a/lib/Json.php b/lib/Json.php new file mode 100644 index 000000000..5660c08de --- /dev/null +++ b/lib/Json.php @@ -0,0 +1,306 @@ + + * $doc = Document::find(1); + * $doc->payload['theme'] = 'dark'; # flagged dirty immediately + * $doc->payload['tags'][] = 'php'; # nested change, detected on save() + * $doc->save(); + * + * $doc->payload = ['a' => 1]; # plain arrays are wrapped automatically + * $doc->payload->to_array(); # ['a' => 1] + * (string)$doc->payload; # '{"a":1}' + * + * + * Documents are decoded to associative arrays. An empty object ({}) at the top + * level is remembered so that it is written back as {} and not as []. + * + * @package ActiveRecord + */ +class Json implements ArrayAccess, IteratorAggregate, Countable, JsonSerializable +{ + /** + * Flags passed to json_encode() when writing a document to the database. + */ + public static $ENCODE_FLAGS = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION; + + /** + * The decoded value: an array for JSON objects and arrays, or a scalar. + * @var mixed + */ + private $value; + + /** + * True when the top-level value is a JSON object. + * @var boolean + */ + private $object; + + /** + * Encoded form of the value the last time the document was clean. + * @var string + */ + private $clean; + + /** + * Keys created by offsetGet() for a key that did not exist. They are dropped + * again unless something was written through the returned reference. + * @var array + */ + private $vivified = []; + + private $model; + private $attribute_name; + + /** + * @param mixed $value Array, scalar, stdClass/JsonSerializable object or another Json + * @param boolean|null $object Force the top-level value to be encoded as an object; detected when null + */ + public function __construct($value = [], $object = null) + { + if ($value instanceof self) { + $object = $object ?? $value->object; + $value = $value->value; + } elseif (is_object($value)) { + $object = $object ?? true; + $value = json_decode(json_encode($value, static::$ENCODE_FLAGS | JSON_THROW_ON_ERROR), true); + } + + $this->value = $value; + $this->object = $object ?? (is_array($value) && !static::is_list($value)); + $this->mark_clean(); + } + + /** + * True if $array has sequential integer keys starting at zero, i.e. it encodes as a JSON array. + */ + private static function is_list(array $array) + { + return $array === [] || array_keys($array) === range(0, count($array) - 1); + } + + /** + * Builds a document from JSON text. + * + * @param string $json JSON text + * @return static + * @throws JsonException if $json is not valid JSON + */ + public static function decode($json) + { + $value = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + return new static($value, ltrim($json)[0] === '{'); + } + + /** + * Associates this document with a model attribute so that changes flag the model as dirty. + */ + public function attribute_of($model, $attribute_name) + { + $this->model = $model; + $this->attribute_name = $attribute_name; + } + + /** + * True if this document belongs to the given model attribute. + */ + public function is_attribute_of($model, $attribute_name) + { + return $this->model === $model && $this->attribute_name === $attribute_name; + } + + /** + * True if this document is attached to a model attribute. + */ + public function is_attached() + { + return $this->model !== null; + } + + /** + * The decoded value: an array for objects and arrays, a scalar otherwise. + * @return mixed + */ + public function value() + { + $this->prune(); + return $this->value; + } + + /** + * The decoded value as a PHP array. + * @return array + */ + public function to_array() + { + return (array)$this->value(); + } + + /** + * The value as JSON text, the form written to the database. + * @return string + */ + public function to_json() + { + return json_encode($this->jsonSerialize(), static::$ENCODE_FLAGS | JSON_THROW_ON_ERROR); + } + + /** + * True when the top-level value is a JSON object rather than a list or scalar. + * @return boolean + */ + public function is_object() + { + return $this->object; + } + + /** + * True if the value differs from the last clean state, including nested changes + * made through references that bypassed offsetSet(). + * @return boolean + */ + public function is_changed() + { + return $this->to_json() !== $this->clean; + } + + /** + * Records the current value as the clean state. + */ + public function mark_clean() + { + $this->clean = $this->to_json(); + } + + public function offsetExists(mixed $offset): bool + { + return is_array($this->value) && isset($this->value[$offset]); + } + + /** + * Returns by reference so that nested writes ($json['a']['b'] = 1, $json['list'][] = 2) + * modify the document instead of a copy. Those writes are detected by is_changed(). + */ + public function &offsetGet(mixed $offset): mixed + { + if (!is_array($this->value)) { + $this->value = (array)$this->value; + } + + if (!array_key_exists($offset, $this->value)) { + // A slot is needed for writes through the reference ($json['new']['deep'] = 1) + // to land in the document. A plain read leaves it null and prune() removes it, + // so reading a missing key does not change the document. + $this->value[$offset] = null; + $this->vivified[$offset] = true; + } + + return $this->value[$offset]; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + if (!is_array($this->value)) { + $this->value = (array)$this->value; + } + + if ($offset === null) { + $this->value[] = $value; + } else { + $this->value[$offset] = $value; + unset($this->vivified[$offset]); + } + + $this->flag_dirty(); + } + + public function offsetUnset(mixed $offset): void + { + if (is_array($this->value)) { + unset($this->value[$offset], $this->vivified[$offset]); + $this->flag_dirty(); + } + } + + /** + * Drops the keys created by reads of missing offsets that were never written to. + */ + private function prune() + { + foreach ($this->vivified as $offset => $unused) { + if (is_array($this->value) && array_key_exists($offset, $this->value) && $this->value[$offset] === null) { + unset($this->value[$offset]); + } + } + + $this->vivified = []; + } + + public function getIterator(): Traversable + { + return new ArrayIterator($this->to_array()); + } + + public function count(): int + { + return count($this->to_array()); + } + + /** + * Value used by json_encode(). An empty top-level object is returned as an + * object so it serializes to {} rather than []. + */ + public function jsonSerialize(): mixed + { + $value = $this->value(); + + if ($this->object && $value === []) { + return (object)[]; + } + + return $value; + } + + public function __toString(): string + { + return $this->to_json(); + } + + /** + * A cloned document no longer belongs to the model it was copied from. + */ + public function __clone() + { + $this->model = null; + $this->attribute_name = null; + } + + private function flag_dirty() + { + if ($this->model) { + $this->model->flag_dirty($this->attribute_name); + } + } +} diff --git a/lib/Model.php b/lib/Model.php index 85c605ba8..24ba89e04 100644 --- a/lib/Model.php +++ b/lib/Model.php @@ -155,6 +155,22 @@ class Model */ public static $sequence; + /** + * Columns to treat as JSON documents even though the database reports them as + * text. Columns with a native JSON type (MySQL/MariaDB JSON, PostgreSQL json and + * jsonb, SQLite JSON) are detected automatically and need not be listed. + * + * + * class Document extends ActiveRecord\Model { + * static $json_attributes = ['settings']; + * } + * + * + * @see Json + * @var array + */ + public static $json_attributes = []; + /** * Set this to true in your subclass to use caching for this model. * Note that you must also configure a cache object. @@ -270,7 +286,15 @@ public function __construct(array $attributes = [], $guard_attributes = true, $i // initialize attributes applying defaults if (!$instantiating_via_find) { foreach (static::table()->columns as $name => $meta) { - $this->attributes[$meta->inflected_name] = $meta->default; + $default = $meta->default; + + // a JSON default is mutable, so each record needs its own copy + if ($default instanceof Json) { + $default = clone $default; + $default->attribute_of($this, $meta->inflected_name); + } + + $this->attributes[$meta->inflected_name] = $default; } } @@ -452,15 +476,25 @@ public function __wakeup() public function assign_attribute($name, $value) { $table = static::table(); - if (!is_object($value)) { - if (array_key_exists($name, $table->columns)) { - $value = $table->columns[$name]->cast($value, static::connection()); - } else { - $col = $table->get_column_by_inflected_name($name); - if (!is_null($col)) { - $value = $col->cast($value, static::connection()); - } + if (array_key_exists($name, $table->columns)) { + $col = $table->columns[$name]; + } else { + $col = $table->get_column_by_inflected_name($name); + } + + // objects are left alone except for JSON columns, which wrap them in a Json document + if (!is_null($col) && (!is_object($value) || $col->type == Column::JSON)) { + $value = $col->cast($value, static::connection()); + } + + if ($value instanceof Json) { + // a document already held by another model or attribute is copied, + // so that changes flag the right owner as dirty + if ($value->is_attached() && !$value->is_attribute_of($this, $name)) { + $value = clone $value; } + + $value->attribute_of($this, $name); } // convert php's \DateTime to ours @@ -566,6 +600,8 @@ public function flag_dirty($name) */ public function dirty_attributes() { + $this->flag_changed_json(); + if (!$this->__dirty) { return null; } @@ -581,9 +617,23 @@ public function dirty_attributes() */ public function attribute_is_dirty($attribute) { + $this->flag_changed_json(); return $this->__dirty && isset($this->__dirty[$attribute]) && array_key_exists($attribute, $this->attributes); } + /** + * Flags JSON attributes whose documents were modified in place, e.g. through + * nested writes like $model->settings['a']['b'] = 1 that bypass assignment. + */ + private function flag_changed_json() + { + foreach ($this->attributes as $name => $value) { + if ($value instanceof Json && $value->is_changed()) { + $this->flag_dirty($name); + } + } + } + /** * Returns a copy of the model's attributes hash. * @@ -1119,6 +1169,7 @@ private function validate() */ public function is_dirty() { + $this->flag_changed_json(); return empty($this->__dirty) ? false : true; } @@ -1312,6 +1363,12 @@ public function __clone() public function reset_dirty() { $this->__dirty = null; + + foreach ($this->attributes as $value) { + if ($value instanceof Json) { + $value->mark_clean(); + } + } } /** diff --git a/lib/Serialization.php b/lib/Serialization.php index 264e68c48..cac9949b1 100644 --- a/lib/Serialization.php +++ b/lib/Serialization.php @@ -215,6 +215,9 @@ final public function to_a() foreach ($this->attributes as &$value) { if ($value instanceof $date_class) { $value = $value->format(self::$DATETIME_FORMAT); + } elseif ($value instanceof Json) { + // nest the document instead of double-encoding its text + $value = $value->jsonSerialize(); } } return $this->attributes; diff --git a/lib/Serializers/CsvSerializer.php b/lib/Serializers/CsvSerializer.php index 305d59ed8..9c5719cf7 100644 --- a/lib/Serializers/CsvSerializer.php +++ b/lib/Serializers/CsvSerializer.php @@ -39,6 +39,13 @@ private function row() private function to_csv($arr) { + // a CSV cell holds text, so JSON documents go in as JSON text + foreach ($arr as &$value) { + if (is_array($value) || is_object($value)) { + $value = json_encode($value); + } + } + $outstream = fopen('php://temp', 'w'); fputcsv($outstream, $arr, self::$delimiter, self::$enclosure, ""); rewind($outstream); diff --git a/lib/Serializers/XmlSerializer.php b/lib/Serializers/XmlSerializer.php index 8f5c55535..6629b8ddc 100644 --- a/lib/Serializers/XmlSerializer.php +++ b/lib/Serializers/XmlSerializer.php @@ -59,6 +59,9 @@ private function write($data, $tag = null) } if (is_array($value) || is_object($value)) { + // objects (e.g. an empty JSON document) are written like hashes + $value = is_object($value) ? get_object_vars($value) : $value; + if (!is_int(key($value))) { $this->writer->startElement(Utils::denamespace($attr)); $this->write($value); diff --git a/lib/Table.php b/lib/Table.php index 592d1e7b0..0359080a3 100644 --- a/lib/Table.php +++ b/lib/Table.php @@ -100,6 +100,7 @@ public function __construct($class_name) $this->reestablish_connection(false); $this->set_table_name(); $this->get_meta_data(); + $this->set_json_attributes(); $this->set_primary_key(); $this->set_sequence_name(); $this->set_delegates(); @@ -484,6 +485,11 @@ private function &process_data($hash) } else { $value = $this->conn->datetime_to_string($value); } + } elseif ($value instanceof Json) { + $value = $value->to_json(); + } elseif ((is_array($value) || is_object($value)) && isset($this->columns[$name]) && $this->columns[$name]->type == Column::JSON) { + // raw hashes passed straight to insert()/update() without going through a model + $value = Column::cast_json($value)->to_json(); } $ret[$name] = $value; @@ -492,6 +498,22 @@ private function &process_data($hash) return $ret; } + /** + * Marks the columns listed in the model's $json_attributes as JSON so that text + * columns holding JSON documents get the same treatment as native JSON columns. + */ + private function set_json_attributes() + { + foreach ((array)$this->class->getStaticPropertyValue('json_attributes', []) as $name) { + $column = $this->columns[$name] ?? $this->get_column_by_inflected_name($name); + + if ($column && $column->type != Column::JSON) { + $column->type = Column::JSON; + $column->default = $column->cast_default($column->default, $this->conn); + } + } + } + private function set_primary_key() { if (($pk = $this->class->getStaticPropertyValue('pk', null)) || ($pk = $this->class->getStaticPropertyValue('primary_key', null))) { diff --git a/test/ActiveRecordFindTest.php b/test/ActiveRecordFindTest.php index 474d40999..dd430bff0 100644 --- a/test/ActiveRecordFindTest.php +++ b/test/ActiveRecordFindTest.php @@ -84,6 +84,13 @@ public function test_find_all_with_no_bind_values() public function test_find_all_with_empty_array_bind_value_throws_exception() { + if ($this->conn->protocol == 'sqlite') { + // SQLite treats the unbound marker as NULL, so IN(?) with no values + // is a valid query that matches nothing rather than an error. + $this->assertCount(0, Author::find('all', ['conditions' => ['author_id IN(?)', []]])); + return; + } + $this->expectException(DatabaseException::class); $authors = Author::find('all', ['conditions' => ['author_id IN(?)', []]]); $this->assertCount(0, $authors); diff --git a/test/JsonTest.php b/test/JsonTest.php new file mode 100644 index 000000000..fdbfa06b6 --- /dev/null +++ b/test/JsonTest.php @@ -0,0 +1,299 @@ +dirty[] = $name; + } + }; + } + + public function test_wraps_hash_as_object() + { + $json = new Json(['a' => 1, 'b' => [1, 2]]); + $this->assert_true($json->is_object()); + $this->assert_equals(['a' => 1, 'b' => [1, 2]], $json->to_array()); + $this->assert_equals('{"a":1,"b":[1,2]}', $json->to_json()); + $this->assert_equals('{"a":1,"b":[1,2]}', (string)$json); + } + + public function test_wraps_list_as_array() + { + $json = new Json([1, 2, 3]); + $this->assert_false($json->is_object()); + $this->assert_equals('[1,2,3]', $json->to_json()); + } + + public function test_empty_array_encodes_as_list_unless_told_otherwise() + { + $this->assert_equals('[]', (new Json([]))->to_json()); + $this->assert_equals('{}', (new Json([], true))->to_json()); + $this->assert_equals('{}', json_encode(new Json([], true))); + } + + public function test_wraps_scalars() + { + $this->assert_same(5, (new Json(5))->value()); + $this->assert_equals('5', (new Json(5))->to_json()); + $this->assert_equals('"five"', (new Json('five'))->to_json()); + $this->assert_equals('true', (new Json(true))->to_json()); + } + + public function test_wraps_objects() + { + $json = new Json((object)['a' => 1, 'b' => (object)['c' => 2]]); + $this->assert_true($json->is_object()); + $this->assert_equals(['a' => 1, 'b' => ['c' => 2]], $json->to_array()); + + $serializable = new class implements JsonSerializable { + public function jsonSerialize(): mixed + { + return ['from' => 'jsonSerialize']; + } + }; + $this->assert_equals(['from' => 'jsonSerialize'], (new Json($serializable))->to_array()); + } + + public function test_copies_another_document() + { + $original = new Json(['a' => 1]); + $copy = new Json($original); + $copy['a'] = 2; + + $this->assert_equals(1, $original['a']); + $this->assert_equals(2, $copy['a']); + } + + public function test_decode() + { + $json = Json::decode(' {"a": {"b": [1, 2.0, "x", null, true]}} '); + $this->assert_true($json->is_object()); + $this->assert_same(['a' => ['b' => [1, 2.0, 'x', null, true]]], $json->to_array()); + $this->assert_equals('{"a":{"b":[1,2.0,"x",null,true]}}', $json->to_json()); + } + + public function test_decode_preserves_empty_object() + { + $this->assert_equals('{}', Json::decode('{}')->to_json()); + $this->assert_equals('[]', Json::decode('[]')->to_json()); + $this->assert_true(Json::decode('{}')->is_object()); + $this->assert_false(Json::decode('[]')->is_object()); + } + + public function test_decode_scalars() + { + $this->assert_same(42, Json::decode('42')->value()); + $this->assert_same('str', Json::decode('"str"')->value()); + $this->assert_null(Json::decode('null')->value()); + } + + public function test_decode_invalid_json_throws() + { + $this->expectException(JsonException::class); + Json::decode('{not json'); + } + + public function test_array_access() + { + $json = new Json(['a' => 1]); + + $this->assert_true(isset($json['a'])); + $this->assert_false(isset($json['missing'])); + $this->assert_equals(1, $json['a']); + + $json['b'] = 2; + $this->assert_equals(['a' => 1, 'b' => 2], $json->to_array()); + + unset($json['a']); + $this->assert_equals(['b' => 2], $json->to_array()); + $this->assert_false(isset($json['a'])); + } + + public function test_append() + { + $json = new Json([1]); + $json[] = 2; + $this->assert_equals('[1,2]', $json->to_json()); + } + + public function test_nested_writes_go_through() + { + $json = new Json(['nested' => ['count' => 1], 'tags' => ['a']]); + + $json['nested']['count'] = 2; + $json['tags'][] = 'b'; + $json['new']['deep']['key'] = 'value'; + + $this->assert_equals(2, $json['nested']['count']); + $this->assert_equals(['a', 'b'], $json['tags']); + $this->assert_equals(['key' => 'value'], $json['new']['deep']); + } + + public function test_reading_a_missing_key_does_not_change_the_document() + { + $json = new Json(['a' => 1]); + $this->assert_null($json['missing']); + $this->assert_false($json->is_changed()); + $this->assert_equals('{"a":1}', $json->to_json()); + } + + public function test_countable_and_iterable() + { + $json = new Json(['a' => 1, 'b' => 2]); + $this->assert_equals(2, count($json)); + + $seen = []; + foreach ($json as $key => $value) { + $seen[$key] = $value; + } + $this->assert_equals(['a' => 1, 'b' => 2], $seen); + } + + public function test_is_changed_tracks_in_place_modifications() + { + $json = new Json(['nested' => ['count' => 1]]); + $this->assert_false($json->is_changed()); + + $json['nested']['count'] = 2; + $this->assert_true($json->is_changed()); + + $json->mark_clean(); + $this->assert_false($json->is_changed()); + + $json['nested']['count'] = 1; + $this->assert_true($json->is_changed()); + } + + public function test_offset_set_flags_the_model_dirty() + { + $model = $this->model_spy(); + $json = new Json(['a' => 1]); + $json->attribute_of($model, 'payload'); + + $json['a'] = 2; + $this->assert_equals(['payload'], $model->dirty); + + unset($json['a']); + $this->assert_equals(['payload', 'payload'], $model->dirty); + } + + public function test_attachment_queries() + { + $model = $this->model_spy(); + $json = new Json(); + $this->assert_false($json->is_attached()); + + $json->attribute_of($model, 'payload'); + $this->assert_true($json->is_attached()); + $this->assert_true($json->is_attribute_of($model, 'payload')); + $this->assert_false($json->is_attribute_of($model, 'other')); + $this->assert_false($json->is_attribute_of($this->model_spy(), 'payload')); + } + + public function test_clone_detaches_from_model() + { + $model = $this->model_spy(); + $json = new Json(['a' => 1]); + $json->attribute_of($model, 'payload'); + + $copy = clone $json; + $copy['a'] = 2; + + $this->assert_false($copy->is_attached()); + $this->assert_equals([], $model->dirty); + $this->assert_equals(1, $json['a']); + } + + public function test_json_serializable_nests_in_json_encode() + { + $json = new Json(['a' => 1]); + $this->assert_equals('{"doc":{"a":1}}', json_encode(['doc' => $json])); + } + + public function test_encode_flags_keep_unicode_and_slashes() + { + $json = new Json(['name' => 'José', 'url' => 'http://example.com/a']); + $this->assert_equals('{"name":"José","url":"http://example.com/a"}', $json->to_json()); + } + + public function test_map_raw_type() + { + $column = new Column(); + + $column->raw_type = 'json'; + $this->assert_equals(Column::JSON, $column->map_raw_type()); + + $column->raw_type = 'jsonb'; + $this->assert_equals(Column::JSON, $column->map_raw_type()); + } + + public function test_cast_string() + { + $column = new Column(); + $column->type = Column::JSON; + + $value = $column->cast('{"a":1}', null); + $this->assert_true($value instanceof Json); + $this->assert_equals(['a' => 1], $value->to_array()); + } + + public function test_cast_array_and_object() + { + $column = new Column(); + $column->type = Column::JSON; + + $this->assert_equals(['a' => 1], $column->cast(['a' => 1], null)->to_array()); + $this->assert_equals(['a' => 1], $column->cast((object)['a' => 1], null)->to_array()); + $this->assert_same(5, $column->cast(5, null)->value()); + } + + public function test_cast_keeps_documents_and_nulls() + { + $column = new Column(); + $column->type = Column::JSON; + + $json = new Json(['a' => 1]); + $this->assert_same($json, $column->cast($json, null)); + $this->assert_null($column->cast(null, null)); + } + + public function test_cast_invalid_string_throws() + { + $column = new Column(); + $column->type = Column::JSON; + + $this->expectException(JsonException::class); + $column->cast('{oops', null); + } + + public function test_cast_default() + { + $column = new Column(); + $column->type = Column::JSON; + + $this->assert_equals('{}', $column->cast_default('{}', null)->to_json()); + $this->assert_equals('{"a":1}', $column->cast_default("'{\"a\":1}'", null)->to_json()); + $this->assert_null($column->cast_default('json_object()', null)); + $this->assert_null($column->cast_default(null, null)); + + // other types are unaffected + $column->type = Column::INTEGER; + $this->assert_same(3, $column->cast_default('3', null)); + } +} diff --git a/test/MysqlJsonTest.php b/test/MysqlJsonTest.php new file mode 100644 index 000000000..ffb58d734 --- /dev/null +++ b/test/MysqlJsonTest.php @@ -0,0 +1,17 @@ +connection_name = 'mysql'; + parent::setUp(); + } + + protected function expected_raw_type() + { + return 'json'; + } +} diff --git a/test/PgsqlJsonTest.php b/test/PgsqlJsonTest.php new file mode 100644 index 000000000..0cff094c6 --- /dev/null +++ b/test/PgsqlJsonTest.php @@ -0,0 +1,17 @@ +connection_name = 'pgsql'; + parent::setUp(); + } + + protected function expected_raw_type() + { + return 'jsonb'; + } +} diff --git a/test/SqliteJsonTest.php b/test/SqliteJsonTest.php new file mode 100644 index 000000000..aaa0b0e39 --- /dev/null +++ b/test/SqliteJsonTest.php @@ -0,0 +1,23 @@ +connection_name = 'sqlite'; + parent::setUp(); + } + + protected function expected_raw_type() + { + return 'json'; + } +} diff --git a/test/fixtures/documents.csv b/test/fixtures/documents.csv new file mode 100644 index 000000000..e9dfc9cf2 --- /dev/null +++ b/test/fixtures/documents.csv @@ -0,0 +1,3 @@ +id,title,payload,metadata,settings +1,"Config","{""theme"":""dark"",""tags"":[""php"",""sql""],""nested"":{""count"":1}}","{}","{""notify"":true}" +2,"List","[1,2,3]","{""a"":{""b"":2}}","[]" diff --git a/test/helpers/AdapterTest.php b/test/helpers/AdapterTest.php index 746b2bb6d..a33f4c2ef 100644 --- a/test/helpers/AdapterTest.php +++ b/test/helpers/AdapterTest.php @@ -104,6 +104,12 @@ public function test_connect_failed() public function test_connect_with_port() { + if ($this->conn->protocol == 'sqlite') { + // a file path has neither user nor port + $this->expectNotToPerformAssertions(); + return; + } + $config = Config::instance(); $name = $config->get_default_connection(); $url = parse_url($config->get_connection($name)); @@ -116,9 +122,7 @@ public function test_connect_with_port() } $connection_string = "{$connection_string}@{$url['host']}:$port{$url['path']}"; - if ($this->conn->protocol != 'sqlite') { - Connection::instance($connection_string); - } + Connection::instance($connection_string); $this->expectNotToPerformAssertions(); } diff --git a/test/helpers/DatabaseTest.php b/test/helpers/DatabaseTest.php index 2a7985744..a6a8a4f2e 100644 --- a/test/helpers/DatabaseTest.php +++ b/test/helpers/DatabaseTest.php @@ -35,6 +35,15 @@ public function setUp(): void if ($this->connection_name == 'sqlite' || $config->get_default_connection() == 'sqlite') { // need to create the db. the adapter specifically does not create it for us. static::$db = substr(Config::instance()->get_connection('sqlite'), 9); + + if (!file_exists(static::$db)) { + // A previous test class deleted the file (SqliteAdapterTest does), so the + // cached connection points at a dead file and the loader still thinks the + // schema exists. Reconnect and rebuild from scratch. + ConnectionManager::drop_connection('sqlite'); + DatabaseLoader::$instances['sqlite'] = 0; + } + new SQLite3(static::$db); } diff --git a/test/helpers/JsonModelTestCase.php b/test/helpers/JsonModelTestCase.php new file mode 100644 index 000000000..fc9d1c0aa --- /dev/null +++ b/test/helpers/JsonModelTestCase.php @@ -0,0 +1,399 @@ +connection_name, PDO::getAvailableDrivers()) || + Config::instance()->get_connection($this->connection_name) == 'skip' + ) { + $this->mark_test_skipped($this->connection_name . ' drivers are not present'); + } else { + parent::setUp(); + } + } + + public function tearDown(): void + { + if ($this->status()->asString() == "skipped") { + return; + } + + parent::tearDown(); + } + + /** + * The raw text stored in a column, bypassing the model. + */ + protected function raw_column($column, $id) + { + $values = [$id]; + return $this->conn->query_and_fetch_one("SELECT $column FROM documents WHERE id = ?", $values); + } + + /** + * Normalises JSON text so databases that reformat documents (MySQL adds spaces) compare equal. + */ + protected function assert_json_text($expected, $actual) + { + $normalise = function ($json) { + return json_encode(json_decode($json, false, 512, JSON_THROW_ON_ERROR)); + }; + $this->assert_same($normalise($expected), $normalise($actual)); + } + + /** + * The JSON type this database reports for the documents.payload column. + */ + abstract protected function expected_raw_type(); + + public function test_native_json_columns_are_detected() + { + $columns = $this->conn->columns('documents'); + + $this->assert_equals($this->expected_raw_type(), $columns['payload']->raw_type); + $this->assert_equals(Column::JSON, $columns['payload']->type); + $this->assert_equals(Column::JSON, $columns['metadata']->type); + + // settings is text at the database level and only JSON through the model + $this->assert_equals(Column::STRING, $columns['settings']->type); + $this->assert_equals(Column::JSON, Document::table()->columns['settings']->type); + } + + public function test_read_decodes_object() + { + $doc = Document::find(1); + + $this->assert_true($doc->payload instanceof Json); + $this->assert_equals('dark', $doc->payload['theme']); + $this->assert_equals(['php', 'sql'], $doc->payload['tags']); + $this->assert_same(1, $doc->payload['nested']['count']); + $this->assert_equals( + ['theme' => 'dark', 'tags' => ['php', 'sql'], 'nested' => ['count' => 1]], + $doc->payload->to_array() + ); + } + + public function test_read_decodes_list() + { + $doc = Document::find(2); + + $this->assert_same([1, 2, 3], $doc->payload->to_array()); + $this->assert_false($doc->payload->is_object()); + $this->assert_equals(3, count($doc->payload)); + } + + public function test_found_record_is_not_dirty() + { + $doc = Document::find(1); + + // reading, including nested reads, must not flag anything + $doc->payload['theme']; + $doc->payload['nested']['count']; + $doc->payload['missing']; + + $this->assert_false($doc->is_dirty()); + $this->assert_null($doc->dirty_attributes()); + } + + public function test_save_without_changes_does_not_update() + { + $doc = Document::find(1); + $before = Document::table()->last_sql; + + $this->assert_true($doc->save()); + $this->assert_equals($before, Document::table()->last_sql); + } + + public function test_assign_array_round_trips() + { + $doc = Document::find(1); + $doc->payload = ['a' => 1, 'b' => [true, null, 1.5, 'x'], 'c' => ['d' => 'e']]; + + $this->assert_true($doc->payload instanceof Json); + $this->assert_true($doc->attribute_is_dirty('payload')); + $this->assert_true($doc->save()); + + $reloaded = Document::find(1); + // MySQL reorders object keys, so compare without regard to order + $this->assert_equals(['a' => 1, 'b' => [true, null, 1.5, 'x'], 'c' => ['d' => 'e']], $reloaded->payload->to_array()); + $this->assert_same(1, $reloaded->payload['a']); + $this->assert_same([true, null, 1.5, 'x'], $reloaded->payload['b']); + $this->assert_json_text('{"a":1,"b":[true,null,1.5,"x"],"c":{"d":"e"}}', $this->raw_column('payload', 1)); + } + + public function test_assign_encoded_string_still_works() + { + $doc = Document::find(1); + $doc->payload = json_encode(['legacy' => true]); + + $this->assert_true($doc->payload instanceof Json); + $this->assert_true($doc->payload['legacy']); + $doc->save(); + + $this->assert_same(['legacy' => true], Document::find(1)->payload->to_array()); + } + + public function test_assign_object() + { + $doc = Document::find(1); + $doc->payload = (object)['from' => 'stdClass']; + $doc->save(); + + $this->assert_same(['from' => 'stdClass'], Document::find(1)->payload->to_array()); + } + + public function test_assign_invalid_json_string_throws() + { + $doc = Document::find(1); + + $this->expectException(JsonException::class); + $doc->payload = '{not json'; + } + + public function test_top_level_write_flags_dirty() + { + $doc = Document::find(1); + $doc->payload['theme'] = 'light'; + + $this->assert_true($doc->attribute_is_dirty('payload')); + $this->assert_equals(['payload'], array_keys($doc->dirty_attributes())); + $doc->save(); + + $this->assert_equals('light', Document::find(1)->payload['theme']); + } + + public function test_nested_write_is_saved() + { + $doc = Document::find(1); + $doc->payload['nested']['count'] = 2; + $doc->payload['tags'][] = 'json'; + $doc->payload['new']['deep'] = true; + + $this->assert_true($doc->is_dirty()); + $doc->save(); + + $reloaded = Document::find(1); + $this->assert_same(2, $reloaded->payload['nested']['count']); + $this->assert_equals(['php', 'sql', 'json'], $reloaded->payload['tags']); + $this->assert_true($reloaded->payload['new']['deep']); + } + + public function test_unset_key_is_saved() + { + $doc = Document::find(1); + unset($doc->payload['theme']); + $doc->save(); + + $this->assert_false(isset(Document::find(1)->payload['theme'])); + } + + public function test_model_is_clean_after_save() + { + $doc = Document::find(1); + $doc->payload['nested']['count'] = 2; + $doc->save(); + + $this->assert_false($doc->is_dirty()); + + $doc->payload['nested']['count'] = 3; + $this->assert_true($doc->is_dirty()); + } + + public function test_null_document() + { + $doc = Document::create(['title' => 'nothing']); + $this->assert_null(Document::find($doc->id)->payload); + + $doc = Document::find(1); + $doc->payload = null; + $doc->save(); + $this->assert_null(Document::find(1)->payload); + $this->assert_null($this->raw_column('payload', 1)); + } + + public function test_empty_object_and_empty_list_round_trip() + { + $doc = Document::find(1); + $this->assert_true($doc->metadata->is_object()); + $this->assert_equals('{}', $doc->metadata->to_json()); + + $doc->metadata = Json::decode('{}'); + $doc->payload = []; + $doc->save(); + + $this->assert_json_text('{}', $this->raw_column('metadata', 1)); + $this->assert_json_text('[]', $this->raw_column('payload', 1)); + $this->assert_true(Document::find(1)->metadata->is_object()); + } + + public function test_scalar_document() + { + $doc = Document::find(1); + $doc->payload = 42; + $doc->save(); + + $this->assert_same(42, Document::find(1)->payload->value()); + } + + public function test_unicode_round_trips() + { + $doc = Document::find(1); + $doc->payload = ['name' => 'José Pérez', 'city' => '東京', 'emoji' => '😀']; + $doc->save(); + + $this->assert_equals(['name' => 'José Pérez', 'city' => '東京', 'emoji' => '😀'], Document::find(1)->payload->to_array()); + } + + public function test_create_with_array() + { + $doc = Document::create(['title' => 'created', 'payload' => ['k' => 'v'], 'settings' => ['notify' => false]]); + + $reloaded = Document::find($doc->id); + $this->assert_same(['k' => 'v'], $reloaded->payload->to_array()); + $this->assert_same(['notify' => false], $reloaded->settings->to_array()); + } + + public function test_update_attributes() + { + $doc = Document::find(1); + $doc->update_attributes(['payload' => ['z' => 1]]); + + $this->assert_same(['z' => 1], Document::find(1)->payload->to_array()); + } + + public function test_new_records_get_their_own_default_document() + { + $a = new Document(); + $b = new Document(); + + if ($a->metadata === null) { + // this database does not allow a default on the column + $this->assert_null($b->metadata); + return; + } + + $this->assert_true($a->metadata instanceof Json); + $this->assert_equals('{}', $a->metadata->to_json()); + $this->assert_false($a->metadata === $b->metadata); + + $a->metadata['only'] = 'a'; + $this->assert_equals('{}', $b->metadata->to_json()); + $this->assert_true($a->attribute_is_dirty('metadata')); + + $a->save(); + $this->assert_equals(['only' => 'a'], Document::find($a->id)->metadata->to_array()); + } + + public function test_json_attributes_opt_in_for_text_column() + { + $doc = Document::find(1); + + $this->assert_true($doc->settings instanceof Json); + $this->assert_true($doc->settings['notify']); + + $doc->settings['notify'] = false; + $doc->settings['level'] = 3; + $doc->save(); + + $this->assert_same(['notify' => false, 'level' => 3], Document::find(1)->settings->to_array()); + $this->assert_json_text('{"notify":false,"level":3}', $this->raw_column('settings', 1)); + } + + public function test_assigning_a_document_from_another_model_copies_it() + { + $a = Document::find(1); + $b = Document::find(2); + + $b->payload = $a->payload; + $b->payload['theme'] = 'light'; + + $this->assert_equals('dark', $a->payload['theme']); + $this->assert_false($a->is_dirty()); + $this->assert_true($b->is_dirty()); + + $b->save(); + $this->assert_equals('light', Document::find(2)->payload['theme']); + $this->assert_equals('dark', Document::find(1)->payload['theme']); + } + + public function test_assigning_between_attributes_copies_it() + { + $doc = Document::find(1); + $doc->metadata = $doc->payload; + $doc->metadata['theme'] = 'light'; + + $this->assert_equals('dark', $doc->payload['theme']); + $this->assert_equals('light', $doc->metadata['theme']); + } + + public function test_reload_discards_changes() + { + $doc = Document::find(1); + $doc->payload['theme'] = 'light'; + $doc->payload['nested']['count'] = 9; + $doc->reload(); + + $this->assert_equals('dark', $doc->payload['theme']); + $this->assert_same(1, $doc->payload['nested']['count']); + $this->assert_false($doc->is_dirty()); + } + + public function test_to_json_nests_the_document() + { + $decoded = json_decode(Document::find(1)->to_json(), true); + + $this->assert_equals('dark', $decoded['payload']['theme']); + $this->assert_equals(['php', 'sql'], $decoded['payload']['tags']); + $this->assert_same([], $decoded['metadata']); + $this->assert_true(is_array($decoded['payload'])); + $this->assert_true($decoded['settings']['notify']); + $this->assert_equals('{}', json_encode(json_decode(Document::find(1)->to_json())->metadata)); + } + + public function test_to_array_returns_plain_arrays() + { + $array = Document::find(1)->to_array(); + + $this->assert_true(is_array($array['payload'])); + $this->assert_equals(['theme' => 'dark', 'tags' => ['php', 'sql'], 'nested' => ['count' => 1]], $array['payload']); + $this->assert_same(['notify' => true], $array['settings']); + } + + public function test_to_xml_nests_the_document() + { + $xml = Document::find(1)->to_xml(); + + $this->assert_true(strpos($xml, 'dark') !== false); + $this->assert_true(strpos($xml, '1') !== false); + } + + public function test_find_by_title_keeps_working() + { + $doc = Document::find_by_title('List'); + $this->assert_same([1, 2, 3], $doc->payload->to_array()); + } + + public function test_table_update_with_raw_array() + { + $data = ['payload' => ['raw' => true]]; + Document::table()->update($data, ['id' => 2]); + + $this->assert_same(['raw' => true], Document::find(2)->payload->to_array()); + } +} diff --git a/test/models/Document.php b/test/models/Document.php new file mode 100644 index 000000000..50f8711a9 --- /dev/null +++ b/test/models/Document.php @@ -0,0 +1,11 @@ +