NAME

DBIO::Deploy::Base - Base class for DBIO driver deploy orchestrators

VERSION

version 0.900004

DESCRIPTION

Base class for the per-driver Deploy orchestrators (DBIO::PostgreSQL::Deploy, DBIO::MySQL::Deploy, DBIO::DuckDB::Deploy, DBIO::SQLite::Deploy). Those classes implement the same test-deploy-and-compare strategy and their install/apply/upgrade methods were byte-near-identical; only the way diff obtains the target model (deploy the desired schema into a throwaway database and introspect it) is genuinely engine-specific.

This base hosts the shared orchestration:

  • "install" -- generate install DDL and run it against the live db.

  • "apply" -- run a diff's SQL against the live db.

  • "upgrade" -- "diff" then "apply".

  • "diff" -- introspect the live db (source), build the target model, and hand both to the driver's diff class.

against a handful of hooks. A subclass must provide the three class-name hooks ("_ddl_class", "_introspect_class", "_diff_class") and "_build_target_model" -- the "make a throwaway, deploy the desired schema, introspect it" step that is the real engine seam. Drivers that use a temporary database (PostgreSQL, MySQL) get a ready-made "_build_target_model" by subclassing DBIO::Deploy::Base::TempDatabase instead; in-memory drivers (DuckDB, SQLite) override "_build_target_model" directly with a :memory: connection.

ATTRIBUTES

schema

A connected DBIO::Schema instance using the driver's component. Required.

add_drop_table

my $deploy = $class->new(schema => $schema, add_drop_table => 1);

Optional. When true, "install" drops what the schema declares before it creates it: first the declared views (in the reverse of their install order), then the tables, each as DROP ... IF EXISTS, in the same batch as the install DDL. A fresh deploy over an existing copy of the schema is what it is for. "deploy" in DBIO::Schema and "deploy" in DBIO::Storage::DBI hand it on from the add_drop_table key of their options ("deploy_args"). A class that overrides "install" owns the dropping itself (see there).

allow_destructive

my $deploy = $class->new(schema => $schema, allow_destructive => 1);

Optional. The standing release for destructive changes: when true, "apply" and "upgrade" run a diff that drops or possibly alters stored data (see "apply"). The argument allow_destructive of those two methods, when given, takes the place of this attribute for that call. Defaults to false. Read/write; the setter returns the object.

METHODS

new

my $deploy = $class->new(schema => $connected_schema, %extra);

Blesses the argument hash. schema is required.

contract_version

my $v = $class->contract_version;

Returns $CONTRACT_VERSION, the compatibility version of this base class's public interface -- independent of the dist $VERSION and unaffected by a release that changes no contract shape. See "CONTRACT VERSION" above and ADR 0024.

deploy_args

my %args = DBIO::Deploy::Base->deploy_args(\%sqltargs);
my $deploy = $deploy_class->new(schema => $schema, %args);

Turns the options a caller handed to $schema->deploy or $storage->deploy into the arguments of "new". Only add_drop_table is known. Any other key throws, naming the key, so an option is never dropped silently. version in particular is refused: the native install always installs the current state of the schema, a version is not selectable. An undef argument is no options; anything but a hash reference throws.

_ddl_quote_ident

my $quoted = $deploy->_ddl_quote_ident($name);

How an identifier is written in the DROP statements of "add_drop_table": through the storage's _ddl_quote_ident when it has one, else a plain identifier stays bare and any other is double-quoted (DBIO::SQL::Util). Override for an engine that quotes differently.

_driver_storage

my $storage = $deploy->_driver_storage;

The storage that answers the driver's own hooks (_ddl_quote_ident, _ddl_sql_dialect, _use_transactional_ddl, ...): the storage of the schema, or, when that is a DBIO::Replicated::Storage, the driver storage of its master. The replicated storage forwards a named list of methods and no others, so a hook of a driver is asked of the master's storage directly. undef when the schema has no storage.

driver_storage

my $storage = DBIO::Deploy::Base::driver_storage($schema->storage);

The function behind "_driver_storage": the driver storage of the master for a DBIO::Replicated::Storage, any other storage as it is.

_ddl_class

Class name whose install_ddl($schema) returns the install DDL. Abstract.

_introspect_class

Class name whose new(dbh => $dbh)->model returns an introspected model. Abstract.

_diff_class

Class name whose new(source => $m, target => $m) returns a diff object (responding to has_changes and as_sql). Abstract.

_new_introspect

my $intro = $self->_new_introspect($dbh);

