Anorm Another ORM logo Anorm Another ORM

Transformers

A transformer converts a value between the shape a model wants and the shape a column holds. It is registered per column on the mapper:

$mapper->transformers = [
    'occurred_at'    => new SqlDateTimeTransform(),
    'email_verified' => new BooleanTransform(),
    'options'        => new JsonArrayTransform(),
];

The key is the column name, not the property name, because a transformer is about storage. Every read goes through DataMapper::readArray() and every write through DataMapper::write(), so a registered transformer is applied on both paths and nowhere else has to know.

The contract

interface TransformInterface
{
    public function txDatabaseToModel($value);
    public function txModelToDatabase($value);
}

Both directions are given whatever the other side actually holds, including null. A transformer that does not handle null will be handed it the first time a column is empty, so handle it deliberately: a nullable column has a third state, and collapsing it to false, 0 or an epoch date loses the one that means “not set”.

txModelToDatabase() returning null writes SQL NULL rather than a quoted empty string.

Saying what column the format needs

A transformer has already decided how the value is stored, which makes it the one part of a mapper that knows the column type rather than guessing at it. It can say so:

interface ColumnTypeHintInterface
{
    public function sqlColumnType();   // e.g. 'DATETIME NULL', or null
}

Implement it alongside TransformInterface and the answer is used for two things: the column dynamic mode creates, and the column anorm schema:diff expects to find. Both ask the same question in the same place, so they cannot drift apart.

This is the difference between knowing a column type and inferring one, and it is a difference in kind rather than in confidence.

Reading a property’s declared type or sampling its value is inference: a reading of evidence, which may be right and cannot be certain. /** @var string */ on a property holding an ISO datetime is a true statement that implies the wrong column. A sample is whatever happened to be written first, and the first value to reach a date column is very often null — which is how a timestamp ends up VARCHAR(128) and stays that way.

A transformer is not evidence about the format. It is the format. SqlDateTimeTransform does not think the column is a date; it writes one. So its answer does not depend on what a property was annotated with or on which value arrived first, and it is taken above both.

It is a separate interface so that implementing it stays optional. Not every transformer knows its column type — FunctionTransform wraps two arbitrary closures and genuinely cannot — and a transformer written before this existed keeps working untouched. Returning null from sqlColumnType() means the same thing as not implementing it: no opinion, fall through to the declared type and then the sample.

Writing one

Nothing about a transformer has to live in Anorm. A class the core knows nothing about is the normal case, and declaring its column type works exactly the same:

namespace App\Transform;

use Anorm\Schema\ColumnTypeHintInterface;
use Anorm\TransformInterface;
use Moment\Moment;

class MomentTransform implements TransformInterface, ColumnTypeHintInterface
{
    public function txDatabaseToModel($value)
    {
        return $value === null ? null : new Moment($value);
    }

    public function txModelToDatabase($value)
    {
        return $value === null ? null : $value->format('Y-m-d H:i:s');
    }

    public function sqlColumnType()
    {
        return 'DATETIME NULL';
    }
}

Register it and the column is DATETIME from the first write, even when the first value written is null:

$mapper->transformers = ['occurred_at' => new MomentTransform()];
CREATE TABLE `events` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `occurred_at` datetime DEFAULT NULL,
  ...
)

Run schema:diff against a legacy table where that column is still text and it is reported, because the transformer is what the model implies:

[WARNING] events.occurred_at — is VARCHAR(128), model implies DATETIME NULL — dates stored as text

The ones that ship

  Model side Column  
SqlDateTimeTransform \DateTime DATETIME NULL Format is constructor-configurable
BooleanTransform bool TINYINT(1) NULL NULL stays NULL
JsonArrayTransform array TEXT  
FunctionTransform anything — Two closures; states no column type

Where a transformer sits among the other answers

When dynamic mode creates a column, or schema:diff works out what a model implies, the sources are consulted in this order:

  Source  
1 $mapper->columnDefinitions told outright
2 a transformer implementing ColumnTypeHintInterface known
3 the type the model declares for the property inferred
4 a value sampled from the model inferred
5 VARCHAR(128) nothing at all

ColumnIntent records which of these an answer came from as well as the answer itself, which is why schema:diff can report a difference from an informed source as drift and stay quiet about a column that nothing has an opinion on. See schema modes for what each source is worth.