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 |
contains/starts_with/ends_with/not_containsescape%and_: searching for'50%'looks for the literal50%, not "anything after 50".like/not_like/iliketake the pattern raw: there%and_really are wildcards.date_equals(f, d)produces a whole-day range, notDATE(col) = ..., which would defeat the index.- Field names are delimited and escaped, so a delimiter inside the name does not break the SQL.
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` DESCPostgreSQL 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, 10PostgreSQL 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)]))
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:
order_byvalidates the direction:order_by('a', '; DROP')raisesValueError.limitcasts to int:limit('5; DROP')raisesValueError.group_bydelimits the identifier but does not validate it: do not pass user input without an allowlist.havingas a fragment is raw SQL and takes no parameters. For aHAVINGwith values, use thehaving=keyword argument above withraw(sql, params).
(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.