Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
60 changes: 59 additions & 1 deletion lib/Adapters/MysqlAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion lib/Adapters/PgsqlAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 16 additions & 2 deletions lib/Adapters/SqliteAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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()
Expand Down
57 changes: 57 additions & 0 deletions lib/Column.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
32 changes: 31 additions & 1 deletion lib/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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.
*
Expand Down
Loading