DBIx::Fast

DBI, fast & easy — one small API over SQLite, MariaDB, MySQL and PostgreSQL.

Perl Object::Pad License

DBIx::Fast sits between a bare DBI handle and a full ORM. You keep writing SQL — but the everyday plumbing (connection resilience, transactions, result shaping, caching, profiling and identifier safety) is handled for you, the same way across every supported driver.

It is not an ORM: no result classes, no relationships, no migrations. Just DBI with the sharp edges filed off.

use DBIx::Fast;

my $db = DBIx::Fast->new( dsn => 'postgres://user:pass@localhost:5432/app' );

my $user  = $db->hash('SELECT * FROM users WHERE id = ?', $id);
my @names = $db->flat('SELECT name FROM users WHERE active = ?', 1);
my $total = $db->count('users', { status => { '>=' => 1 } });

$db->insert('users', { name => 'Alice', status => 1 }, time => 'created_at');
$db->up('users', { status => 2 }, { id => $id });

$db->txn(sub {
    $db->insert('orders', { total => 100 });
    $db->insert('order_items', { order_id => $db->last_id, sku => 'X' });
});

Why DBIx::Fast?

Most projects end up re-implementing the same boring, easy-to-get-wrong infrastructure around DBI. DBIx::Fast bundles it:

Quick start

# --- One connection per name per process (preforking servers, job workers) ---
DBIx::Fast->configure(app => { dsn => 'mariadb://user:pass@db/app' });
my $db = DBIx::Fast->connect('app');      # same instance everywhere; new one after fork

# --- Connect (DSN, URI, or SQLite shortcut) ---
my $db = DBIx::Fast->new( dsn => 'dbi:MariaDB:dbname=app;host=localhost',
                          user => 'root', password => 'secret' );
my $db = DBIx::Fast->new( dsn => 'mariadb://root:secret@localhost:3306/app' );
my $db = DBIx::Fast->new( SQLite => '/path/to/app.db' );

# --- Read in the shape you want ---
my $rows = $db->all('SELECT * FROM users WHERE age > ?', 18);   # arrayref of hashrefs
my $row  = $db->hash('SELECT * FROM users WHERE id = ?', $id);  # single hashref
my $name = $db->val('SELECT name FROM users WHERE id = ?', $id);# scalar
my @ids  = $db->flat('SELECT id FROM users');                   # flat list

# Memory-efficient iterator (does not slurp the whole result set)
my $rs = $db->query('SELECT * FROM logs ORDER BY id DESC');
while (my $log = $rs->hash) { ... }

# Named parameters
$db->execute('SELECT * FROM users WHERE name = :name', { name => 'Alice' });

# Lists, filters and pages
my $rows = $db->all('SELECT * FROM users WHERE id IN (?)', \@ids);  # IN (?, ?, ?)
my ($where, @b) = $db->where({ status => 'active', name => { CONTAINS => $q },
                               country => $form->{country} });  # undef skipped
my $page = $db->page("SELECT * FROM users $where", \@b, page => 2, per => 50,
                     order => '-created', allowed_order => [qw(id created)]);
# { rows => [...], total => 1234, page => 2, per => 50, pages => 25 }

# Small helpers
$db->exists('users', { email => $email });                 # 1 / 0
my $names = $db->kv('SELECT id, name FROM users');          # { id => name }
my $by_id = $db->all_by('id', 'SELECT * FROM users');       # { id => row }
$db->delete('sessions', { user_id => $id }); my $n = $db->rows;   # affected rows

# --- Write (driver-native upsert / bulk) ---
$db->upsert('products', { sku => 'X', stock => 5 }, ['sku'], ['stock']);
$db->insert_many('logs', \@rows);

# --- Transactions with retry, savepoints, isolation ---
$db->txn(sub {
    $db->insert('accounts', { id => 1, balance => 100 });
    $db->transaction->savepoint('sp1');
    $db->up('accounts', { balance => 90 }, { id => 1 });
}, { max_retries => 3, isolation => 'SERIALIZABLE' });

# --- Caching ---
my $db = DBIx::Fast->new( SQLite => 'app.db', cache => { default_ttl => 60 } );
my $p  = $db->cached(tag => 'products')->hash('SELECT * FROM products WHERE id=?', $id);
$db->insert('products', { sku => 'Y' });   # auto-invalidates 'products' reads

# --- Errors: scoped, with the failing SQL, loud reads ---
my $db = DBIx::Fast->new( dsn => $dsn, RaiseError => 0, strict_reads => 1,
                          on_error => sub ($e) { $log->error("$e->{error} [$e->{sql}]") },
                          on_connect_sql => [ 'SET NAMES utf8mb4' ] );  # re-applied on reconnect
my $mark = $db->error_mark;
handle_request();
my $errors = $db->errors_since($mark);     # [{ error, sql, binds, time, ... }]

# Typed exceptions - still stringify to the classic text, so regexes keep working
eval { $db->insert('users', { email => $email }) };
if ($@ isa DBIx::Fast::X::Duplicate) { say "taken: ", $@->constraint }

# --- Profiling ---
$db->tracker;
$db->all('SELECT * FROM users');
my $stats = $db->tracker->get_stats;       # total/avg time, slow queries, by type

How it compares

DBIx::Fast is not trying to replace ORMs or framework layers — it fills a different niche: thin, multi-driver, batteries-included SQL.

| | DBIx::Fast | DBIx::Class | DBIx::Simple | Mojo::Pg / mysql | DBIx::Connector | |---|:---:|:---:|:---:|:---:|:---:| | Style | thin SQL layer | full ORM | thin query helper | framework DB layer | connection mgr only | | Drivers in one module | SQLite / MariaDB / MySQL / Pg | many (ORM) | any (agnostic) | one per module | any (DBI) | | Dialect-aware upsert / last_id / isolation | ✅ | ✅ | ➖ | ➖ | ➖ | | Fork- & ping-safe reconnect | ✅ built-in | ✅ | ➖ | ✅ (pool) | ✅ | | Deadlock / lock-wait auto-retry | ✅ | manual/plugin | ➖ | ➖ | ➖ | | Savepoints + isolation levels | ✅ | ✅ | ➖ | ➖ | ➖ | | Result cache (TTL + tags) | ✅ built-in | plugin | ➖ | ➖ | ➖ | | Query profiler + EXPLAIN/index tools | ✅ built-in | debug hooks | ➖ | ➖ | ➖ | | Identifier validate+quote / PII redaction | ✅ | ✅ | ➖ | ➖ | n/a | | ORM: relations / row objects / migrations | ❌ by design | ✅ | ❌ | migrations | ❌ | | Object system | Object::Pad | Moo-style | — | Mojo::Base | — |

✅ built-in · ➖ not provided / bring your own · ❌ out of scope

Use DBIx::Fast when you want to write SQL directly across more than one database and not hand-roll connection handling, transactions, caching and safety every time. Reach for an ORM (DBIx::Class, Rose::DB::Object, Teng) when you want to map an object graph with relationships and migrations.

Driver support

| Driver | DBD | Notes | |---|---|---| | SQLite | DBD::SQLite | always tested | | MariaDB | DBD::MariaDB | tested against a live server in CI | | MySQL | DBD::mysql | version-gated upsert (row-alias on 8.0.19+) | | PostgreSQL | DBD::Pg | tested against a live server in CI |

Install

cpanm DBIx::Fast

Core dependencies: DBI, SQL::Abstract, Object::Pad. Optional features pull in their own deps only when used: LRU::Cache (default cache backend), Cpanel::JSON::XS and Term::ANSIColor (profiler output), and POSIX::AtFork (fork detection without a getpid() per query - reads ~10% faster - and inherited MariaDB sockets neutralized at fork time).

Testing

The SQLite suite runs everywhere. The driver-specific tests run when you point them at a live server:

DBIX_FAST_TEST_MARIADB='mariadb://root:pass@127.0.0.1:3307/dbix_test' \
DBIX_FAST_TEST_PG='postgres://postgres:pass@127.0.0.1:5433/dbix_test' \
  prove -lr t/

For local development, ./dev/test.sh (repo only, not shipped in the dist) spins up throwaway MariaDB, PostgreSQL and MySQL docker containers and runs the full suite - all four drivers plus the POD gate. See dev/README.md for the complete local-setup notes.

./dev/test.sh                 # start containers (create if missing) + run tests
./dev/test.sh up              # start containers only
./dev/test.sh stop            # stop them
./dev/test.sh release         # pre-CPAN gate + build the tarball (uploads nothing)
./dev/test.sh full_release    # + examples, WHERE fuzzer, CRUD cache differential,
                              #   benchmark; FULL_RELEASE_PWS=1 adds an app suite

Testing your own code without a database

DBIx::Fast::Test::Mock gives a real DBIx::Fast whose server is replaced by canned answers - the methods run their real code and SQL of the chosen dialect:

use DBIx::Fast::Test::Mock;

my $mock = DBIx::Fast::Test::Mock->new(dialect => 'MariaDB');
$mock->on(qr/FROM users WHERE id = \?/ => [ { id => 7, name => 'ana' } ])
     ->on(qr/^INSERT INTO `orders`/      => { last_id => 501 });

my $service = MyApp::Orders->new(db => $mock->db);
$service->checkout(7);
is($mock->called(qr/^INSERT INTO `orders`/), 1);

Documentation

Full reference in the POD: perldoc DBIx::Fast. Subsystems: Connector, Transaction, Result, Schema, Cache / Cached, Profiler, exceptions.

Security

DBIx::Fast uses parameterized queries throughout and validates+quotes every identifier. To report a vulnerability, see SECURITY.md (please do not open a public issue).

License

Free software under the Artistic License 2.0. Author: SeHarrys.