Osm Admin: ALTER TABLE. Non-NULL Data Conversion. Query Test Suite
2 years ago ∙ 5 minutes read
Implemented in Osm Admin:
- new ALTER TABLE algorithm
- NULL to non-NULL data conversion
- new query test suite
More details:
New ALTER TABLE Algorithm
Previously, I came to a conclusion that a table migration should consist of four steps: pre-migration, conversion, validation, and post-migration.
It's not exactly true. If a table is new, there is no data to be converted and validated, and, hence, there is no need to issue DDL statements twice. However, if a table already exists, all these steps are needed.
Diff\Table::alter()
These four steps are invoked in the alter()
method:
// Osm\Admin\Schema\Diff\Table
protected function alter(): void {
$this->preAlter();
$this->convert();
$this->validate();
$this->postAlter();
}
The preAlter()
and postAlter()
methods are very similar:
protected function preAlter(): void {
if ($this->requires_pre_alter) {
$this->db->alter($this->new->table_name, function(Blueprint $table) {
foreach ($this->properties as $property) {
if (!$property->new->explicit) {
continue;
}
$property->migrate($property->old?->explicit
? Property::PRE_ALTER
: Property::CREATE, $table);
}
});
}
}
protected function postAlter(): void {
if ($this->requires_post_alter) {
$this->db->alter($this->new->table_name, function(Blueprint $table) {
foreach ($this->properties as $property) {
if (!$property->new->explicit) {
continue;
}
if ($property->old?->explicit) {
$property->migrate(Property::POST_ALTER, $table);
}
}
});
}
}
For the record, table creation has changed a bit as well:
protected function create(): void {
$this->db->create($this->new->table_name, function(Blueprint $table) {
foreach ($this->properties as $property) {
if (!$property->new->explicit) {
continue;
}
$property->migrate(Property::CREATE, $table);
}
$table->json('_data')->nullable();
$table->json('_overrides')->nullable();
});
}
Property Migration Mode
As you can see, Property::migrate()
method is called with additional mode parameter:
Property::CREATE
- create new propertyProperty::PRE_ALTER
- modify property in the pre-alter phaseProperty::POST_ALTER
- modify property in the post-alter phase
Here is how it looks:
public function migrate(string $mode, Blueprint $table = null): bool {
throw new NotImplemented($this);
}
require_pre/post_alter
Another change in this method is that it's used to determine is anything should be migrated at all. If so, it returns true
. While detecting the need for migration, Osm Admin passes null
to the second parameter.
Here is how it's used:
protected function get_requires_pre_alter(): bool {
foreach ($this->properties as $property) {
if (!$property->new->explicit) {
continue;
}
if (!$property->old?->explicit) {
return true;
}
if ($property->migrate(Property::PRE_ALTER)) {
return true;
}
}
return false;
}
protected function get_requires_post_alter(): bool {
foreach ($this->properties as $property) {
if (!$property->new->explicit) {
continue;
}
if (!$property->old?->explicit) {
continue;
}
if ($property->migrate(Property::POST_ALTER)) {
return true;
}
}
return false;
}
Property Migrations
Property::migrate()
is different for every property type. For example, string
properties are migrated as follows:
public function migrate(string $mode, Blueprint $table = null): bool {
// if it's a new property, migration should run no matter what
$run = $mode === static::CREATE;
$column = $this->column($table);
$run = $this->nullable($mode, $column) || $run;
$this->change($mode, $column);
return $run;
}
nullable()
And Other Column Attributes
Property::migrate()
analyze every column attribute changes and add migrations accordingly. For example, here is how column nullability is migrated:
protected function nullable(string $mode, ?ColumnDefinition $column): bool {
$changed = $mode === static::CREATE ||
$this->old->actually_nullable != $this->new->actually_nullable;
// defer conversion from nullable to non-nullable from pre-alter
// to post-alter phase
$deferred = $mode !== static::CREATE &&
$this->old->actually_nullable &&
!$this->new->actually_nullable;
$column?->nullable($deferred
? $mode === static::PRE_ALTER
: $this->new->actually_nullable);
return match($mode) {
static::CREATE => true,
static::PRE_ALTER => $changed && !$deferred,
static::POST_ALTER => $changed && $deferred,
};
}
Data Conversions
All good! I've split ALTER migration into "pre" and "post" phases, and they behave under debugger as expected. Now it's time to implement data conversion between these steps.
Now, it's pretty empty:
// Osm\Admin\Schema\Diff\Table
protected function convert(): void {
}
Main Idea
This method should run an UPDATE:
UPDATE products
SET description = IF(description IS NULL, '-', description)
query()
formula syntax should be something like:
query(Product::class)
->select("description ?? '-' AS description")
->bulkUpdate();
The current update()
method only accepts literals, so it can't be used.
Table::convert()
Before going into the bulkUpdate()
SQL generation, let's prepare the query in the table migration.
Here is goes:
// Osm\Admin\Schema\Diff\Table
protected function convert(): void {
$query = Query::new(['table' => $this->new]);
if ($this->requires_convert) {
foreach ($this->properties as $property) {
$property->convert($query);
}
$query->bulkUpdate();
}
}
The same principle of one method answering two questions is used here. When a $query
parameter is provided, $property->convert()
method prepares the conversion query. If it's not provided, it returns true
if a property requires data conversion:
protected function get_requires_convert(): bool {
foreach ($this->properties as $property) {
if ($property->convert()) {
return true;
}
}
return false;
}
Property::convert()
Property::convert()
method is different for every method. For example, the string
type:
public function convert(Query $query = null): bool {
$formula = $this->new->name;
$formula = $this->convertToNonNull($formula);
if ($query && $formula !== $this->new->name) {
$query->select("{$formula} AS {$this->new->name}");
}
return $formula !== $this->new->name;
}
Conversion Triggers
This method check various conversion triggers, and if it detects one, a formula is added to the query, and the convert()
method returns true
. For example, here is how Osm Admin detects that nullable property becomes non-nullable:
protected function convertToNonNull(string $formula): string {
if (!$this->old) {
return $formula;
}
$makeNonNull = $this->old->actually_nullable &&
!$this->new->actually_nullable;
return $makeNonNull
? "{$formula} ?? {$this->non_null_formula}"
: $formula;
}
Notice. For conversion, it doesn't matter if a property is explicit - it works both with explicit columns, and properties stored in the _data
JSON column.
Query Test Suite
Chicken-Egg Paradox
As with well-known chicken-egg paradox, I have a dilemma. Migration unit tests assume that formula queries work well. Formula query unit tests assume that migrations work well.
The solution is to test formula queries on a schema that is migrated without data conversion, that is, that only use CREATE TABLE
statements.
Let's create a schema for testing queries:
<?php
namespace Osm\Admin\Samples\Queries\V001;
use Osm\Admin\Schema\Attributes\Fixture;
use Osm\Admin\Schema\Record;
use Osm\Admin\Schema\Attributes\Explicit;
/**
* @property ?string $description #[Explicit]
*
* @uses Explicit
*/
#[Fixture]
class Product extends Record
{
}
It's quite simple now, I will extend it as needed.
Bootstrap Script
Testing queries requires a specific preparation, or how they call it, a bootstrap script:
// tests_queries/bootstrap.php
<?php
declare(strict_types=1);
use Osm\Admin\Samples\App;
use Osm\Admin\Schema\Schema;
use Osm\Runtime\Apps;
require 'vendor/autoload.php';
umask(0);
try {
Apps::$project_path = dirname(__DIR__);
Apps::compile(App::class);
Apps::run(Apps::create(App::class), function(App $app) {
$app->cache->clear();
$app->migrations()->fresh();
$app->migrations()->up();
$app->schema = $app->cache->get('schema', fn() =>
Schema::new([
'fixture_class_name' =>
\Osm\Admin\Samples\Queries\V001\Product::class,
'fixture_version' => 1,
])->parse()
);
$app->schema->migrate();
});
}
catch (Throwable $e) {
echo "{$e->getMessage()}\n{$e->getTraceAsString()}\n";
throw $e;
}
Configuration
The next step is to create a PHPUnit configuration file:
// phpunit_queries.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/4.1/phpunit.xsd"
colors="true"
backupGlobals="false"
bootstrap="tests_queries/bootstrap.php"
stopOnFailure="true">
<testsuite name="all">
<directory prefix="test_" suffix=".php">tests_queries</directory>
</testsuite>
</phpunit>
Namespace Root
Then register a namespace root in the composer.json
file:
{
...
"autoload-dev": {
"psr-4": {
...
"Osm\\Admin\\TestsQueries\\": "tests_queries/",
...
}
}
}
And run composer update
.
First Test
Create the first test:
<?php
namespace Osm\Admin\TestsQueries;
use Osm\Admin\Samples\Generics\Item;
use Osm\Admin\Samples\Queries\V001\Product;
use Osm\Framework\TestCase;
use function Osm\query;
class test_01_sql_generation extends TestCase
{
public string $app_class_name = \Osm\Admin\Samples\App::class;
public bool $use_db = true;
public function test_zero_count(): void {
// GIVEN a schema defined in the `Osm\Admin\Samples\Queries\V001`
// namespace
// WHEN you count records in an empty table
$count = query(Product::class)
->value("COUNT() AS count");
// THEN it's 0
$this->assertEquals(0, $count);
}
}
Here, the use_db
flag runs every test in a transaction that is rolled back when the test ends.
Check if the test passes:
vendor/bin/phpunit -c phpunit_queries.xml
And, yes, it does!