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_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.

See Learn halfORM in half an hour for a full walkthrough.


Executors

These methods execute SQL immediately and return results.

ho_insert(*args)

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

()

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_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.

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=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) async

Async variant of ho_insert. Executes SQL.

New in version 0.18.0.

Sync counterpart: ho_insert

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


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. 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()

# Typical usage: guard a single-row write
Author(id=42).ho_assert_is_singleton().ho_update(email='new@example.com')

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')                    # True  — right operand is constrained
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 SQL JOIN and WHERE clauses as a dict, or None if no constraint.

Returns:

Type Description

dict with keys: 'joins' : list of JOIN SQL strings (one per joined relation) 'where' : WHERE expression SQL string, or None 'values' : list of string values (join values first, then where values)

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.