NAME

DBIO::Extension - Base class of a database extension (PostGIS, AGE, pgvector, ...) known to deploy, introspection and diff

VERSION

version 0.900004

SYNOPSIS

package MyApp::DB::Extension::Vector;
use base 'DBIO::Extension';

sub extension_interface { 1 }
sub name   { 'vector' }
sub driver { 'DBIO::PostgreSQL' }

sub verdict {
  my ($class, $change, $ctx) = @_;
  return undef unless $change->{kind} eq 'column_type'
    && ($change->{old_info}{data_type} // '') =~ /\bvector\(/;
  return { refuse => "column $change->{column_name}: the dimension of a vector is not converted" };
}

DESCRIPTION

A database extension adds to a database what the application's schema does not declare: types, functions, operators and, for some extensions, objects in the application's own namespaces that the engine does not record as the extension's. Deploy, introspection and diff need three answers for each extension a database holds: which objects belong to the application model and which to the extension, what the extension adds to an object of the model, and how a change of such an object is written or refused (ADR 0066).

A DBIO extension is a class deriving from this one. Every method has a default that does nothing, so an extension writes only what it needs. All methods are class methods; anything an extension knows about the database comes through the context $ctx (see "THE CONTEXT").

The list of extensions a schema has is built by DBIO::Extension::List, the phases and their merges are run by DBIO::Extension::Protocol, and DBIO::Deploy::Base runs the protocol around introspection and diff. The commutativity test for an extension set is DBIO::Test::ExtensionSet.

METHODS

extension_interface

sub extension_interface { 1 }

The extension interface version the class implements. Required: the default returns undef, which "check_interface" refuses.

name

sub name { 'postgis' }

The engine's own name of the extension: pg_extension.extname, the DuckDB extension name, the SQLite module name. One class per engine extension, one entry per name. The default is undef: a plain class that serves several names (the driver's class for extensions without rules) states none, and the name of the list's entry stands. When a class states a name that differs from the entry's, the list refuses.

driver

sub driver { 'DBIO::PostgreSQL' }

The driver the extension belongs to, as an assertion. The extension never determines the driver (ADR 0032 section 7): it is compared with the resolved driver by "check_driver" in DBIO::Extension::List, after the DSN has decided the driver and never at connect. The default undef asserts nothing.

inspect

sub inspect { my ($class, $ctx) = @_; ... }   # phase 0

The only method with database access: $ctx->{dbh} is the handle the deploy works on. Returns the extension's state as a plain, copyable structure (AGE: { graphs => [ { name => ..., namespace => ... } ] }) or undef for none. Core keeps it per extension under its name ($ctx->{self}{state} in later phases, $ctx->{peers}{$name}{state} for the others) and the driver may put it into the model, read-only. A deploy never creates or drops it. Default: undef.

claims

sub claims { my ($class, $model, $ctx) = @_; ... }   # phase 1

Returns an array reference of addresses (see "ADDRESSES") of objects that are the extension's and not the application's (AGE: [ schema => $graph_schema ] for each graph). $model is the driver's base model, as the driver's introspection or target build produced it. The method reads; it does not change the model. Default: [].

annotate

sub annotate { my ($class, $model, $ctx) = @_; ... }   # phase 2

Returns an array reference of [ $address, $attribute, $value ] triples for objects that stay in the model (PostGIS: base_type, geometry_type, srid on a column whose type lives in the PostGIS schema). $model is the model after phase 1, so objects another extension claimed are no longer in it. $attribute is a plain string, $value a plain scalar (or a structure comparable as data). Default: [].

verdict

sub verdict { my ($class, $change, $ctx) = @_; ... }   # phase 3

Asked by the driver for a change it is about to write. $change is the driver's description of one change; the driver's POD states its keys. The column type change that is the case today has the form

{ kind        => 'column_type',
  table       => $table_key,
  column_name => $column,
  old_info    => \%column_info,    # incl. the annotations of phase 2
  new_info    => \%column_info }

