Relation¶
A Relation object is a predicate — it describes the logical condition
that rows must satisfy to belong to the relation. Its extension is the set
of rows currently satisfying that predicate in the database.
Instantiating with keyword arguments specialises the predicate. No SQL is executed until you call an executor method.
Author() # tautology — the whole table
Author(last_name='Martin') # subset: "is an author named Martin"
Author(last_name='Martin', id=42) # at most one row
Executors (ho_select, ho_get, ho_insert, ho_update, ho_delete,
ho_count, ho_is_empty) execute SQL immediately and return results.
Introspection methods (ho_assert_is_singleton, ho_is_set,
ho_where_display, ho_mogrify) inspect or assert on the predicate without
touching the database.
The module-level helper ho_list(*relations) combines multiple relations
into a single OR predicate for efficient membership testing.
See Learn halfORM in half an hour for a full walkthrough.
Executors¶
These methods execute SQL immediately and return results.
ho_insert(*args, upsert=False)
¶
Insert the row described by this predicate. Executes SQL.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
*args
|
column names to include in the returned dict. If omitted,
all columns are returned (equivalent to |
()
|
|
upsert
|
bool
|
add |
False
|
Returns:
| Name | Type | Description |
|---|---|---|
dict |
[dict]
|
the inserted row. |
Raises:
| Type | Description |
|---|---|
ReadOnlyRelationError
|
if the relation is a view or other non-writable kind. |
ho_get(*args)
¶
Fetch the single row matching this predicate from the database. Executes SQL.
Guarantees that the predicate matches exactly one row and returns it as
a plain dict mapping column names to their Python values.
Issues a single SELECT … LIMIT 2 query.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
*args
|
str
|
optional column names to select. If omitted, all columns are returned. |
()
|
Returns:
| Name | Type | Description |
|---|---|---|
dict |
dict
|
the matching row. |
Raises:
| Type | Description |
|---|---|
NotFoundError
|
no row matches the predicate. |
MultipleRowsError
|
more than one row matches the predicate. |
Example
ho_get usage:
Changed in version 1.0.0 (breaking): returns a dict instead
of a Relation object. Raises :exc:NotFoundError or
:exc:MultipleRowsError instead of the generic :exc:ExpectedOneError.
Exact-match guarantee
ho_get() raises :exc:~half_orm.relation_errors.NotFoundError when no
row matches and :exc:~half_orm.relation_errors.MultipleRowsError when
more than one row matches. Both inherit from
:exc:~half_orm.relation_errors.ExpectedOneError so existing
except ExpectedOneError clauses continue to work.
ho_select(*args, distinct=False, order_by=None, limit=None, offset=None, json_agg=None)
¶
Enumerate the extension of this predicate. Executes SQL.
This method is a generator. Without arguments it is equivalent to
iterating directly on the relation object (for row in rel:).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
*args
|
column names to project. If omitted, all columns are returned. |
()
|
|
distinct
|
bool
|
add |
False
|
order_by
|
str
|
SQL |
None
|
limit
|
int
|
maximum number of rows to return. Default: |
None
|
offset
|
int
|
number of rows to skip. Default: |
None
|
json_agg
|
dict
|
aggregate already-set fkeys as JSON arrays via
a Each entry maps a fkey attribute name to its spec:
The fkey must have been set via The type of the aggregated value depends on the FK direction:
|
None
|
Yields:
| Name | Type | Description |
|---|---|---|
dict |
one row of the extension. |
Example
Project and sort:
for row in Author(last_name='Martin').ho_select('id', 'email', order_by='id'):
print(row) # {'id': 1, 'email': 'alice@example.com'}
Aggregate related rows as JSON (reverse FK):
alice = Author(last_name='Martin')
alice.post_rfk.set() # join all posts
for row in alice.ho_select(json_agg={'post_rfk': ['id', 'title']}):
print(row['post_rfk']) # [{'id': 1, 'title': '...'}, ...]
Chained FK (A ← B → C) — aggregate the leaf relation's data:
# For each post, collect the persons who commented on it.
# post ← comment → person (comment is the junction)
post = Post(title='Hello')
comment = Comment()
comment.author_fk.set() # chain: comment → person
post.comment_rfk.set(comment)
for row in post.ho_select(json_agg={'comment_rfk': ['last_name']}):
print(row['comment_rfk']) # [{'last_name': '...'}, ...]
New in version 0.18.0: distinct, order_by, limit and offset parameters.
New in version 0.18.6: json_agg parameter.
Changed in version 0.18.7 (breaking): direct FK and singleton reverse FK (UNIQUE/PK) in json_agg return a dict (or None) instead of a list.
json_agg: FK direction determines the return type
When using the json_agg parameter, the type of each aggregated value
depends on the FK direction:
| FK type | Condition | Return value |
|---|---|---|
| Reverse (one-to-many) | no UNIQUE/PK on FK columns | list of dicts ([] if empty) |
| Reverse (one-to-one) | FK columns have UNIQUE or PK | dict or None |
| Direct (many-to-one) | — | dict or None |
# Reverse FK, non-unique — author → posts (list)
alice = Author(last_name='Martin')
alice.post_rfk.set() # join all posts
for row in alice.ho_select(json_agg={'post_rfk': ['title']}):
print(row['post_rfk']) # [{'title': '...'}, ...]
# Direct FK — post → author (dict)
post = Post(title='Hello')
post.author_fk.set() # join all authors
for row in post.ho_select(json_agg={'author_fk': ['last_name']}):
print(row['author_fk']) # {'last_name': 'Martin'}
# Chained FK (A ← B → C) — aggregate the leaf relation's data
# For each post, collect the persons who commented on it.
# post ← comment → person (comment is the junction)
post = Post(title='Hello')
comment = Comment()
comment.author_fk.set() # chain: comment → person
post.comment_rfk.set(comment)
for row in post.ho_select(json_agg={'comment_rfk': ['last_name']}):
print(row['comment_rfk']) # [{'last_name': '...'}, ...]
Changed in version 0.18.7 (breaking).
ho_count(*args, distinct=False)
¶
Return the number of rows that satisfy the predicate. Executes SQL.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
*args
|
column names for the inner SELECT (useful with
|
()
|
|
distinct
|
bool
|
if |
False
|
Returns:
| Name | Type | Description |
|---|---|---|
int |
the cardinality of the extension. |
Example
ho_count usage:
New in version 0.18.0: distinct parameter.
ho_is_empty()
¶
ho_update(*args, update_all=False, **kwargs)
¶
Update every row that satisfies the predicate. Executes SQL.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
*args
|
column names to return from the updated rows. Pass
|
()
|
|
update_all
|
bool
|
must be |
False
|
**kwargs
|
|
{}
|
Returns:
| Type | Description |
|---|---|
|
list[dict] | None: the updated rows if |
|
|
otherwise |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
if no constraint is set and |
ho_delete(*args, delete_all=False)
¶
Remove every row that satisfies the predicate. Executes SQL.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
*args
|
column names to return from the deleted rows. Pass
|
()
|
|
delete_all
|
bool
|
must be |
False
|
Returns:
| Type | Description |
|---|---|
|
list[dict] | None: the deleted rows if |
|
|
otherwise |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
if the predicate is not set and |
Async executors¶
Async counterparts of every executor. Require an async connection opened
with await model.aconnect(). Return plain values (not generators) so the
cursor can be closed before returning to the caller.
import asyncio
from half_orm.model import Model
async def main():
blog = Model('blog')
await blog.aconnect()
Author = blog.get_relation_class('blog.author')
Post = blog.get_relation_class('blog.post')
alice = await Author(
name='Alice', email='alice@example.com'
).ho_ainsert()
posts = await Post(author_id=alice['id']).ho_aselect()
n = await Post().ho_acount()
await blog.adisconnect()
asyncio.run(main())
ho_ainsert(*args, upsert=False)
async
¶
Async variant of ho_insert. Executes SQL.
New in version 0.18.0.
Sync counterpart: ho_insert
ho_aget(*args)
async
¶
Async variant of ho_get. Executes SQL.
Issues a single SELECT … LIMIT 2 query and returns the matching
row as a plain dict.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
*args
|
str
|
optional column names to select. If omitted, all columns are returned. |
()
|
Returns:
| Name | Type | Description |
|---|---|---|
dict |
dict
|
the matching row. |
Raises:
| Type | Description |
|---|---|
NotFoundError
|
no row matches the predicate. |
MultipleRowsError
|
more than one row matches the predicate. |
New in version 1.0.0.
Sync counterpart: ho_get
ho_aselect(*args, distinct=False, order_by=None, limit=None, offset=None)
async
¶
Async variant of ho_select. Returns a list of dicts (not a generator). Executes SQL.
New in version 0.18.0.
Sync counterpart: ho_select
ho_acount(*args, distinct=False)
async
¶
Async variant of ho_count. Executes SQL.
New in version 0.18.0.
Sync counterpart: ho_count
ho_ais_empty()
async
¶
Async variant of ho_is_empty. Executes SQL.
New in version 0.18.0.
Sync counterpart: ho_is_empty
ho_aupdate(*args, update_all=False, **kwargs)
async
¶
Async variant of ho_update. Executes SQL.
New in version 0.18.0.
Sync counterpart: ho_update
ho_adelete(*args, delete_all=False)
async
¶
Async variant of ho_delete. Executes SQL.
New in version 0.18.0.
Sync counterpart: ho_delete
Bulk load¶
ho_copy(data, columns=None)
classmethod
¶
Load rows into the table using PostgreSQL COPY FROM. Executes SQL.
Much faster than repeated :meth:ho_insert calls for bulk loads.
No RETURNING is supported — the number of inserted rows is returned
instead.
New in version 0.18.12.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
either a |
required | |
columns
|
list[str] | None
|
explicit column list. Required when
data is a headerless file-like object; ignored when data is
a |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
int |
int
|
number of rows inserted. |
Raises:
| Type | Description |
|---|---|
ReadOnlyRelationError
|
if the relation is a view or other non-writable kind. |
ValueError
|
if data is empty or columns is required but missing. |
ho_acopy(data, columns=None)
async
classmethod
¶
Async variant of :meth:ho_copy. Executes SQL.
Requires an async connection opened with await model.aconnect().
New in version 0.18.12.
Sync counterpart: ho_copy
Introspection¶
These methods inspect or assert on the predicate without executing SQL.
ho_assert_is_singleton()
¶
Assert that this predicate identifies exactly one row, without querying the database.
A predicate is a singleton when:
- every field of a unique identifier (primary key or any
UNIQUE NOT NULLconstraint) is set with the=comparator, or - a FK join constrains a unique identifier of this relation: the fields
on this side of the join form a PK or UNIQUE NOT NULL, and the
corresponding fields on the joined relation are all fixed with
=.
The check is purely structural — no SQL is executed.
Returns:
| Type | Description |
|---|---|
|
self — for chaining before a write operation. |
Raises:
| Type | Description |
|---|---|
NotASingletonError
|
if no unique identifier is fully set. |
Example
ho_is_singleton usage:
# OK — id is the primary key
Author(id=42).ho_assert_is_singleton()
# OK — email has a UNIQUE NOT NULL constraint
Author(email='alice@example.com').ho_assert_is_singleton()
# Raises — last_name is not a unique identifier
Author(last_name='Martin').ho_assert_is_singleton()
# OK — FK navigation: comment.post_id fixes post.id (PK)
Comment(post_id=42).fk_post().ho_assert_is_singleton()
# Typical usage: guard a single-row write
Author(id=42).ho_assert_is_singleton().ho_update(email='new@example.com')
# Via FK navigation: delete the post linked to a specific comment
Comment(post_id=42).fk_post().ho_assert_is_singleton().ho_delete()
New in version 0.18.0.
ho_is_set()
¶
Return True if one field at least is set or if self has been constrained by at least one of its foreign keys or self is the result of a combination of Relations (using set operators) where at least one operand is itself constrained.
Syntactic check, not semantic — risk of data loss
ho_is_set() inspects the predicate structure only. When both operands
of a set operator are constrained, it returns True even if the
extension is semantically equivalent to the full table:
Post() & Post() # False — both operands unconstrained
Post() | Post(title='a') # False — right operand is unconstrained
Post(title='a') | Post(title=('!=', 'a')) # True — but = all posts
Because ho_delete() and ho_update() allow execution without
delete_all=True / update_all=True when ho_is_set() is True,
these predicates will silently operate on the entire table.
Always verify the actual extension with ho_count() or ho_mogrify()
before destructive operations on set-operator predicates.
ho_where_display()
¶
Returns the predicate as a (possibly nested) dict, or None if unconstrained.
Every node in the tree — leaf, compound, or negation — always carries:
'tables':set[str]— allschema.tablenames involved (leaf: derived from the SQL AST; compound/neg: union of children).'constraints':list[dict]— all leaf constraints, each with keysrelation,field,comp,value(leaf: own fields; compound/neg: concatenation of children).
A leaf node additionally has 'joins', 'where', 'values'.
A compound node (|, &, -) additionally has
'operator' ('or', 'and', 'and not'),
'left', and 'right'.
A negation node (~) additionally has 'operator': 'neg'
and 'operand'.
Returns:
| Type | Description |
|---|---|
|
dict | None: the predicate structure, or |
|
|
has no constraint set. |
New in version 0.18.0.
ho_mogrify()
¶
Print the SQL SELECT that would be executed and return self.
Activates SQL tracing for the next query on this object. The query is printed to stderr when the next executor is called. Useful for debugging predicate composition.
Returns:
| Type | Description |
|---|---|
|
self — for chaining. |
ho_dict()
¶
Returns a dictionary containing only the values of the fields that are set.
ho_description()
classmethod
¶
Returns the description (comment) of the relation
Decorators¶
singleton(fct)
¶
Decorator that enforces a singleton predicate before calling the method.
Calls :meth:~half_orm.relation.Relation.ho_assert_is_singleton on
self before executing the decorated method. Raises
:exc:~half_orm.relation_errors.NotASingletonError if the predicate
does not identify exactly one row. No database query is performed.
Use this on any method that must operate on a single, identified row.
Example
singleton decorator usage:
@register
class Author(blog.get_relation_class('blog.author')):
Fkeys = {'post_rfk': '_reverse_fkey_blog_post_author_id'}
@singleton
def publish(self, title: str, content: str):
return self.post_rfk(title=title, content=content).ho_insert()
Author(id=1).publish('My post', 'Content here') # OK
Author(last_name='Martin').publish('…', '…') # raises NotASingletonError
Changed in version 0.18.0: the check is now purely structural (no database query).
transaction(fct)
¶
Decorator that wraps a Relation method in a database transaction.
Every INSERT / UPDATE / DELETE executed inside the decorated method runs inside a single atomic unit. Commits on normal return, rolls back and re-raises on any exception.
Nested @transaction calls use PostgreSQL savepoints: a failure in an
inner method rolls back only that inner scope.
See also: Transaction — the context manager equivalent.
Utilities¶
ho_list(*relations)
¶
Combine relations into a single OR predicate, avoiding N SQL queries.
The typical use-case is membership testing::
members = ho_list(Author(id=1), Author(id=2), Author(id=3))
if some_author in members: # one query instead of three
...
ho_list is equivalent to chaining | manually::
r1 | r2 | r3 | ...
Set-membership semantics — r in ho_list(...) calls
Relation.__contains__, which computes
(r - ho_list(...)).ho_count() == 0. Two edge cases follow
directly from set theory:
-
∅ ⊆ S — if r matches no rows (the empty set), it is vacuously contained in every relation:
r in ho_list(...)always returnsTrue. -
Unconstrained operand — if any element of relations is unconstrained (
ho_is_set()returnsFalse), the OR absorbs all rows (the universal set). Anyr in ho_list(...)then returnsTrue, which is almost certainly a bug at the call site.
Alternative — existence check with ho_is_empty
When the intent is "does r have at least one row in common with this list?" rather than strict set inclusion, prefer::
if not (r & ho_list(r1, r2, r3)).ho_is_empty():
...
(r & union).ho_is_empty() computes the intersection first, so
it returns False when r matches no rows — avoiding the ∅ ⊆ S
edge case entirely.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
*relations
|
one or more :class: |
()
|
Returns:
| Name | Type | Description |
|---|---|---|
Relation |
the union predicate |
Raises:
| Type | Description |
|---|---|
ValueError
|
if called with no arguments. |