Learn halfORM in half an hour¶
halfORM is a database-first ORM for PostgreSQL. You write the schema in SQL; halfORM introspects it at runtime and gives you Python objects to work with your data. No migrations, no code generation.
The central idea: a Relation object is a predicate. It describes the
logical condition that rows must satisfy to belong to the relation. Its
extension — the set of rows that currently satisfy the predicate in the
database — is what you read, update, or delete.
Adding a constraint specialises the predicate. Set operators (|, &, -, ^)
are logical operators on predicates. Foreign key navigation composes predicates
across tables.
This guide covers everything you need, in order.
Schema used in this guide¶
create schema blog;
create table blog.author (
id serial primary key,
first_name text not null,
last_name text not null,
email text unique not null
);
create table blog.post (
id serial primary key,
title text not null,
content text,
author_id integer references blog.author(id) on delete cascade
);
create table blog.comment (
id serial primary key,
content text not null,
post_id integer references blog.post(id) on delete cascade,
author_id integer references blog.author(id) on delete cascade
);
1. Connect (1 min)¶
halfORM reads connection parameters from a file named after the database,
searched in the directory defined by HALFORM_CONF_DIR (defaults to
/etc/half_orm):
# /etc/half_orm/blog (or $HALFORM_CONF_DIR/blog)
[database]
name = blog
user = alice
password = secret
host = localhost
CLI exploration¶
Before writing any Python, use python -m half_orm to check connections and
explore schemas from the command line:
# Diagnostic: check all databases in HALFORM_CONF_DIR
python -m half_orm
# List all relations in a database
python -m half_orm blog
# Inspect a specific relation (columns, types, constraints, foreign keys)
python -m half_orm blog blog.author
[halfORM] version 0.18.x
📋 Available relations for blog:
r "blog"."author" → No description available
r "blog"."comment" → No description available
r "blog"."post" → No description available
📋 Relation Types:
r: Table
p: Partioned table
v: View
m: Materialized view
f: Foreign data
In Python¶
Get a class for a table with get_relation_class() — each class represents the set of all rows in that table:
Author = blog.get_relation_class('blog.author')
Post = blog.get_relation_class('blog.post')
Comment = blog.get_relation_class('blog.comment')
Print a class to inspect its schema (columns, types, constraints, foreign keys):
2. A Relation is a predicate¶
Instantiating with keyword arguments specialises the predicate by adding conditions. No argument → the tautology "is a row of this table" (the whole table). Each argument adds a conjunct.
Author() # predicate: "is an author" → all rows
Author(last_name='Martin') # predicate: "is an author named Martin" → subset
Author(last_name='Martin', id=42) # predicate: "Martin with id 42" → at most one row
The object does not query the database. It holds a predicate. The query happens
only when you explicitly ask for the extension (via ho_select, ho_count, etc.).
This is the foundation everything else builds on.
3. Query the extension (5 min)¶
Cardinality¶
Author().ho_count() # cardinality of "is an author"
Author(last_name='Martin').ho_count() # cardinality of "is an author named Martin"
Author(last_name='Unknown').ho_is_empty() # True if the extension is empty
Enumerate the extension¶
ho_select() is a generator that yields each row as a dict. Without arguments
it is equivalent to iterating directly on the relation:
for row in Author(): # equivalent to Author().ho_select()
print(row['first_name'], row['last_name'])
Pass field names to project only certain columns:
for row in Author(last_name='Martin').ho_select('id', 'email'):
print(row) # {'id': 1, 'email': 'alice@example.com'}
Control ordering and size of the result set:
Comparators¶
Equality is the default. For anything else, pass a (operator, value) tuple:
Author(last_name=('like', 'Mar%')) # set where last_name LIKE 'Mar%'
Author(last_name=('ilike', 'mar%')) # case-insensitive
Author(id=('>', 10)) # set where id > 10
Author(id=('in', [1, 2, 3])) # set where id IN (1, 2, 3)
NULL values¶
Python None unsets a constraint (no effect on the set definition).
Use half_orm.null.NULL to build the set of rows where a column is NULL:
from half_orm.null import NULL
Post(content=NULL) # set where content IS NULL
Post(content=('is not', NULL)) # set where content IS NOT NULL
4. Insert: add an element to a table (2 min)¶
ho_insert() inserts the row described by the object's constraints and returns
the inserted row as a dict:
row = Author(
first_name='Alice',
last_name='Martin',
email='alice@example.com',
).ho_insert()
print(row) # {'id': 1, 'first_name': 'Alice', 'last_name': 'Martin', ...}
5. Singleton predicate (2 min)¶
A predicate is a singleton when it logically identifies at most one row —
i.e. a primary key or unique NOT NULL constraint is fully specified with =.
ho_assert_is_singleton() verifies this without querying the database and
returns self for chaining. It raises NotASingletonError if the predicate
is not a singleton.
Author(id=42).ho_assert_is_singleton() # OK — id is the PK
Author(email='alice@example.com').ho_assert_is_singleton() # OK — email is UNIQUE NOT NULL
Author(last_name='Martin').ho_assert_is_singleton() # raises: last_name is not unique
Use it as a guard before any single-row write:
Author(id=42).ho_assert_is_singleton().ho_update(email='alice@newdomain.com')
Author(id=99).ho_assert_is_singleton().ho_delete()
6. Update: transform the extension (1 min)¶
ho_update() modifies every row that satisfies the predicate:
# Update one row — guarded
Author(id=1).ho_assert_is_singleton().ho_update(email='alice@newdomain.com')
# Update a whole subset at once
Post(author_id=99).ho_update(content='[archived]')
7. Delete: remove the extension (1 min)¶
ho_delete() removes every row that satisfies the predicate:
Author(id=99).ho_assert_is_singleton().ho_delete()
# delete_all=True is required when no primary key field is set
# — a deliberate safety guard against accidental mass deletions.
Post(author_id=1).ho_delete(delete_all=True)
8. Foreign keys: composing predicates across tables (10 min)¶
@register — make your subclass the default¶
blog.get_relation_class('blog.post') returns a generated base class. When you
subclass it, halfORM still uses the base class for FK navigation unless you
register your subclass:
@register tells the Model to use Post everywhere that table appears — FK
navigation returns your class, with your aliases and methods, not the
generated base class. Always decorate relation subclasses with @register.
Naming FK attributes¶
halfORM exposes every foreign key, but auto-generated names are long. Give them
friendly aliases in a Fkeys class attribute:
@register
class Post(blog.get_relation_class('blog.post')):
Fkeys = {
'author_fk': 'post_author_id_fkey', # FK → author
'comment_rfk': '_reverse_fkey_blog_comment_post_id', # ← comment
}
@register
class Author(blog.get_relation_class('blog.author')):
Fkeys = {
'post_rfk': '_reverse_fkey_blog_post_author_id',
'comment_rfk': '_reverse_fkey_blog_comment_author_id',
}
@register
class Comment(blog.get_relation_class('blog.comment')):
Fkeys = {
'post_fk': 'comment_post_id_fkey',
'author_fk': 'comment_author_id_fkey',
}
Finding FK names
print(Post()) lists all foreign keys with their internal names.
fkey() — extend the predicate into a related table¶
Calling a FK attribute produces a new predicate on the related table, restricted to the rows linked to the current predicate's extension:
post = Post(id=1)
author = post.author_fk() # predicate: "is the author of post 1"
posts_by_martin = Author(last_name='Martin').post_rfk()
# predicate: "is a post written by a Martin"
The result is itself a predicate — keep composing it:
# "is a post written by a Martin with non-empty content"
martin_posts = Author(last_name='Martin').post_rfk(content=('is not', NULL))
print(martin_posts.ho_count())
fkey.set(...) — compose predicates across a join¶
.set() adds a join condition: the rows satisfying the current predicate must
also be linked to rows satisfying the given predicate in another table.
halfORM generates the JOIN automatically. No import of the related class needed:
# "is a post whose author satisfies last_name LIKE 'Mar%'"
post = Post()
post.author_fk.set(last_name=('like', 'Mar%'))
print(post.ho_count())
Chain as many .set() calls as needed:
# "is a comment whose post was written by a 'Mar%' author"
comment = Comment()
post = Post()
post.author_fk.set(last_name=('like', 'Mar%'))
comment.post_fk.set(post)
print(comment.ho_count())
json_agg — attach a related predicate's extension as a JSON column¶
# For each author named Martin, their posts (id + title) as a JSON array
alice = Author(last_name='Martin')
alice.post_rfk.set()
for row in alice.ho_select(json_agg={'post_rfk': ['id', 'title']}):
# row['post_rfk'] = [{'id': 1, 'title': '...'}, ...]
print(row['last_name'], row['post_rfk'])
Empty list = all fields. Optional 'alias' key renames the output column:
9. Predicate algebra (5 min)¶
Predicates compose. Five logical operators map directly to Python operators — the result is always a new predicate.
martins = Author(last_name='Martin')
gmail = Author(email=('ilike', '%@gmail.com'))
martins | gmail # disjunction: "is a Martin" OR "has a gmail address"
martins & gmail # conjunction: "is a Martin" AND "has a gmail address"
martins - gmail # difference: "is a Martin" AND NOT "has a gmail address"
-martins # negation: NOT "is a Martin"
martins ^ gmail # symmetric difference: (A | B) - (A & B)
Every result is a predicate — query its extension like any other:
for row in (martins | gmail).ho_select('first_name', 'last_name', 'email'):
print(row)
(martins - gmail).ho_count()
Compose freely:
# "is a post with content whose title does not contain 'draft'"
relevant = (
Post(content=('is not', NULL)) &
-Post(title=('ilike', '%draft%'))
)
print(relevant.ho_count())
10. Transactions (5 min)¶
Transaction is a context manager that wraps operations in a single atomic unit.
It commits on success, rolls back on exception.
from half_orm.transaction import Transaction
with Transaction(blog):
alice_row = Author(
first_name='Alice', last_name='Martin', email='alice@example.com'
).ho_insert()
Post(
title='First post',
content='Hello world',
author_id=alice_row['id'],
).ho_insert()
# Both rows are committed, or neither is.
Transactions nest — inner Transaction blocks use savepoints automatically:
with Transaction(blog):
alice_row = Author(...).ho_insert()
with Transaction(blog): # savepoint
Post(...).ho_insert()
# exception here rolls back only the post, not Alice
11. Custom classes and business logic (2 min)¶
Put methods on your relation classes to encapsulate logic that belongs to a predicate:
from half_orm.relation import singleton
@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):
"""Add a post to the set defined by self (must be a singleton)."""
return self.post_rfk(title=title, content=content).ho_insert()
@singleton calls ho_assert_is_singleton() automatically — no DB round-trip,
pure predicate check:
alice = Author(id=1)
alice.publish('My post', 'Content here') # @singleton verifies alice is a singleton predicate
Quick reference¶
| Goal | Code |
|---|---|
| Connect | Model('dbname') |
| Get a relation class | Rel =model.get_relation_class('schema.table') |
| Tautological predicate (whole table) | rel = Rel() |
| Specialise a predicate | rel = Rel(field=val, ...) |
| Cardinality of the extension | rel.ho_count() |
| Enumerate the extension | rel.ho_select('col1', 'col2') |
| Add a row | Rel(col=val).ho_insert() |
| Update the extension | Rel(pred).ho_update(col=new_val) |
| Delete the extension | Rel(pred).ho_delete() |
| Assert singleton predicate (no DB) | Rel(pk=val).ho_assert_is_singleton() |
| Compose into related table | rel.fk_attr() |
| Compose via join | rel.fk_attr.set(Relation∣fields) |
| Aggregate related extension | rel.ho_select(json_agg={'fk_attr': ['col', ...]}) |
| Disjunction / conjunction / difference / negation / XOR | a ∣ b, a & b, a - b, -a, a ^ b |
| Atomic operations | with Transaction(model): |
| SQL NULL | from half_orm.null import NULL |
| Inspect generated SQL | model.sql_trace = True |
Next: the API reference documents every method in detail.