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

ORDER BY clause: a comma-separated list of <column> [asc|desc] [nulls first|last], e.g. 'last_name, first_name desc'. Column names are checked against the relation and quoted; an output-column ordinal ('1 desc') is accepted too. Anything else — an expression, a function call, a qualified name — raises ValueError, since the clause is interpolated into the query rather than bound as a parameter. 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': '...'}, ...]

# Chained FK, forward all the way (A → B → C) — pull fields from an
# INTERMEDIATE hop too, not just the leaf, via 'intermediate_nodes'.
# comment → post → person: comment's own 'content' alongside the
# post's author's 'last_name', merged into one flat object.
comment = Comment(content='nice post')
post = Post()
post.author_fk.set()               # chain: post → person (the leaf)
comment.post_fk.set(post)
for row in comment.ho_select(json_agg={
    'post_fk': {
        'fields': ['last_name'],             # leaf (person)
        'intermediate_nodes': {'fields': ['title']},  # hop 1 (post)
    },
}):
    print(row['post_fk'])  # {'title': '...', 'last_name': '...'}

intermediate_nodes is purely additive — omit it and a chain behaves exactly as before. Field names must be unique across the whole chain (a collision raises RuntimeError), and it requires an explicit, non-empty fields list at the leaf.

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, json_agg=None) async

Async variant of ho_select. Returns a list of dicts (not a generator). Executes SQL.

New in version 0.18.0.

New in version 0.18.6 (async parity): json_agg parameter — see :meth:ho_select for its full documentation (aggregate already-set fkeys as JSON arrays/objects via a LEFT JOIN/correlated-subquery + json_agg/jsonb_agg).

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', 'table_path' and 'neg'. The last is True when the relation itself is negated (~r on an operand carrying no set operator); a negated compound is reported as a negation node instead, so consumers that ignore 'neg' would read a negated leaf as if it were not negated.

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

Returns a hashable, canonical key for this relation's structure.

The key identifies how the question was asked: the relation, and the shape of the predicate built on it. It is a pure function of the object, issues no query, and can be used as a dictionary key or a set member, which a Relation itself cannot be: defining __eq__ as set equality makes instances unhashable, since no pure function of the object can track an equality that depends on the current state of the database.

Two relations with the same key always denote the same query. The converse does not hold: Person(last_name='Martin') and Person(first_name='Jo') may match the same rows today and still have different keys. The error is therefore always on the safe side — a cache keyed this way may miss, never return the wrong rows.

Operand order is normalised for the commutative operators, so a | b and b | a share a key. No attempt is made to reconcile predicates that are logically equivalent but structurally different; that is query equivalence, which is NP-complete for conjunctive queries under set semantics, and not the business of a hash key.

The key is recomputed on each call, in time proportional to the size of the predicate: a Relation is mutable, so caching it would be wrong without invalidation.

Arguments passed to the terminal method are not part of the key. ho_select() and ho_select(limit=10) run on the same relation and share a key, so a cache must be keyed on the call, not on the relation alone, whenever those arguments vary.

Returns:

Name Type Description
tuple

((fqrn, only), canonical_predicate), where fqrn is the

(database, schema, relation) triple, only says whether the

query reads the relation with ONLY, excluding the rows of its

inheritance children, and the last member is None when the

relation is unconstrained.

Example

Memoise results in the application, since halfORM keeps no cache of its own:

cache = {}

def rows(rel):
    key = rel.ho_structural_key()
    if key not in cache:
        cache[key] = list(rel.ho_select())
    return cache[key]

rows(Post(published=True))               # one query
rows(Post(published=True))               # served from cache
rows(Author(name='Alice').rfk_posts())   # other key, one query

Issue each query once when several code paths build predicates independently:

wanted, seen = [], set()
for spec in report_specs:
    rel = build_predicate(spec)
    if (key := rel.ho_structural_key()) not in seen:
        seen.add(key)
        wanted.append(rel)

New in version 1.2.0.

Structural, not extensional

The key answers how was this asked, never what comes back. Two predicates matching the same rows today keep different keys if they are written differently, so a cache keyed this way may miss but can never return the wrong rows. Invalidation remains the application's business: a Relation is a predicate, not a snapshot.

ho_read_set()

Returns the physical relations whose contents can change this result.

Reading a view reads the relations it is defined over, and reading a table without ONLY reads its inheritance children as well. The two compose, so a view over an inherited table reaches that table's children. Foreign-key navigation adds the relations it joins. Views are absent from the result, having no rows of their own.

An application cache can index its entries by these names and drop the ones a write touches. Note the boundary: this catches writes issued by this process. Another client writing to the database leaves the cache stale, so the scheme suits data that changes rarely and through one application. Cross-process invalidation needs LISTEN/NOTIFY and triggers, which is the application's business, not the library's.

The answer depends only on the schema. It is computed once per relation and dropped by reconnect(reload=True).

Returns:

Type Description

frozenset[str]: 'schema.name' for each physical relation read.

Example

Drop the cache entries a write invalidates:

class QueryCache:
    def __init__(self):
        self._rows, self._by_table = {}, {}

    def rows(self, rel):
        key = rel.ho_structural_key()
        if key not in self._rows:
            self._rows[key] = list(rel.ho_select())
            for table in rel.ho_read_set():
                self._by_table.setdefault(table, set()).add(key)
        return self._rows[key]

    def invalidate(self, table):
        for key in self._by_table.pop(table, ()):
            self._rows.pop(key, None)

cache.invalidate('blog.post')   # after a write on blog.post

New in version 1.2.0.

Invalidation across processes needs a trigger

ho_read_set() tells an application which cache entries a write touches, but only for writes it makes itself. To catch every client, have the database say so — a statement-level trigger that calls pg_notify, and a listener on notifies():

CREATE FUNCTION public.ho_notify_write() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
    PERFORM pg_notify('ho_write', TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME);
    RETURN NULL;
END $$;

CREATE TRIGGER ho_notify_write
    AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON blog.post
    FOR EACH STATEMENT EXECUTE FUNCTION public.ho_notify_write();

Statement-level, so one notification per statement rather than per row; PostgreSQL delivers it at commit and collapses duplicates within a transaction. This is DDL, so it belongs in a schema patch, not in halfORM.

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