Skip to content

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 RETURNING *).

()
upsert bool

add ON CONFLICT DO UPDATE to the INSERT. Default: False.

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.

Example

Insert an author:

alice = Author(
    first_name='Alice', last_name='Martin',
    email='alice@example.com',
).ho_insert()
alice['id']   # 1

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:

row = Person(last_name='Lagaffe', first_name='Gaston').ho_get()
print(row['id'], row['last_name'])

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.

from half_orm.relation_errors import NotFoundError, MultipleRowsError

last_name='Martin'
try:
    row = Author(last_name=last_name).ho_get()
except NotFoundError:
    print("no such author")
except MultipleRowsError as e:
    print(f"Ambiguous: more than one {last_name} found in authors")

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 DISTINCT to the SELECT. Default: False.

False
order_by str

SQL ORDER BY clause, e.g. 'last_name, first_name desc'. Default: None.

None
limit int

maximum number of rows to return. Default: None.

None
offset int

number of rows to skip. Default: None.

None
json_agg dict

aggregate already-set fkeys as JSON arrays via a LEFT JOIN + json_agg + GROUP BY on the primary key.

Each entry maps a fkey attribute name to its spec:

  • [field, ...] — list of column names; alias = fkey attr name.
  • {'fields': [...], 'alias': 'name'} — explicit alias.
  • [] — empty list returns all columns via row_to_json.
  • {'fields': [...], 'alias': 'name', 'distinct': True} — deduplicate aggregated rows using a correlated subquery (SELECT DISTINCT … FROM … WHERE join_cond) instead of a LEFT JOIN. Avoids duplicates produced by intermediate JOIN multiplications. Default: False.

The fkey must have been set via .fk_attr.set(rel) before calling ho_select.

The type of the aggregated value depends on the FK direction:

  • reverse FK, non-unique (one-to-many): a list of dicts, empty ([]) when no related rows exist.
  • reverse FK, unique (one-to-one via UNIQUE or PK constraint): a single dict, or None when no related row exists.
  • direct FK (many-to-one): a single dict, or None when the FK target is absent (nullable FK).
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=True).

()
distinct bool

if True, count only distinct tuples. Default: False.

False

Returns:

Name Type Description
int

the cardinality of the extension.

Example

ho_count usage:

Author().ho_count()                    # total number of authors
Author(last_name='Martin').ho_count()  # subset cardinality

New in version 0.18.0: distinct parameter.

ho_is_empty()

Return True if the extension is empty, False otherwise. Executes SQL.

Returns:

Type Description

bool

Example

ho_is_empty usage:

Author(last_name='Unknown').ho_is_empty()  # True if no such author

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 '*' to return all columns. If omitted, nothing is returned.

()
update_all bool

must be True when self has no constraint set, to confirm the intent to update all rows. Default: False.

False
**kwargs

{column_name: new_value} pairs to apply. None values are silently ignored.

{}

Returns:

Type Description

list[dict] | None: the updated rows if *args was provided,

otherwise None.

Raises:

Type Description
RuntimeError

if no constraint is set and update_all is False.

Example

ho_update usage:

# Update a single row — guarded by singleton check
Author(id=1).ho_assert_is_singleton().ho_update(email='new@example.com')

# Update an entire subset at once
Post(author_id=99).ho_update(content='[archived]')

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 '*' to return all columns.

()
delete_all bool

must be True when no primary key field is set, as a safety guard against accidental mass deletions. Default: False.

False

Returns:

Type Description

list[dict] | None: the deleted rows if *args was provided,

otherwise None.

Raises:

Type Description
RuntimeError

if the predicate is not set and delete_all is False.

Example

ho_delete usage:

# Delete one identified row
Author(id=99).ho_assert_is_singleton().ho_delete()

# Delete all posts for a given author
Post(author_id=1).ho_delete()

# Delete all posts
Post().ho_delete(delete_all=True)


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 list[dict] (column names are taken from the keys of the first dict) or a file-like object opened in text mode (CSV with a header row, or headerless if columns is given).

required
columns list[str] | None

explicit column list. Required when data is a headerless file-like object; ignored when data is a list[dict].

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.

Example

From a list of dicts:

n = Author.ho_copy([
    {'first_name': 'Bob', 'last_name': 'Martin',
     'birth_date': date(1980, 1, 1)},
    {'first_name': 'Eve', 'last_name': 'Dupont',
     'birth_date': date(1990, 5, 12)},
])
print(n)  # 2

From a CSV file (with header row):

with open('authors.csv') as f:
    n = Author.ho_copy(f)

From a headerless CSV file:

with open('authors_no_header.csv') as f:
    n = Author.ho_copy(
        f,
        columns=['first_name', 'last_name', 'birth_date'],
    )

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 NULL constraint) 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.

# Deletes ALL posts — no error raised
(Post(title='a') | Post(title=('!=', 'a'))).ho_delete()

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] — all schema.table names involved (leaf: derived from the SQL AST; compound/neg: union of children).
  • 'constraints': list[dict] — all leaf constraints, each with keys relation, 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 None if the relation

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.

Example

Author(last_name='Martin').ho_mogrify().ho_count()
displays:
select
count(*) from (select
r... .*
from
"blog"."author" as r...
where
    (r... ."name" = 'Martin'::text)) as ho_count

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.

Example

transaction decorator usage:

from half_orm.relation import transaction

@register
class Author(blog.get_relation_class('blog.author')):
    @transaction
    def publish_many(self, posts):
        for title, content in posts:
            self.post_rfk(title=title, content=content).ho_insert()

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 semanticsr 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 returns True.

  • Unconstrained operand — if any element of relations is unconstrained (ho_is_set() returns False), the OR absorbs all rows (the universal set). Any r in ho_list(...) then returns True, 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:Relation objects of the same table.

()

Returns:

Name Type Description
Relation

the union predicate r1 | r2 | ....

Raises:

Type Description
ValueError

if called with no arguments.