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 @@
+