ODB: The Object Database
The Databases chapters connect Dragon to SQL engines. This section is about the other database: the one written in Dragon.
ODB is an embedded, single-file, ACID document database, written in Dragon itself. You store JSON documents, you declare their shape with JSON Schema, you get real referential integrity, snapshot reads that never block writers, and a typed query language (OQL) that rejects a bad query before it runs. Think SQLite's deployment story - one file, no server, no setup - with a document data model that keeps relational speed where you declare structure.
ODB is a package. Add it to your project and connect:
dragon grab odb
from odb import connect, ODB, Documents, Document, OQL
db: ODB = connect("shop.odb") # creates the file if it does not exist
That is the entire installation. One .odb file is the database and the unit of sharing: copy it, email it, check it into an artifact store. A transient shop.odb.lock sidecar exists only while a process has the file open; it is disposable and never part of the artifact.
Two concepts
ODB has exactly two user-facing ideas:
- A schema is a named JSON Schema that owns a collection of documents. It compiles to a validator and a physical layout - schemas are never interpreted at runtime.
- Metadata declares a schema's primary key, unique constraints, indexes, and refs (relationships).
Everything else - queries, integrity, history, replication - is built from those two.
customers: Documents = db.schema("customers", {
"type": "object",
"properties": {
"id": {"type": "integer"},
"name": {"type": "string"},
"email": {"type": "string"},
"city": {"type": "string"},
"vip": {"type": "boolean"}
},
"required": ["id", "name", "email", "city"]
}).meta({
"primary": ["id"],
"unique": ["email"],
"index": ["city"]
})
schema() registers the shape and returns a Documents handle; .meta() attaches the keys and indexes and returns the same handle, so a definition reads as one chained statement. Both are committed transactionally: the schema and its indexes exist together or not at all.
Saving and reading documents
ada: Document = customers.save({"id": 1, "name": "Ada",
"email": "ada@x.com", "city": "Lagos"})
print(ada["_id"]) # 1 - the internal docid, always readable
got: Document = customers.read(id=1) # point lookup through the unique index
print(got["name"]) # Ada
got.edit({"city": "Abuja"}) # partial update, persisted
print(customers.count()) # documents in the collection
A Document is subscriptable like a dictionary (got["name"]), knows how to edit() and wipe() itself, and always carries _id. read() takes either the internal docid positionally or indexed fields as keywords.
For everything beyond point lookups there is OQL, the query language the next chapters cover. You write it as a template[OQL] value, so every interpolated !{...} is a bound parameter, never spliced into the query text - injection is unrepresentable, and the rows come back typed:
class Customer {
id: int
name: str
city: str
def(d: Document) { # a row hydrates itself from the matched document
self.id = d["id"]
self.name = d["name"]
self.city = d["city"]
}
}
where: str = "Lagos"
lagos: list[Customer] = db.all[Customer](template[OQL] { customers ? city == !{where} })
print(lagos[0].name) # Ada - a real field, not a dict lookup
Integrity is not optional
Declare a constraint and ODB enforces it at commit, atomically:
from odb.errors import IntegrityError
try {
customers.save({"id": 3, "name": "Dup", "email": "ada@x.com", "city": "Jos"})
} except IntegrityError as e {
print("refused:", e) # unique index 'email' already holds ada@x.com
}
Refs work the same way: an order pointing at customer 99 is refused if no such customer exists, and deleting a customer that orders still reference is refused too (or cascades, if you asked for that). The next chapter covers the whole metadata vocabulary.
Proving the file is healthy
from odb import CheckReport
rep: CheckReport = db.check()
print(rep.ok) # True, or rep.message says exactly what broke
check() verifies tree structure, index completeness (every document in every index, no orphans, no phantoms), and full ref integrity in one pass. Every page in the file carries a checksum that is verified on read; damage surfaces as a loud CorruptionError, never as silently wrong data.
Kill it whenever you like
ODB commits with a copy-on-write page tree and an atomic meta-page swap. There is no write-ahead log to replay and no recovery phase: kill the process at any instant - kill -9, power loss, a crashed container - and the next open sees the last committed state, immediately. The engine's own test harness proves this by simulating crashes at every single I/O boundary across hundreds of seeded trials.
Readers get the same guarantee live: a read pins an immutable snapshot, so a long scan never blocks a writer and never sees a half-committed state.
When to reach for ODB
Reach for SQL databases when you already operate PostgreSQL or MySQL, or when other stacks must query the same server. Reach for ODB when the database should be part of the program: local-first apps, tools, services that want zero-dependency state, test fixtures you can copy around, or anywhere SQLite would fit but your data is documents with relationships. Close it when you are done:
db.close()
