Dragon

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