Classes and the @memo decorator

A memo class is very similar to a regular Python class but comes with several powerful, built-in features. 📝

What is a Memo Class?

A memo class behaves much like a standard Python class but includes these additional properties:

  • Persistence: Instances are automatically saved (persisted) when referenced by other objects or tags, making them accessible later.

  • Managed Storage: Instances are stored efficiently, either on disk or in memory, depending on how frequently they are accessed.

  • Automatic Type Conversion:

    • Simple attributes (like int, str, float, date, bytes) are converted to dbzero types behind the scenes but behave just like standard Python types when you access them.

    • Collection attributes (tuple, list, set, dict) are converted to dbzero collections. They work like regular Python collections but are managed by dbzero.

    • Future Integration: We plan to add support for pandas DataFrames in the near future. 🐼

  • Object Members: Attributes can be other python objects, as long as they're instances of a memo class.

  • UUID: Each instance is automatically assigned a universally unique identifier (UUID), which can be used externally in API clients or web applications to reference the object directly.

  • Tagging: You can apply custom tags to memo instances, allowing for easy searching and retrieval later on.

Now, let's see what a typical memo class looks like in practice.

Declaring Memo Classes

To declare a memo class, simply apply the @dbzero.memo decorator to a standard Python class. Unlike many database frameworks, there's no need to define a separate data model or schema. You assign attributes directly in the constructor (__init__), just as you would with any regular Python object.

import dbzero as db0
 
@db0.memo
class Person:
    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

Instantiating and Modifying Instances

Instantiating a memo class works exactly like it does for a regular Python class. The key difference is that each new instance is automatically saved (persisted) under the current prefix. (See Understanding Prefixes and Scoped Types to learn more).

Furthermore, memo class instances are fully dynamic. You can add, remove, or change their attributes at any time, just as you would with a standard Python object.

# Instantiation is the same as with a regular class
person_1 = Person("Alice", 30)
person_2 = Person("Bob", 25)
 
# Attributes can be modified
person_1.name = "Alice Smith"
person_2.age += 1
 
# Attributes can be added on the fly
person_2.address = "123 Main St"

Using Collections as Members

You can use standard Python collections like tuple, list, set, and dict as attributes in your memo classes. When assigned, dbzero automatically converts them into persistent, managed collections that still behave just like their standard Python counterparts.

from datetime import date
import dbzero as db0
 
@db0.memo
class Person:
    def __init__(self):
        # A tuple of strings
        self.name = ("Alice", "Smith", "Mrs")
        
        # A list of date objects
        self.important_dates = [date(2023, 1, 1), date(2023, 12, 31)]
        
        # A set of strings
        self.favorite_animals = {"cat", "dog", "rabbit"}
        
        # A dictionary
        self.addresses = {"home": "123 Main St", "work": "456 Elm St"}

Referencing Other Memo Objects

You can assign memo objects as attributes. This lets you build complex, nested object graphs. The number of nested objects, their size, and their dependencies are limited only by available storage.

Memo objects are reference-counted. An object will automatically persist as long as at least one other object holds a reference to it. When the last reference is removed, the object qualifies for garbage-collection.

For example, a Pet object can hold a reference to a Person object.

@db0.memo
class Pet:
    def __init__(self, name: str, owner: Person = None):
        self.name = name
        self.owner = owner
 
cat = Pet("Whiskers", Person("Bob", 30))

Using Memo Objects in Python Collections

You can store memo objects in standard Python collections, such as list or dict, just like any other Python object. A collection holds references to its items. Therefore, as long as a memo object is part of a collection, it is referenced and will persist. The objects inside may be garbage-collected only after the collection itself goes out of scope, assuming no other references to them exist.

In this example, three Pet objects are created and stored in a standard Python list. They will persist at least as long as the bob_cats list exists in memory or is referenced by other dbzero-managed object.

# Assuming 'Person' and 'Pet' are @memo classes
bob = Person(name="Bob", age=30, pets=None)
 
# The Pet objects are stored in a regular Python list
bob_cats = [Pet(name, bob) for name in ["Whiskers", "Garfield", "Mittens"]]
bob.pets = bob_cats

Modifying Memo Objects

You can modify memo objects at any time after they're created. All changes are persistent, meaning they are saved to the durable persistent automatically.

Memo objects are flexible, just like regular python objects. You can update existing attributes, change an attribute's data type, add new attributes, or remove them entirely using standard Python syntax.

The following example demonstrates these operations on a Person object named bob:

# Assume 'bob' is an existing memo object: Person("Bob", 30)
 
# 1. Update an attribute's value
bob.name = "Robert"
 
# 2. Change an attribute's type (e.g., from int to Decimal)
bob.age = Decimal("31.0")
 
# 3. Add a new attribute
bob.address = "456 Elm St"
 
# 4. Remove an attribute
del bob.address

Flexible Schemas

By design, memo classes are schema-less. This means you can create instances of the same class that have different data types for the same attribute.

While flexible schema is great, you can still enforce strict data validation, by integrating third-party libraries like pydantic or marshmallow.

Even though no schema is enforced, the library tracks the data types you use. You can inspect this information with the dbzero.get_schema function (see . For each attribute, it returns:

  • primary_type: The most common data type used.
  • all_types: A list of all data types ever used for that attribute across all instances.

The example below shows three Car objects where the photo attribute is stored as None, a URL string, and bytes.

@db0.memo
class Car:
    def __init__(self, brand, model, year, photo):
        self.brand = brand
        self.model = model
        self.year = year
        self.photo = photo
 
# Create instances with different types for the 'photo' attribute
toyota = Car("Toyota", "Corolla", 2020, None)
bmw = Car("BMW", "X5", 2021, "[https://example.com/bmw-x5.jpg](https://example.com/bmw-x5.jpg)")
audi = Car("Audi", "A4", 2022, open("car_photo.jpg", "rb").read())
 
# Inspect the schema inferred from the objects
db0.get_schema(Car)
 
# Output:
# {
#  'brand': {'primary_type': <class 'str'>, 'all_types': [<class 'str'>]},
#  'model': {'primary_type': <class 'str'>, 'all_types': [<class 'str'>]},
#  'year': {'primary_type': <class 'int'>, 'all_types': [<class 'int'>]},
#  'photo': {
#    'primary_type': <class 'bytes'>,
#    'all_types': [<class 'bytes'>, <class 'str'>, <class 'NoneType'>]
#  }
# }

Class Inheritance

Memo classes support inheritance, allowing you to create a hierarchy of related classes, just as you would with regular Python classes. A memo class can inherit from other memo classes or from standard Python classes. When you create an instance of a child class, it functions as a single memo object that inherits attributes and methods from its parents. The maximum inheritance depth is 255 levels, which is sufficient for virtually any application.

This example shows an ElectricCar that inherits from Car, which in turn inherits from Vehicle.

@db0.memo
class Vehicle:
    def __init__(self, brand: str, model: str, year: int):
        self.brand = brand
        self.model = model
        self.year = year
 
@db0.memo
class Car(Vehicle):
    def __init__(self, brand: str, model: str, year: int, fuel_type: str):
        super().__init__(brand, model, year)
        self.fuel_type = fuel_type
 
@db0.memo
class ElectricCar(Car):
    def __init__(self, brand: str, model: str, year: int, battery_capacity: float):
        # The fuel_type is fixed to "electric" for this class
        super().__init__(brand, model, year, "electric")
        self.battery_capacity = battery_capacity
 
# Create an instance of the child class
nissan_leaf = ElectricCar("Nissan", "Leaf", 2024, 40.0)
 
# The instance is recognized as a member of its entire inheritance chain
print(f"Is it a Vehicle? {isinstance(nissan_leaf, Vehicle)}")
# Output: Is it a Vehicle? True
 
print(f"Is it a Car? {isinstance(nissan_leaf, Car)}")
# Output: Is it a Car? True
 
print(f"Is it an ElectricCar? {isinstance(nissan_leaf, ElectricCar)}")
# Output: Is it an ElectricCar? True

Multiple Inheritance and Mixins

Directly inheriting from more than one memo class is not supported. However, you can achieve similar functionality by using mixins. A mixin is a standard Python class that provides a specific set of methods or attributes. A memo class can inherit from a single parent memo class and one or more standard Python mixin classes. This pattern allows you to compose functionality from different sources without violating the single-memo-parent rule.

In this example, ElectricCar inherits its core behavior from Car (a memo class) and adds battery-related features from the HasBattery mixin (a regular Python class).

# Assume 'Car' is an existing @memo class that requires brand, model, year, and fuel_type.
 
# A standard Python class designed to be used as a mixin.
class HasBattery:
    def __init__(self, battery_capacity: float):
        self.battery_capacity = battery_capacity
 
    def get_range(self) -> float:
        # A method provided by the mixin (e.g., 5 km of range per kWh)
        return self.battery_capacity * 5.0
 
@db0.memo
class ElectricCar(Car, HasBattery):
    def __init__(self, brand: str, model: str, year: int, battery_capacity: float):
        # Call the __init__ for the parent @memo class
        Car.__init__(self, brand, model, year, fuel_type="electric")
        # Call the __init__ for the mixin class
        HasBattery.__init__(self, battery_capacity)
 
# Create an instance
nissan_leaf = ElectricCar("Nissan", "Leaf", 2024, 40.0)
 
# Call a method from the mixin
print(f"Range: {nissan_leaf.get_range()} km")
# Output: Range: 200.0 km

Object Lifetime and Persistence

The lifetime of a memo object is managed automatically through reference counting. An object (excluding singletons) is guaranteed to exist in the storage as long as there is at least one active reference to it. There is no need for explicit "save" or "delete" of memo objects.

An active reference can be:

  • An attribute of another memo object.
  • A collection holding the object.
  • A tag pointing to the object.
  • A Python variable in your application's current scope.
💡

Objects are kept alive also by tags you assign to them, so they can be retrieved with a query at any time (see Working with Tags). To truly "delete" a tagged objects, you must first remove all tags from it.

When the last reference to an object is removed, it is automatically garbage-collected and deleted from storage.

Keep in mind that references from Python variables are only valid while the dbzero workspace is open. When dbzero is closed, these variables become stale, and any attempt to access the memo object through them will result in an error.

Memo Singletons

A singleton is a special memo class restricted to a single instance. You can think of it as a "root" object or a central entry point for your application's data. They are useful for managing global state or configuration.

Unlike regular memo objects, singletons are not reference-counted. They persist permanently until they are explicitly deleted.

You create a singleton by decorating its class with @dbzero.memo(singleton=True).

  • To create it, call the constructor with its arguments, usually once at application startup.
  • To retrieve it later, call the constructor without any arguments. To do this more explicitely, you can also use dbzero.fetch(TypeName) (see fetch).

This example defines a singleton MySalesApp to hold application-wide data.

# Assume 'Client' is another @memo class
@db0.memo(singleton=True)
class MySalesApp:
    def __init__(self, market_name: str):
        self.market_name = market_name
        self.customers = [] # Use a list for .append()
        self.prices = {}
        self.special_offers = {}
 
    def add_customer(self, customer: Client):
        self.customers.append(customer)
 
# --- Initialization phase ---
# The singleton is created here with its initial arguments.
root = MySalesApp("UK")
root.add_customer(Client(email="george.montana@gmail.com", account_id="12345"))
 
# --- Later, in another part of the code ---
def calculate_price(product, quantity):
    # Retrieve the existing singleton instance by calling with no arguments.
    app = MySalesApp()
    price_per_unit = app.prices.get(product, 0)
    return price_per_unit * quantity

Object UUIDs and Retrieval

Every memo object has a permanent universally unique identifier (UUID) upon creation.

These UUIDs have several key properties:

  • Unique & Permanent: They are guaranteed to be unique across all systems and are never reused, even after an object is deleted.
  • Compact & Safe: They are short, URL-friendly strings. They are also "intelligence-free," meaning they don't reveal any internal information about the data they identify.

Retrieving an object by its UUID is the most efficient way to access data, offering a time complexity of O(1).

You can get an object's UUID with dbzero.uuid and retrieve the object using dbzero.fetch.

obj = Person("Alice", 30)
 
# Get the UUID of the object
uuid = db0.uuid(obj)
print(f"Object UUID: {uuid}")
 
# Fetch the object by its UUID (the fastest retrieval method)
retrieved_obj = db0.fetch(uuid)
print(f"Retrieved name: {retrieved_obj.name}")
 
# You can also provide a class to ensure the retrieved object is of the expected type.
# This will raise an error if the UUID exists but points to a different type of object.
retrieved_person = db0.fetch(uuid, Person)
print(f"Retrieved person's name: {retrieved_person.name}")

Stable Type Identification with id

By default, the library links a Python class to its stored data using a combination of its class name, module name, and constructor fields. This is convenient and works well for minor changes, like adding a new attribute. However, this link can break if you perform major refactoring, such as renaming the class while moving it to a different file. To create a permanent identifier that survives these changes, you can assign an explicit type id.

A type id ensures that your class remains linked to its data, regardless of any future modifications to its name, location, or structure. A recommended naming convention for the type id is a globally unique, namespaced string to prevent collisions, such as /organization/project/module/ClassName.

# This type `id` creates a permanent link to the class's data.
@db0.memo(id="/acme/destruction-planner/utilities/Calendar")
class AppointmentBook:
    # The class name (originally 'Calendar') can be refactored freely,
    # as can its fields and module location, because the type `id` remains constant.
    pass

Converting to Native Python Types

To convert a memo object and its entire graph of nested objects into native Python types (like a dict or list), use the dbzero.load function. This is essential for tasks like serializing data for an API response or exporting it to a standard format like JSON. dbzero.load() recursively walks through the object and all its attributes. To prevent infinite recursion, it will raise an error if it detects a cycle in the object graph.

You can customize the conversion logic for any class by implementing a __load__(self, ...) method. This gives you full control over the output, allowing you to hide fields, add computed values, or create different representations based on arguments passed to dbzero.load().

The following example shows both default and custom conversion:

# Default conversion recursively creates dictionaries.
person_dict = db0.load(Person("Alice", 30))
# person_dict is {'name': 'Alice', 'age': 30}
 
# --- Custom Conversion Logic ---
@db0.memo
class Car:
    def __init__(self, brand, model, year):
        self.brand = brand
        self.model = model
        self.year = year
 
    def __load__(self, detailed=False):
        # This method controls how Car objects are converted.
        if detailed:
            # Return a detailed dictionary
            return {
                "brand": self.brand,
                "model": self.model,
                "year": self.year,
            }
        # By default, return a simple summary string
        return f"{self.brand} {self.model}"
 
# --- Using the custom __load__ method ---
my_car = Car("Toyota", "Corolla", 2020)
 
# 1. Default case (detailed=False)
summary_view = db0.load(my_car)
# summary_view is now "Toyota Corolla"
 
# 2. Detailed case
detailed_view = db0.load(my_car, detailed=True)
# detailed_view is {'brand': 'Toyota', 'model': 'Corolla', 'year': 2020}