Factory for an introspector over $dbh. Default: _introspect_class->new(dbh => $dbh). Override to pass extra construction args (e.g. PostgreSQL's schema_filter).

_introspect_current

The introspected model of the live (source) database. Default: $self->_new_introspect($self->_dbh)->model.

_install_ddl

The install DDL string for the connected schema. Default: _ddl_class->install_ddl($self->schema).

_execute_ddl

$self->_execute_ddl($dbh, $sql);

Splits $sql into statements ("_split_statements" in DBIO::SQL::Util) and runs each via $dbh->do. The splitter reads the text as SQL: a semicolon inside a string literal, a quoted identifier, a comment, a dollar-quoted string or the body of a CREATE TRIGGER ends nothing, a comment in front of a statement is taken off it, and a piece that holds only comments is no statement and is not run. The body of a function or a procedure is read as one statement when the storage declares a dialect that has a reader for it: PostgreSQL's BEGIN ATOMIC ... END body and nested /* /* */ */ comments under postgresql, and under oracle every PL/SQL unit (function, procedure, package, type body, trigger, anonymous block), which is run as one statement with the semicolon behind its END and ends at a line of / as well. A routine in any other dialect has to come dollar-quoted.

The splitter reads a backslash in a literal one way or the other depending on the engine, and the text cannot say which. It is told by the storage: _ddl_sql_dialect ("_ddl_sql_dialect" in DBIO::Storage::DBI::Capabilities) names postgresql, sqlite, mysql or oracle. A storage that does not declare one -- the default -- leaves the splitter taking both readings.

When the storage reports _use_transactional_ddl truthy and $dbh is the handle that storage itself holds, the loop is wrapped in $storage->txn_do so a failure on any statement rolls the whole batch back. The ownership half matters because this method is also called with a foreign handle: DBIO::Deploy::Base::TempDatabase opens its own connection to the throwaway database and deploys into that. Bracketing a transaction on the live connection around DDL running on another one would buy no atomicity and could roll back the caller's own work, so that case runs unwrapped and silent -- the throwaway database is dropped whole on failure, which is its unit of recovery.

Within a bracketed batch the statements are grouped by the storage's _ddl_statement_is_transactional hook, so DDL an engine refuses to run inside a transaction runs outside one without costing the rest of the batch its atomicity; see "Per-statement exceptions". With no such statement — the usual case — there is exactly one group and exactly one txn_do.

When the storage reports 0 (the default for engines whose DDL forces an implicit COMMIT -- MySQL and MariaDB on every version, Oracle, Sybase, Informix -- or whose rebuild path depends on AutoCommit=on, e.g. SQLite, or that deliberately declines a capability its engine could support, e.g. DB2), the loop runs statement-at-a-time as before and a carp is emitted naming this engine class so operators see that a partial-failure recovery depends on per-driver bookkeeping (e.g. the __VERSION row gate in DBIO::DeploymentHandler).

_build_target_model

The desired-state model: deploy the install DDL into a throwaway database and introspect it. Abstract here -- provided by DBIO::Deploy::Base::TempDatabase for temp-database drivers, or overridden directly by in-memory drivers.

_extension_list

my $entries = $self->_extension_list;

The resolved list of the database extensions the schema declares, as an array reference of "entries" in DBIO::Extension::List (name, class, options, with the installed state the driver read attached). Default: [] -- no extensions, and then "diff" behaves exactly as without the protocol. A driver that supports extensions overrides this with its list; it spells the declaration, owns the table of the family's classes and reads the installed state.

_extension_driver_helpers

The hash reference that becomes $ctx->{driver} of every extension (quote and key helpers, address constructors). Default: {}.

_extension_connection_schema

The namespace of the connection (ADR 0065) for $ctx->{connection_schema}. Default: undef.

_extension_restricted

True when the deploy is restricted to the schemas it manages, for $ctx->{restricted}. Default: false.

_apply_extension_claims

$self->_apply_extension_claims($model, \@addresses);

Driver seam, phase 1: remove the objects at the claimed addresses from $model (with the driver's cascade: a claimed schema takes its tables, a table its columns, indexes, triggers, keys). Called only when there is at least one address. The default throws: a driver that declares extensions that claim something must implement it.

_apply_extension_annotations

$self->_apply_extension_annotations($model, \@triples);

Driver seam, phase 2: set the [ $address, $attribute, $value ] triples on the objects of $model. Called only when there is at least one triple. The default throws, like "_apply_extension_claims".

_apply_extension_states

$self->_apply_extension_states($model, \%states);

Puts the phase 0 states into the model, read-only, under their extension names. Called only when an extension returned a state. Default: $model->{extensions} = \%states, read-only all the way down -- the hashes (keys and values) and the arrays in them, locked with Hash::Util and Internals::SvREADONLY. Both models of a diff get the same states.

_extension_verdict

my $answer = $self->_extension_verdict($change);

For a driver's diff code that has the deploy: the merged phase 3 answer for one change (undef, { using => $expr } or { refuse => \@messages }). undef without declared extensions. A refusal makes "apply" throw.

install

$deploy->install;

Generates the install DDL and executes it against the connected database. Suitable for a fresh install on an empty database. Returns true.

With "add_drop_table" true, the drop statements of the declared views and tables (DBIO::SQL::Util, without CASCADE on an engine that declares the sqlite dialect, which does not know it) go in front of the install DDL, in the same batch. If a driver overrides install, it owns the dropping: the generic drop runs only when the class uses this install itself, so a SUPER::install from an override installs and does not drop a second time.

diff

my $diff = $deploy->diff;
my $diff = $deploy->diff({ foreign_keys_not_valid => 1 });

Introspects the live database (source), builds the target model via "_build_target_model", and returns _diff_class->new(source => ..., target => ...).

With database extensions declared ("_extension_list") the extension protocol of ADR 0066 runs around it: phase 0 reads the extensions' state over the live handle, phases 1 and 2 (claims, annotations) are applied to the source and the target model with the same set, and the diff class is constructed with the additional argument extensions, the DBIO::Extension::Protocol, so it can ask ->verdict($change) for a change it is about to write (see "_extension_verdict"). Without declared extensions nothing of this happens and the diff is built from the two models as they are.

The optional argument is a hash reference of options for the diff class. It is handed to new of the diff class (on the extension path as well) beside source and target. Only the names the diff class lists in ->diff_options ("diff_options" in DBIO::Diff::Base) are accepted; any other key throws, naming the key and the diff class, so an option is never dropped silently. An undef argument is no options; anything but a hash reference throws.

apply

$deploy->apply($diff);
$deploy->apply($diff, allow_destructive => 1);
my $same_diff = $deploy->apply($diff, dry_run => 1);

Executes each statement of $diff->as_sql against the connected database. No-op (returns false) when $diff->has_changes is false.

Before the first statement the diff is read through "classify" in DBIO::Diff::Base and "destructive_operations" in DBIO::Diff::Base:

  • A diff with a refusal (an operation whose action is refuse) is never applied. The DBIO::Exception names the summary of each refusing operation. No option releases it.

  • A diff with a destructive operation (a drop of something the schema class does not declare) or an operation that reports a possibly lossy change (the optional operation method lossy, see "destructive_operations" in DBIO::Diff::Base) runs with allow_destructive => 1, given as argument or as the "allow_destructive" attribute. Without it the DBIO::Exception names the summary of each such operation and no statement has run.

  • Everything else (additive and changing operations) runs as it is.

The release is for destructive operations only; the model of the diff, the extension protocol and the refusals keep their say.

With dry_run => 1 nothing is executed and nothing is checked: apply returns the diff itself, whose ->as_sql, ->summary and ->classify show what a real run would do. It returns false for a diff without changes.

Other argument names throw, naming the name.

apply and "upgrade" are the only entry points; a driver class inherits both and takes part through two hooks, run in this order for a released diff: "_check_before_apply" (the driver's checks against the live tables) and "_execute_diff" (the execution itself).

_check_before_apply

sub _check_before_apply {
  my ($self, $diff) = @_;
  # compare the diff with the live tables; throw DBIO::Exception to stop
  $self->next::method($diff);
}

Driver seam of "apply", called once the diff is released (the guard has passed) and before the first statement runs. A driver puts its checks of the diff against the live tables here (rows a new constraint would violate, a reorganisation a statement needs first) and throws a DBIO::Exception to stop the run. Not called for dry_run or a diff without changes. The default does nothing.

_execute_diff

sub _execute_diff {
  my ($self, $diff) = @_;
  ...                      # the driver's own way to run the diff
  return 1;
}

Driver seam of "apply": runs a diff that has been checked and released, and returns true. The default renders $diff->as_sql, asks the extension protocol of the diff for refusals and hands the statements to "_execute_ddl". A driver that executes in its own way (a table rebuild, statements after the DDL, a reorganisation) overrides this method and inherits "apply" and "upgrade" unchanged.

upgrade

my $diff = $deploy->upgrade;
my $diff = $deploy->upgrade(allow_destructive => 1);
my $diff = $deploy->upgrade(dry_run => 1);

Convenience: "diff" then "apply". Returns the diff object if changes were applied, or undef if the database was already up to date. The arguments are those of "apply" and are handed on to it: allow_destructive releases a destructive diff, and dry_run returns the diff without having executed anything. An unknown argument name throws before the diff is built.

CONTRACT VERSION

This class exposes an independent compatibility version, distinct from $VERSION (the dist version injected by Dist::Zilla's VersionFromMainModule):

my $v = $class->contract_version;

$CONTRACT_VERSION bumps when the deploy orchestrator's public interface (install, apply, upgrade, diff) or the hook contract (_ddl_class, _introspect_class, _diff_class, _build_target_model) changes. The dist $VERSION bumps on every release, but two core releases at the same contract version remain wire-compatible. Out-of-tree drivers should record the contract version they were last tested against and compare it against core's at load time, warning (or strict-failing under DBIO_STRICT_CONTRACT) when the shapes have drifted. See docs/adr/ for the contract-version policy.

TRANSACTIONAL DDL

Whether the loop in "_execute_ddl" is wrapped in $storage->txn_do is governed by the transactional_ddl capability on the storage (DBIO::Storage::DBI::Capabilities), plus one condition that is not about the engine at all: the $dbh handed to "_execute_ddl" must be the handle that storage owns. It is not always — DBIO::Deploy::Base::TempDatabase deploys into a connection it opened itself — and bracketing the live storage around DDL running elsewhere is worse than not bracketing at all. See "_execute_ddl".

The capability answers one narrow question: can several DDL statements run inside one transaction and be taken back by a ROLLBACK?

  • Transactional: PostgreSQL, Firebird, Microsoft SQL Server. DDL participates in the surrounding transaction, so the driver sets _use_transactional_ddl(1) and the whole multi-statement batch runs as one atomic unit — a failure on statement 7 of 12 rolls back the preceding six.

  • Implicit COMMIT on DDL: MySQL, MariaDB, Oracle, Sybase, Informix. Each DDL statement commits the transaction around it, so wrapping $dbh->do in txn_do changes nothing and the loop runs statement-at-a-time. A one-time notice is emitted at the first "_execute_ddl" call naming the deploy class, so operators see that partial-failure recovery rests on the driver's own bookkeeping (e.g. the version-row gate).

    MySQL 8.0 and MariaDB 10.6 do not change this, and their atomic DDL is easy to mistake for it: atomic DDL means a single statement no longer half-completes across a crash — no orphaned table left without its data dictionary entry. It does not put DDL under transaction control, and it does not let a ROLLBACK undo a CREATE TABLE. This capability asks the second question, so for these engines the answer stays 0 on every version.

  • Capable, but deliberately answering no: SQLite, DB2. The engine does have transactional DDL, but its diff path rebuilds tables in place and that rebuild depends on AutoCommit=on; a blanket txn_do wrap regresses it. Nothing in core special-cases SQLite — the decision is carried entirely by the driver declaring _use_transactional_ddl(0), which is why that declaration must stay explicit and commented in the driver rather than being "tidied up" to match the engine. DB2 also runs DDL inside the current unit of work (a ROLLBACK takes it back), so the driver's answer is a deliberate 0 for its own reasons, not an engine limit: it drops and re-creates an index under the same name inside one batch, and on z/OS the pending DROP can make the re-CREATE fail (SQLCODE -679). Both reasons are unmeasured — no live DB2 has been tested against this driver — which is exactly why the declaration names them instead of staying silent.

Every driver should declare its answer rather than inherit the default. Core defaults to 0 because that is the safe assumption for an engine nobody has checked, but a storage that stays silent is indistinguishable from one that was overlooked, and it leaves the deploy path resting on a core default core is free to change. DBIO::ClickHouse::Storage is the reference shape: an explicit _use_transactional_ddl(0) with its reasoning, and a test pinning the value together with get_inherited, which is defined only for a value the class set itself. Core is not neutral about this either — a capability read as a class method dies unless it was declared outright.

Recovery on the non-transactional set is engine-specific. DBIO::DeploymentHandler relies on the __VERSION row as a forward-progress gate: a partially applied upgrade can be re-run safely because the diff re-computes against live state, and the next apply picks up the remainder.

Per-statement exceptions

The capability answers for the connection, but atomicity is decided per statement: some engines have DDL that may not be inside a transaction even where everything else may. The known case is PostgreSQL's ALTER TYPE ... ADD VALUE — rejected outright in a transaction block before server 12, and from 12 on accepted while the new value stays unusable until that transaction commits, so an upgrade that adds an enum value and then references it breaks either way.

A driver declares those statements through the _ddl_statement_is_transactional hook on DBIO::Storage::DBI::Capabilities, and "_execute_ddl" groups consecutive statements by the answer, in their original order: each run of transaction-safe statements gets one txn_do, and a statement that answered false runs on its own, outside any transaction. A batch where everything answers true is a single run, identical to bracketing the whole loop, so a driver that never defines the hook sees no change.

Where a batch does split, the upgrade is no longer atomic as a whole — a failure in a later run leaves the earlier ones applied. That is what the __VERSION gate below is for, and core announces the split once so operators know which recovery story applies.

AUTHOR

DBIO & DBIx::Class Authors

COPYRIGHT AND LICENSE

Copyright (C) 2026 DBIO Authors Portions Copyright (C) 2005-2025 DBIx::Class Authors Based on DBIx::Class, heavily modified.

This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.