Examples for my ZODB Talk

Author: Kevin Dangoor
Date: July 3, 2008

The example code comes from yachts.py and test_yachts.py. The code in here is used for illustration and is incomplete. Look in the other files.

Let's start with the imports:

# <== include('yachts.py', 'imports') ==>
# The ZODB package provides us with a FileStorage which knows how to
# store the data (this is the one that most people use), and a DB
# object that provides access to the database.
from ZODB import FileStorage, DB

# persistent gives us a base class that helps detect changes in
# persistent objects.
from persistent import Persistent
from persistent.list import PersistentList

# transaction gives us the tools to handle atomic transactions
# in the ZODB (ZODB is ACID compliant).
import transaction

# the Standalone ZCatalog package
from zcatalog import catalog
from zcatalog import indexes

# <==end==>

Now, we connect to the database:

# <== include('yachts.py', 'connect') ==>
def connect(filename="yacht_data.zodb"):
    """Connects to the database and sets the root."""
    global db, conn, root, last_filename
    last_filename = filename
    storage = FileStorage.FileStorage(filename)
    db = DB(storage)

    conn = db.open()
    root = conn.root()
# <==end==>

Let's define a customer object:

# <== include('yachts.py', 'customer') ==>
class Customer(Persistent):
    def __init__(self, name):
        self.name = name

    def __str__(self):
        return "Customer: " + self.name

    __repr__ = __str__
# <==end==>

First thing to note is that the ZODB is ACID-compliant. Let's add a customer:

# <== include("test_yachts.py", "add_customer") ==>
root = get_root()
p = Customer("Josie MacGuffin")
root['customer'] = p
transaction.commit()
# <==end==>

Watch how we can abort a transaction:

# <== include("test_yachts.py", "acid_compliant") ==>
p = Customer("Edward Snellmaster")
root['customer'] = p
transaction.abort()
assert root['customer'].name == "Josie MacGuffin"
# <==end==>

We want to have more than one customer, so let's keep them in a list:

# <== include("test_yachts.py", "customers_in_list") ==>
del root['customer']
root['customers'] = []
root['customers'].append(Customer(random.choice(names)))
root['customers'].append(Customer(random.choice(names)))
transaction.commit()
root = reconnect()
assert len(root['customers']) == 2
# <==end==>

But wait, a list isn't Persistent:

# <== include("test_yachts.py", "problem_with_customers_in_a_list") ==>
root['customers'].append(Customer(random.choice(names)))
assert len(root['customers']) == 3
root = reconnect()
assert len(root['customers']) == 2
# <==end==>

We should use a PersistentList object:

# <== include("test_yachts.py", "use_persistent_list") ==>
current_customers = root['customers']
root['customers'] = PersistentList(current_customers)
transaction.commit()
root = reconnect()
assert len(root['customers']) == 2
root['customers'].append(Customer(random.choice(names)))
assert len(root['customers']) == 3
transaction.commit()

root = reconnect()
assert len(root['customers']) == 3
# <==end==>

Good to note that the ZODB is indeed not a relational database:

# <== include("test_yachts.py", "add_attribute") ==>
customer = root['customers'][0]
customer.phone = "555-1212"
transaction.commit()
# <==end==>

You can create "volatile" attributes that you don't want persisted:

# <== include("test_yachts.py", "volatile") ==>
customer = root['customers'][0]
customer._v_someval = 1
transaction.commit()
root = reconnect()
customer = root['customers'][0]
assert not hasattr(customer, "_v_someval")
# <==end==>

Now, let's do something more complex. We're going to set things up so that we can take orders for people's yachts. We'll start by defining the various features that people can add:

# <== include("yachts.py", "features") ==>
class Feature(Persistent):
    """Describes a feature of the yacht."""
    name = None
    price = None
    size = None

    def __init__(self, name, price, size):
        self.name = name
        self.price = price
        self.size = size

    def __str__(self):
        return "%s (%s sq ft, $%s)" % (self.name, self.size,
            locale.format("%d", self.price, True))

    __repr__ = __str__

