Query Builder

The recommended way to write conditions. The constructors live at module level, one module per backend, and return Condition objects that carry the SQL and its parameters separately.

from easymysql.my_query import equals, greater_than, in_list   # MySQL
from easymysql.pg_query import equals, greater_than, in_list   # PostgreSQL

cond = equals('active', True) & greater_than('stock', 0)
db.select('products', cond)

Import from the module that matches your backend. The identifier delimiter differs (` vs ") and so does date arithmetic. Both expose the same names except ilike and icontains, which only exist in pg_query.

Composition

With operators: & (AND), | (OR), ~ (NOT). Or with the list forms: all_(...), any_(...), not_(c).

db.select('products', any_(
    contains('name', 'chair'),
    in_list('category_id', [1, 2, 3]),
))

Each operand is wrapped in parentheses, so mixing AND and OR groups the way you expect:

raw("a = %s OR b = %s", (1, 2)) & equals('c', 3)
# ((a = %s OR b = %s) AND (`c` = %s))     params=(1, 2, 3)

all_() and any_() skip None, which makes optional filters straightforward:

db.select('products', all_(
    equals('published', True),
    contains('title', text) if text else None,
    in_list('category', cats) if cats else None,
))

A Condition is not a boolean

and, or and not return one of their operands, so they drop a condition. bool(cond), if cond: and cond and other raise TypeError:

cond = equals('active', True) and greater_than('stock', 0)   # TypeError
cond = equals('active', True) &   greater_than('stock', 0)   # correct

For an optional filter, compare with None, or hand the None to all_() / any_(), which skip it.

A Condition cannot be interpolated

f"WHERE {cond}"     # TypeError: the parameters would be lost

Pass it straight to select / update / delete, or unpack it:

sql, params = cond      # an iterable of 2, not indexable
repr(cond)              # to inspect it

Every constructor, and the SQL it produces

Field names are shown as f, values as the %s placeholders that reach the driver. Where the two backends differ, both are shown.

Call Generated SQL
equals('f', v) `f` = %s
equals('f', None) `f` IS NULL
not_equals('f', v) `f` != %s
greater_than('f', v) `f` > %s
greater_than_or_equal('f', v) `f` >= %s
less_than('f', v) `f` < %s
less_than_or_equal('f', v) `f` <= %s
between('f', a, b) `f` BETWEEN %s AND %s
not_between('f', a, b) `f` NOT BETWEEN %s AND %s
in_list('f', [1, 2]) `f` IN (%s, %s)
in_list('f', []) 1=0
not_in_list('f', [1, 2]) `f` NOT IN (%s, %s)
not_in_list('f', []) 1=1
is_null('f') `f` IS NULL
is_not_null('f') `f` IS NOT NULL
contains('f', 'x') `f` LIKE %s ESCAPE '\\'
not_contains('f', 'x') `f` NOT LIKE %s ESCAPE '\\'
starts_with('f', 'x') `f` LIKE %s ESCAPE '\\'
ends_with('f', 'x') `f` LIKE %s ESCAPE '\\'
like('f', '%x%') `f` LIKE %s
not_like('f', '%x%') `f` NOT LIKE %s
ilike('f', '%x%') "f" ILIKE %s (pg_query only)
icontains('f', 'x') "f" ILIKE %s (pg_query only)
date_equals('f', d) MySQL `f` >= %s AND `f` < DATE_ADD(%s, INTERVAL 1 DAY)
PostgreSQL "f" >= %s AND "f" < %s + INTERVAL '1 day'
date_greater_than('f', d) `f` > %s
date_less_than('f', d) `f` < %s
date_between('f', d1, d2) `f` BETWEEN %s AND %s
all_(a, b) ((`a` = %s) AND (`b` = %s))
all_() 1=1
any_(a, b) ((`a` = %s) OR (`b` = %s))
any_() 1=0
not_(c) NOT (`a` = %s)
raw('a > %s', (1,)) a > %s
exists('SELECT 1 …', (1,)) EXISTS (SELECT 1 FROM t WHERE x = %s)

Behaviour worth knowing

Call Produces
equals(f, None) IS NULL
in_list(f, []) 1=0 — matches nothing
not_in_list(f, []) 1=1 — no exclusions
all_() with no arguments 1=1
any_() with no arguments 1=0

Escape hatches

For what the constructors do not cover. Both take parameters, so they stay safe:

raw('age BETWEEN %s AND %s', (18, 65))
raw('MATCH(title) AGAINST (%s IN BOOLEAN MODE)', ('+python',))
exists('SELECT 1 FROM orders o WHERE o.user_id = users.id AND o.total > %s', (100,))

Clauses

select() and select_one() take the ordering, grouping, paging and locking clauses as keyword arguments. Identifiers are delimited and having keeps its values as parameters:

rows = db.select('products', cond, ['id', 'name'],
                 order_by=[('price', 'DESC'), 'name'],
                 limit=20, offset=40)

rows = db.select('sales', fields=['region'],
                 group_by=['region'],
                 having=raw('SUM(total) > %s', (10000,)))

with db.transaction():
    row = db.select_one('accounts', {'id': 7}, for_update=True)
Argument Accepts
order_by a column, a (column, 'ASC'|'DESC') pair, or a list of either
group_by a column or a list of columns
having a Condition — use raw(sql, params) for an aggregate
limit, offset non-negative integers; offset requires limit
for_update bool; requires an open transaction

A direction other than ASC / DESC, a negative limit, an offset without limit, or mixing these with the raw order= fragment raises ValueError. A having that is not a Condition raises TypeError. for_update outside a transaction raises RuntimeError.

Fragment helpers

These return a SQL string, not a Condition, and go in the fourth argument of select(). Join them with spaces, in SQL order.

from easymysql.my_query import order_by, limit, group_by, having
Call Generated SQL
order_by('f', 'DESC') MySQL ORDER BY `f` DESC
PostgreSQL ORDER BY "f" DESC
group_by('f') MySQL GROUP BY `f`
PostgreSQL GROUP BY "f"
limit(10) LIMIT 10
limit(10, 20) MySQL LIMIT 20, 10
PostgreSQL LIMIT 10 OFFSET 20
having('COUNT(*) > 2') HAVING COUNT(*) > 2
db.select('sales', {'active': 1},
          fields='category, COUNT(*) AS n',
          order=" ".join([group_by('category'),
                          having('COUNT(*) > 2'),
                          order_by('category'),
                          limit(5)]))
group_by needs an explicit fields

The default * produces SELECT * ... GROUP BY category, which MySQL 8 rejects under only_full_group_by — its default sql_mode — and PostgreSQL rejects always. fields must list the grouped columns and the aggregates, nothing else.

They validate their input instead of escaping it:

(sql, params) builders

To build the query without running it:

from easymysql.my_query import (insert_params, update_params, delete_params,
                                select_params, condition_params)

sql, params = insert_params('users', {'name': 'Ann'})
# ('INSERT INTO `users` (`name`) VALUES (%s)', ('Ann',))
db.execute(sql, params)

select_params('t', {'a': 1}, fields='id', order=limit(5))
# ('SELECT id FROM `t` WHERE `a` = %s LIMIT 5', (1,))

condition_params({'a': 1, 'b': None})
# ('`a` = %s AND `b` IS NULL', (1,))
Call SQL params
insert_params('t', {'a': 1}) INSERT INTO `t` (`a`) VALUES (%s) (1,)
update_params('t', {'a': 1}, {'id': 7}) UPDATE `t` SET `a` = %s WHERE `id` = %s (1, 7)
delete_params('t', {'id': 7}) DELETE FROM `t` WHERE `id` = %s (7,)
select_params('t', {'a': 1}) SELECT * FROM `t` WHERE `a` = %s (1,)
condition_params({'a': 1}) `a` = %s (1,)

The same guards apply: delete_params('t', '') raises ValueError.