$ctx->{models} holds the source and target model after phase 2. Returns

  • undef -- not mine, the extension has no opinion,

  • { using => $expr } -- the change is written with this USING expression (or the driver's equivalent), or

  • { refuse => $message } -- the change is refused, with this message.

Anything else is a malformed answer and throws. Default: undef.

activation

sub activation { my ($class, $ctx) = @_; ... }

Per connection, as data, not statements: a hash reference with the keys

  • load -- a library name, an array reference of them, or a hash reference { $name => $library }. A bare library name stands for itself ('age' is { age => 'age' }).

  • search_path -- a schema name or an array reference of them.

No other key is accepted. The driver turns the merged activation of all active extensions into the statements of a new connection (PostgreSQL: LOAD 'age' and the search_path, the namespace of the connection first and the extensions' entries in name order after it). Default: undef, nothing.

storage_layer

sub storage_layer { 'DBIO::PostgreSQL::Age::Storage' }

The storage layer (ADR 0032) that carries the extension's runtime surface (AGE: cypher, create_graph), or undef. The driver registers it for a declared extension that names one. Default: undef.

components

sub components { 'DBIO::PostgreSQL::PostGIS' }

An optional class method outside interface version 1: the result-side parts of the extension, as a list of component class names (PostGIS: the geometry inflation component). The driver applies them to the schema for a declared extension, as load_components would. Default: the empty list. A new optional method with a do-nothing default does not change the interface version (see "INTERFACE VERSION"), so an extension that does not need it never mentions it.

interface_current

my $v = DBIO::Extension->interface_current;

The interface version this core implements.

interface_versions

my @accepted = DBIO::Extension->interface_versions;

The set of interface versions this core accepts. Until the family's first release exactly the current version; afterwards the current and the previous one for one release.

check_interface

DBIO::Extension->check_interface($class);

Throws a DBIO::Exception unless $class derives from this class and its extension_interface is in "interface_versions". The message names the extension, its version, core's set and the side to upgrade. Returns true.

THE INTERFACE

Identity

Phases

Every phase method receives the same input for every active extension, as a copy, and returns data. See "PHASES AND MERGES".

Beyond the interface

THE CONTEXT

$ctx carries, for one deploy and one connection, a fresh copy for every call:

dbh

Only in "inspect". The database handle of the deploy.

peers

The other active entries, name to { class, installed, version, schema, state }. installed is true when the database has the extension, version and schema are what the driver read (undef when it did not), state is the peer's "inspect" result (undef while phase 0 runs and for a peer without state). The peers are how an extension regulates its interaction with another one, inside its own code.

self

The extension's own entry: { name, class, options, installed, version, schema, state }. options are the options of the declaration.

driver

A hash reference of the driver's helpers an extension may use. The driver's POD names them (PostgreSQL: quote_ident, qualified_key, the address constructors). Core passes the hash through and promises nothing about its content. Not copied: it holds code references.

connection_schema

The namespace of the connection (ADR 0065), or undef.

restricted

True when the deploy is restricted to the schemas it manages.

models

Only in "verdict": { source => $model, target => $model } after phase 2.

PHASES AND MERGES

The phases run in a fixed sequence. Within a phase every extension reads the same input and returns data; core merges.

phase        input of every extension      merge                          conflict
0 inspect    the connection                per extension, by name         none
1 claims     the driver's base model       union of addresses             none
2 annotate   the model after phase 1       union of triples               same address+attribute,
                                                                          two values: refuses
3 verdict    the change, models of 2       at most one answer per change  two different answers
                                                                          refuse; any refuse refuses
                                                                          the diff, all listed
activation   the context                   the driver composes            the driver refuses what
                                                                          it cannot compose

Phases 1 and 2 run on both sides of a diff, the live model and the target model, with the same set. The list is ordered by name; the order decides only the sequence of messages and of statements that do not depend on each other. No result depends on it.

ADDRESSES

An address is the driver's: [ schema => $name ], [ table => $key ], [ column => $table_key, $column ], [ type => $key ] and so on. The driver's POD lists its kinds and its cascade (a claimed schema takes its tables, a table its columns, indexes, triggers, keys). Core treats an address as an opaque array reference of scalars: it sorts, de-duplicates and compares it, and hands it back to the driver unchanged.

WRITING AN EXTENSION

A third extension needs no change in core, in the driver or in another extension: a class, and the declaration naming it.

package MyApp::DB::Extension::Vector;
use base 'DBIO::Extension';
sub extension_interface { 1 }
sub name   { 'vector' }
sub driver { 'DBIO::PostgreSQL' }
# ... the methods above that the extension needs ...

package MyApp::DB;
use DBIO Schema => -pg;
__PACKAGE__->pg_extensions(vector => { class => 'MyApp::DB::Extension::Vector' });

An extension with result-side parts (a column inflation component) names them in "components"; the driver applies them for a declared extension. It is optional and outside the interface version.

The rules for every extension, family or not:

1. One class per engine extension; name is the engine's name.
2. It states extension_interface and driver, and derives from DBIO::Extension.
3. Only "inspect" touches the database. The other methods are functions of their arguments: no changes to the input, no state between calls, no globals.
4. It never determines the driver (ADR 0032 section 7), changes no @ISA of a storage, Deploy or Introspect class, and registers nothing into a driver registry.
5. It answers only for what is its own. Where another extension's presence changes its answer, it reads $ctx->{peers}; it does not ask core for an order.
6. It passes DBIO::Test::ExtensionSet over itself and the extensions it names as peers.

The test:

use DBIO::Test::ExtensionSet qw(extension_set_ok);

extension_set_ok(
  driver     => 'DBIO::PostgreSQL::Deploy',
  extensions => [ 'MyApp::DB::Extension::Vector', 'DBIO::PostgreSQL::PostGIS::Extension' ],
  source     => $live_model_fixture,
  target     => $target_model_fixture,
  changes    => [ { kind => 'column_type', ... } ],
);

It runs the phases in every permutation of the set, asserts the same post-phase models, diff statements and activation, and the same output of every extension in every position, and fails when an extension changed its input.

INTERFACE VERSION

DBIO::Extension carries the interface version core implements ("interface_current") and the set it accepts ("interface_versions"). A version outside the set refuses when the driver resolves the list ("check_interface"), with a DBIO::Exception; there is no warning mode. A new optional method with a do-nothing default does not change the version; a changed signature, a changed meaning of a return value or a new required method does.

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.