Skip to content

DuckDB as a space

A SpaceProvider lets an external store answer MeTTa matches. The DuckDB example maps each SQL table to a relation whose head is the table name and whose arguments follow the table's column order.

DuckDBSpace belongs to extensions/python/examples/integration/duckdb_space.py. It is not exported by metta, and installing pymetta does not install a packaged DuckDB adapter. Copy or adapt the example when you want this integration.

Push ground positions into SQL

The provider inspects the pattern before it asks DuckDB for rows. Grounded values and the NULL symbol become SQL predicates. Variables stay open. MeTTa still unifies every returned atom:

python
    def match(self, pattern: Atom) -> Iterator[Atom]:
        if not (isinstance(pattern, Expression) and pattern.children and isinstance(pattern.head, Symbol)):
            # A shapeless pattern falls back to full enumeration; the engine
            # unifies, so this stays correct.
            yield from self.atoms()
            return
        table = pattern.head.name
        if table not in self.table_names():
            return
        columns = self.columns(table)
        if len(pattern.args) != len(columns):
            return
        where, parameters = [], []
        for column, arg in zip(columns, pattern.args):
            if isinstance(arg, Grounded) or (isinstance(arg, Symbol) and arg == NULL):
                # A ground position states its value; IS NOT DISTINCT FROM
                # is SQL equality that also finds NULL when NULL is asked.
                where.append(f"{_identifier(column)} IS NOT DISTINCT FROM ?")
                parameters.append(_to_sql_value(arg))
            # A non-NULL symbol stays out of the pushdown: rows carry text
            # as grounded strings, so a symbol never matches one and the
            # engine's unification is the answer, consistently.
        sql = f"select * from {_identifier(table)}"
        if where:
            sql += " where " + " and ".join(where)
        for row in self._conn.execute(sql, parameters).fetchall():
            yield Expression([Symbol(table), *(_to_atom_value(v) for v in row)])

Equality pushdown reduces the SQL work while preserving MeTTa's final structural check. A non-NULL symbol does not become a SQL text comparison because SQL text returns as a grounded string, not a symbol.

Register the provider

The example's attach helper accepts a connection, path, or in-memory database and registers the provider under a space name:

python
def attach(m, name: str, database: Any = ":memory:", tables: list[str] | None = None) -> DuckDBSpace:
    """Register a DuckDB database as a space on this engine. database is a
    connection, a path, or :memory:. A path or :memory: opens a connection
    the space owns and close() closes; a passed connection stays the
    caller's."""
    if hasattr(database, "execute"):
        provider = DuckDBSpace(database, tables)
    else:
        provider = DuckDBSpace(duckdb.connect(database), tables)
        provider._owns_connection = True
    metta.attach(name, provider)
    return provider

The normal use path creates tables, registers &crm, enumerates rows, and binds a ground position:

python
m = metta.space()
conn = duckdb.connect(":memory:")
conn.execute("create table users (id integer, name text)")
conn.execute("insert into users values (1, 'Ada'), (2, 'Bob'), (3, 'Cy')")
conn.execute("create table vips (id integer)")
conn.execute("insert into vips values (1), (3)")
provider = attach(m, "&crm", conn)

check("enumerate", m.run("!(collapse (match &crm (users $id $n) $n))"),
      [[Expression("Ada", "Bob", "Cy")]])
check("pushdown filter", m.run("!(match &crm (users 2 $n) $n)"), [["Bob"]])

Join and write

Provider matching is available directly, and the registered space composes with native facts inside one MeTTa expression:

python
# Provider-level match answers atoms directly.
check("provider-level match", list(provider.match(S.users(2, V.n))),
      [Expression(S.users, 2, "Bob")])

# One match joins SQL tables with each other and with native facts.
m.run("(nickname 1 the-countess)")
(group,) = m.run(
    "!(collapse (match &crm (, (vips $id) (users $id $n)) "
    "(match (context-space) (nickname $id $nick) ($n $nick))))"
)
check("SQL joined with native facts", group, [Expression(Expression("Ada", S["the-countess"]))])

# Writes: add-atom inserts, remove-atom deletes, from running MeTTa.
m.run('!(add-atom &crm (users 4 "Dee"))')
check("insert landed in SQL",
      conn.execute("select name from users where id = 4").fetchone()[0], "Dee")
m.run('!(remove-atom &crm (users 4 "Dee"))')
check("delete landed in SQL",
      conn.execute("select count(*) from users where id = 4").fetchone()[0], 0)

Dates cross as ISO text and SQL NULL crosses as the NULL symbol. Clearing removes rows while preserving the schema, and unregistering removes the space binding:

python
# NULL and dates: the NULL symbol both ways, ISO text for scalar types,
# a NULL binding finding exactly the NULL row, clear() keeping the schema.
conn.execute("create table logs (day DATE, note TEXT)")
conn.execute("insert into logs values (DATE '2026-08-13', 'shipped'), (NULL, 'undated')")
rows = m.run("!(collapse (match &crm (logs $d $n) ($d $n)))")
listed = {str(pair) for pair in rows[0][0]}
check("a date crosses as its ISO text", '("2026-08-13" "shipped")' in listed, True)
check("NULL crosses as the NULL symbol", '(NULL "undated")' in listed, True)
check("a NULL binding finds the NULL row",
      m.run("!(match &crm (logs NULL $n) $n)"), [["undated"]])
check("a date binding finds the dated row",
      m.run('!(match &crm (logs "2026-08-13" $n) $n)'), [["shipped"]])
provider.clear()
check("clear empties, schema stays",
      m.run("!(collapse (match &crm (logs $d $n) x))"), [[Expression()]])

metta.space("&crm").drop()
done("duckdb_space")

Continue with Python-backed spaces for normal provider use and joins, and SpaceProvider for the interface another backend implements.

Released under the MIT License.