DiningRoom = Feature("Dining Room", 100000, 200)
Bedroom = Feature("Bedroom", 50000, 100)
MasterSuite = Feature("Master Suite", 150000, 250)
HotTub = Feature("Hot Tub", 40000, 64)
Helipad = Feature("Helipad", 200000, 900)
SharksWithLasers = Feature("Sharks with Lasers", 1000000, 100)
# <==end==>

Here's the definition of an order for a Yacht:

# <== include("yachts.py", "yacht_main_part") ==>
class Yacht(Persistent):
    def __init__(self, owner, name, size, price):
        for k, v in locals().items():
            setattr(self, k, v)
        self._features = []

    def __str__(self):
        output = ["Yacht: %s" % self.name]
        output.append("Owner: %s" % (self.owner))
        output.append("Size (usable sq ft): %s" % (self.size))
        output.append("Base price: $%s" % (locale.format("%d", self.price, True)))
        output.append("Features:")
        total = self.price
        for feature in self._features:
            total += feature.price
            output.append("  %s" % (feature))
        output.append("Total Cost: $%s" % locale.format("%d", total, True))
        return "\n".join(output)

# <==end==>

Here's an example of changing a mutable object. Specifically, when we change that _features list on a Yacht:

# <== include("yachts.py", "add_feature") ==>
def add_feature(self, feature):
    current_used = sum(feature.size for feature in self._features)
    if current_used + feature.size > self.size:
        raise ValueError("Not enough space for %s" % feature)

    self._features.append(feature)
    self._p_changed = True
# <==end==>

Let's see how you can make a Yacht, but only add as many features as will fit:

# <== include("test_yachts.py", "create_yacht") ==>
root['orders'] = PersistentList()
customer = root['customers'][0]
order = Yacht(customer, "Blustery Barnacles", 200, 100000)
root['orders'].append(order)
order.add_feature(Bedroom)
order.add_feature(SharksWithLasers)
try:
    order.add_feature(Helipad)
    assert False, "Not enough space for a helipad"
except ValueError:
    pass
# <==end==>

The ZODB is a true object database and handles object references just fine:

# <== include("test_yachts.py", "object_identity") ==>
customer = root['customers'][0]
order = root['orders'][0]
assert customer is order.owner
# <==end==>

You can use __setstate__ to help with the equivalent of "schema migration". Note that changes made by setstate only actually persist if the object is touched in some other fashion:

# <== include("test_yachts.py", "schema_migration") ==>
customer = root['customers'][0]
Customer.__str__ = lambda self: "%s (%s)" % (self.name, self.email)
try:
    str(customer)
    assert False, "Should have gotten attribute error, because email is new"
except AttributeError:
    pass

# we can use __setstate__
def setstate(self, state):
    super(Customer, self).__setstate__(state)
    if 'email' not in state:
        self.email = "none"

Customer.__setstate__ = setstate

root = reconnect()
customer = root['customers'][0]
assert str(customer) == "%s (none)" % (customer.name), "Customer: %s" % (customer)
# <==end==>

You can do searches via standard Python list comprehensions. To do more sophisticated searches, you need something more. Standalone ZCatalog is one option. Let's create an index of our customers, with a full-text index of their names:

# <== include("yachts.py", "create_catalog") ==>
def create_catalog():
    root = get_root()
    if 'catalog' not in root:
        cat = catalog.Catalog()
        ti = indexes.TextIndex(field_name='name')
        cat['name'] = ti
        root['catalog'] = cat
    else:
        cat = root['catalog']
    for customer in root['customers']:
        cat.index_doc(customer)
# <==end==>

Let's try searching it:

# <== include("test_yachts.py", "searchtest") ==>
customer = Customer("George Manfransinginsen")
root['customers'].append(customer)
create_catalog()
transaction.commit()
cat = root['catalog']
matches = list(cat.searchResults(name="Manfransinginsen"))
assert customer in matches
# <==end==>

This works based on the BTrees package, so that it doesn't need to load in all of the objects.

An alternative to ZCatalog is IndexedCatalog

Useful links: