Composable SQL query builder for Perl — expression trees, immutable queries, zero dependencies.
- Everything is an expression — SELECTs, CASE, functions, arithmetic, subqueries all compose freely
- Immutable queries — modifier methods return new objects; build a base query once, derive many variants
- Bind parameters by default — plain values always produce
?placeholders; useraw()only when you mean it - SQL::Abstract-compatible WHERE syntax — the hashref/arrayref conditions Perl developers already know
- Zero non-core dependencies — no SQL::Abstract, no Moose, nothing outside the Perl core
cd SQL-Wizard
perl Makefile.PL
make
make test
sudo make installuse SQL::Wizard;
my $q = SQL::Wizard->new;
# SELECT with JOIN, WHERE, ORDER BY, LIMIT
my ($sql, @bind) = $q->select(
-columns => ['u.id', 'u.name', $q->func(COUNT => 'o.id')->as('orders')],
-from => ['users|u', $q->left_join('orders|o', 'u.id = o.user_id')],
-where => { 'u.status' => 'active', 'u.age' => { '>' => 18 } },
-group_by => 'u.id',
-order_by => [{ -desc => 'orders' }],
-limit => 20,
)->to_sql;
# Pass directly to DBI
my $sth = $dbh->prepare($sql);
$sth->execute(@bind);| Method | Output |
|---|---|
$q->col('u.name') |
column reference (no bind) |
$q->val(42) |
bound value → ? |
$q->raw('NOW()') |
literal SQL fragment |
$q->func('COUNT', '*') |
COUNT(*) |
$q->coalesce('a', $q->val(0)) |
COALESCE(a, ?) |
$q->cast('price', 'INTEGER') |
CAST(price AS INTEGER) |
$q->greatest('a', 'b') |
GREATEST(a, b) |
$q->between('age', 18, 65) |
age BETWEEN ? AND ? |
$q->exists($subquery) |
EXISTS(SELECT ...) |
Every expression supports .as('alias'), .asc(), .desc(), .over(...), and .to_sql().
Operator overloading on expression objects:
my $subtotal = $q->col('price') * $q->col('qty');
my $tax = $subtotal * $q->val(0.2);
my $total = $subtotal + $tax;
$q->select(
-columns => [
$subtotal->as('subtotal'),
$tax->as('tax'),
$total->as('total'),
],
-from => 'line_items',
);Supported: +, -, *, /, %. Plain Perl numbers are auto-coerced to bind parameters.
Hashref conditions (AND by default):
-where => { status => 'active', age => { '>' => 18 } }
# status = ? AND age > ?
-where => { id => { -in => [1, 2, 3] } } # id IN (?, ?, ?)
-where => { id => { -not_in => $subquery } } # id NOT IN (SELECT ...)
-where => { deleted_at => undef } # deleted_at IS NULLExplicit AND / OR nesting:
-where => [-and => [
{ status => 'active' },
[-or => [
{ role => 'admin' },
{ role => 'editor' },
]],
]]
# (status = ? AND (role = ? OR role = ?))Expression objects in WHERE:
-where => [
$q->exists($subquery),
$q->between('age', 18, 65),
$q->raw('ST_DWithin(location, ?, ?)', $point, $radius),
]$q->join('orders|o', 'u.id = o.user_id') # INNER JOIN
$q->left_join('orders|o', 'u.id = o.user_id') # LEFT JOIN
$q->right_join('orders|o', 'u.id = o.user_id') # RIGHT JOIN
$q->full_join('orders|o', 'u.id = o.user_id') # FULL OUTER JOIN
$q->cross_join('sizes') # CROSS JOIN (no ON)
# table|alias expands to: table alias
# ON clause accepts a string or hashref (same syntax as WHERE)# Searched CASE
$q->case(
[$q->when({ total => { '>' => 10000 } }, 'Platinum')],
[$q->when({ total => { '>' => 5000 } }, 'Gold')],
$q->else('Bronze'),
)->as('tier')
# Simple CASE (CASE ON)
$q->case_on(
$q->col('u.role'),
[$q->when($q->val('admin'), 'Full Access')],
[$q->when($q->val('editor'), 'Edit Access')],
$q->else('Read Only'),
)->as('access_level')# Inline specification
$q->func('ROW_NUMBER')->over(
-partition_by => 'department',
-order_by => [{ -desc => 'salary' }],
)->as('rank')
# ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rank
# Running total with frame
$q->func('SUM', 'amount')->over(
-partition_by => 'account_id',
-order_by => 'transaction_date',
-frame => 'ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW',
)->as('running_total')
# Named windows (defined in -window)
$q->select(
-columns => [
$q->func('RANK')->over('w')->as('r'),
$q->func('DENSE_RANK')->over('w')->as('dr'),
],
-from => 'employees',
-window => { w => { '-partition_by' => 'dept', '-order_by' => 'salary' } },
)my $a = $q->select(-columns => [qw/id name/], -from => 'active_users');
my $b = $q->select(-columns => [qw/id name/], -from => 'legacy_users');
my $c = $q->select(-columns => [qw/id name/], -from => 'pending_users');
my ($sql, @bind) = $a->union($b)->union_all($c)->order_by('name')->limit(100)->to_sql;Methods: union, union_all, intersect, except.
my ($sql, @bind) = $q->with(
recent_orders => $q->select(
-columns => ['*'],
-from => 'orders',
-where => { created_at => { '>' => $q->raw("NOW() - INTERVAL '30 days'") } },
),
big_spenders => $q->select(
-columns => ['user_id', $q->func(SUM => 'total')->as('spent')],
-from => 'recent_orders',
-group_by => 'user_id',
-having => $q->raw('SUM(total) > ?', 1000),
),
)->select(
-columns => ['u.name', 'bs.spent'],
-from => ['users|u', $q->join('big_spenders|bs', 'u.id = bs.user_id')],
-order_by => [{ -desc => 'bs.spent' }],
)->to_sql;Recursive CTEs:
$q->with_recursive(
org_tree => {
-initial => $q->select(-columns => [qw/id name parent_id/], -from => 'employees',
-where => { parent_id => undef }),
-recurse => $q->select(-columns => ['e.id', 'e.name', 'e.parent_id'],
-from => ['employees|e', $q->join('org_tree|t', 'e.parent_id = t.id')]),
},
)->select(-columns => ['*'], -from => 'org_tree', -order_by => 'name')->to_sql;# Single row
$q->insert(-into => 'users', -values => { name => 'Alice', email => 'alice@example.com' })->to_sql;
# Multi-row
$q->insert(
-into => 'users',
-columns => [qw/name email/],
-values => [['Alice', 'alice@example.com'], ['Bob', 'bob@example.com']],
)->to_sql;
# INSERT ... SELECT
$q->insert(-into => 'archive', -columns => [qw/id name/],
-select => $q->select(-columns => [qw/id name/], -from => 'users', -where => { status => 'deleted' }))->to_sql;
# PostgreSQL upsert
$q->insert(
-into => 'counters',
-values => { key => 'hits', value => 1 },
-on_conflict => { -target => 'key', -update => { value => $q->raw('counters.value + EXCLUDED.value') } },
)->to_sql;
# MySQL ON DUPLICATE KEY
$q->insert(
-into => 'counters',
-values => { key => 'hits', value => 1 },
-on_duplicate => { value => $q->raw('value + VALUES(value)') },
)->to_sql;# Simple
$q->update(
-table => 'users',
-set => { status => 'inactive', updated_at => $q->raw('NOW()') },
-where => { last_login => { '<' => '2023-01-01' } },
)->to_sql;
# With JOIN (MySQL style)
$q->update(
-table => ['users|u', $q->join('orders|o', 'u.id = o.user_id')],
-set => { 'u.last_order' => $q->col('o.created_at') },
-where => { 'o.status' => 'completed' },
)->to_sql;# Simple
$q->delete(-from => 'users', -where => { status => 'deleted' })->to_sql;
# With subquery
$q->delete(
-from => 'users',
-where => { id => { -not_in => $q->select(-columns => ['user_id'], -from => 'active_sessions') } },
)->to_sql;
# PostgreSQL USING
$q->delete(
-from => 'orders',
-using => 'users',
-where => { 'orders.user_id' => $q->col('users.id'), 'users.status' => 'banned' },
)->to_sql;my $base = $q->select(-from => 'users', -where => { status => 'active' });
my $admins = $base->add_where({ role => 'admin' });
my $page = $admins->order_by('name')->limit(20)->offset(0);
my $count = $base->columns([$q->func('COUNT', '*')->as('n')]);
# $base is unchanged throughoutMethods: add_where, columns, order_by, limit, offset.
See the LICENSE file for details.