Skip to content

Add first-class JSON columns and fix the SQLite test run - #2

Merged
twoixter merged 1 commit into
masterfrom
feature/json-columns
Sep 10, 2026
Merged

twoixter merged 1 commit into
masterfrom
feature/json-columns

Conversation

@twoixter

Copy link
Copy Markdown
Member

Summary

JSON columns (MySQL/MariaDB JSON, PostgreSQL json/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; nested in-place changes are detected on save() against a clean snapshot. A top-level {} survives the round trip, and reading a missing key does not alter the document.
  • Column::cast() parses strings as JSON text, so existing 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, 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.
  • README gains a "JSON columns" section.

SQLite as the default adapter

The suite now passes with PHPAR_ADAPTER=sqlite (master had 147 errors and 2 failures):

  • The SQLite adapter only claims ORDER BY/LIMIT support on UPDATE/DELETE when the build has SQLITE_ENABLE_UPDATE_DELETE_LIMIT. Debian/Ubuntu builds (CI) have it; macOS does not, so those two tests skip there.
  • 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. Booleans deliberately stay text so PostgreSQL keeps accepting true for integer columns.
  • DatabaseTest rebuilds the SQLite schema when a previous class deleted the file. Two tests that assumed a network URL, or a database that rejects IN() with no values, handle SQLite explicitly.

Known limits

  • Documents decode to associative arrays, so a nested empty object is written back as []; only the top-level {} is preserved.
  • MySQL reorders object keys and normalises whitespace; the tests compare without regard to order.

Tests

  • test/JsonTest.php covers the value object and casts without a database.
  • MysqlJsonTest, PgsqlJsonTest and SqliteJsonTest run the same model-level cases (test/helpers/JsonModelTestCase.php) against each database in every job through a new documents table, independently of the default adapter.
  • No existing test was changed except the three SQLite accommodations above.

Verified locally: 911 tests green on MySQL 5.7 and 8.4, MariaDB 11.8, PostgreSQL 18 and SQLite, with each of mysql, pgsql and sqlite as the default adapter, and phpcs clean.

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.
@twoixter
twoixter merged commit 2b28562 into master Sep 10, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant