Changing the Database Schema

5 views Markdown

Your tables are created and migrated by your own code, at a moment the platform gives you. The same code has to be safe to run again.

Overview

There is no migration directory and no version file to bump. A module that needs storage creates it in its own lifecycle, when it is enabled. It brings an older installation forward in the same place. One method holds both the create path and the upgrade path. Every statement in it needs a condition that makes the second run a no-op.

The core's own tables are not yours to change. Adding a column to a table the product ships is a change an upgrade will undo. Worse, it is one an upgrade may collide with. Store what you need in your own table and join.

Prerequisites

  • A module, since this is where table ownership lives. A hook listener that needs storage belongs in a module for the same reason.
  • A table name that cannot collide with the product's. Prefix it with your module's name.

Walkthrough

Create When the Module Is Enabled

  1. Declare the enable step of your module's lifecycle and call your schema check from it.
  2. In the check, ask whether the table exists before creating it. Enabling a module that is already installed must not fail.
  3. Enable the module in the panel and confirm the table appears.

Migrate in the Same Method

  1. When the table already exists, use the same method to bring an older shape forward. Ask whether the old column is still there, and change it only if it is.
  2. Guard every step by its own condition rather than by a stored version number. An installation that skipped a release then still converges.
  3. Run the check twice in a row and confirm the second run does nothing.

Seed Only an Empty Table

  1. If your feature needs starting rows, insert them only when the table is empty.
  2. Never seed on every enable; an operator who disabled and re-enabled the module would get duplicates, or would silently lose their edits.
  3. Disable and re-enable, then confirm the row count did not change.

Reference

What the Platform Calls

change_addon_status() The caller. It receives 'enable' or 'disable' from the panel and runs your pair of methods. Only then does it write the new status into the module's configuration.
Modules::getInstance() How anything else reaches your module, including a script that wants to run the schema step twice. Never construct the class directly.
the lifecycle methods you declare
// Declared by your module, called by the platform when the operator flips the switch.
// On "enable" activate() runs first when it exists, then enable(); on "disable" the
// deactivate()/disable() pair does the same. The new status is written ONLY if the last
// one returned a truthy value, so a falsy return or a thrown exception rejects the click
// rather than leaving the module half-installed.
public function enable(): bool;
public function disable(): bool;

// Your own steps, called from enable(). Both the create path and the upgrade path live
// here, so both run on every enable, on every re-enable and after every update.
private function check_database(): void;   // tables
private function check_columns(): void;    // columns added by a later version
private function seed(): void;             // starting rows, once

The Query Layer's Schema-Facing Part

WDB methods a schema step uses
public static function hasTable($table = '');               // bool, via SHOW TABLES LIKE - no prefix is applied
public static function exec($arg = '');                     // int affected rows; 0 on failure, WITHOUT throwing
public static function query($statement, $isthis = false);  // PDOStatement, or false on failure - also without throwing
public static function getAssoc($statement = false);        // one row of that statement; false when there is none
public static function getErrorMessage();                   // why exec() or query() came back empty
public static function getPrefix(): string;                 // the schema prefix, when the installation configures one

// DDL runs through exec()/query() because the chained builder only writes DML. Neither
// of those two throws: a failed CREATE or ALTER returns 0 / false and says nothing.

One Condition per Statement

What you are adding The condition that makes a second run a no-op Statement
a table WDB::hasTable($t) is false CREATE TABLE
a column SHOW COLUMNS FROM $t LIKE 'col' returns no row ALTER TABLE ... ADD COLUMN
a renamed column the old name still returns a row ALTER TABLE ... CHANGE
a removed column the name still returns a row ALTER TABLE ... DROP COLUMN
starting rows the table's row count is 0 INSERT

Example

a module's schema step
public function enable(): bool
{
    $this->check_database();
    $this->check_columns();

    return true;
}

private function check_database(): void
{
    if (\WDB::hasTable(self::TABLE)) {
        // Already installed: this is the upgrade path, not the create path. Each step
        // is guarded by its own condition, so an installation that skipped a release
        // converges too.
        $old = \WDB::query("SHOW COLUMNS FROM `" . self::TABLE . "` LIKE 'ticket_id'");
        if ($old && \WDB::getAssoc($old))
            \WDB::exec("ALTER TABLE `" . self::TABLE . "` CHANGE `ticket_id` `owner_id` INT UNSIGNED NOT NULL DEFAULT 0");

        return;
    }

    $created = \WDB::exec('CREATE TABLE `' . self::TABLE . '` (
        `id`        INT UNSIGNED NOT NULL AUTO_INCREMENT,
        `owner_id`  INT UNSIGNED NOT NULL DEFAULT 0,
        `status`    VARCHAR(32)  NOT NULL DEFAULT "",
        `ctime`     DATETIME     NOT NULL,
        PRIMARY KEY (`id`),
        KEY `owner_id` (`owner_id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');

    // exec() swallows the error, so ask for it rather than assuming success.
    if (!\WDB::hasTable(self::TABLE))
        throw new \Exception('Table could not be created: ' . \WDB::getErrorMessage());

    $this->seed();
}

private function check_columns(): void
{
    // Columns a later version introduced, table => column => definition.
    $columns = [
        self::TABLE => ['provider' => 'VARCHAR(30) NOT NULL DEFAULT ""'],
    ];

    foreach ($columns as $table => $definitions) {
        if (!\WDB::hasTable($table)) continue;

        foreach ($definitions as $column => $definition) {
            $stmt = \WDB::query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
            if ($stmt && \WDB::getAssoc($stmt)) continue;

            \WDB::exec("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
        }
    }
}

private function seed(): void
{
    // Starting rows go in ONCE. An operator who disabled and re-enabled the module
    // must not get duplicates, and must not lose their own edits.
    $stmt  = \WDB::select('COUNT(id) AS total')->from(self::TABLE);
    $total = $stmt->build() ? (int) (($stmt->getAssoc() ?: [])['total'] ?? 0) : 0;
    if ($total > 0) return;

    \WDB::insert(self::TABLE, ['owner_id' => 0, 'status' => 'ready', 'ctime' => \DateManager::Now()]);
}
proving the second run does nothing
// A throwaway script: read the shape, run the step twice, read it again. The claim
// "it is safe to run again" is only worth anything once the two readings agree.
$module = Modules::getInstance('Addons', 'AcmeScanner');

$shape = static function (): array {
    $rows = WDB::query('SHOW COLUMNS FROM `Acme_scans`');
    $cols = $rows ? WDB::fetch_assoc($rows) : [];

    $count = WDB::select('COUNT(id) AS total')->from('Acme_scans');

    return [
        'columns' => array_column($cols, 'Field'),
        'rows'    => $count->build() ? (int) (($count->getAssoc() ?: [])['total'] ?? 0) : 0,
    ];
};

$module->enable();
$first = $shape();

$module->enable();
$second = $shape();

echo $first === $second ? "idempotent\n" : "DIVERGED\n";

Pitfalls

Do not add columns to the product's tables

An upgrade owns those tables. Your column may survive, may be dropped, or may collide with one the product adds under the same name. Keep your data in your own table and join to theirs by identifier.

A failed statement says nothing

exec() and query() do not raise on an SQL error. They return 0 and false, and the reason stays in getErrorMessage() until something asks for it. A schema step that only calls them reports success on an installation where nothing was created. Read the shape back, or throw when the check finds it missing.

The step runs more than once

Enabling, re-enabling and upgrading all reach it. Every statement needs a condition that makes the second run a no-op. The way to be sure is to run it twice and compare the shape, not to reason about it.

Removing a module must not remove the operator's data

Dropping your table on disable turns an accidental click into data loss. Leave the rows. A re-enable then finds its data where it left it, and an operator who really wants it gone can say so.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.