EQL for SQL Experts: A Practical Guide#

If you are fluent in SQL, this guide will help you become productive with the Entity Query Language (EQL) quickly. EQL provides a relational, pythonic way to express queries and rule-based reasoning directly in Python, while minimizing boilerplate. A key difference from SQL is that joins are implicit: relationships are expressed as conditions over Python objects, and EQL finds matching assignments for you.

This guide maps familiar SQL ideas (SELECT, WHERE, JOIN, EXISTS/IN, boolean logic, uniqueness) to EQL constructs.

TL;DR: SQL → EQL mental model#

  • FROM aliasing → let(name, type_, domain): declare placeholders (think table aliases) and their data source.

  • SELECT one column/entity → entity(selected_var, …conditions): choose which variable to return.

  • SELECT multiple columns → set_of((var1, var2, …), …conditions): return a tuple of variables.

  • WHERE predicates → and_(…), or_(…), not_(…), and Python methods (e.g., startswith), plus contains/in_.

  • JOIN (INNER) → express relationships with equality between placeholders/attributes; EQL joins implicitly.

  • IN / EXISTS → in_(x, collection) or contains(collection, x); presence is implicit by using a placeholder.

  • Enforce exactly one row → the(…).evaluate(): raises if multiple matches; an(…) allows many.

References to examples in this documentation set:

  • Example with the: uniqueness, like asserting exactly one result.

  • Example with And + Or: complex boolean logic.

  • Example with Not: negation.

  • Example with Joining Multiple Sources: implicit joins via relationships.

  • Example with Inference: derive structured objects from matched patterns.

Minimal example: SELECT … FROM … WHERE …#

SQL

SELECT body.*
FROM bodies AS body
WHERE body.name = 'Body2';

EQL

from entity_query_language import entity, an, let
from dataclasses import dataclass
from typing_extensions import List

@dataclass(unsafe_hash=True)
class Body:
    name: str

@dataclass(eq=False)
class World:
    id_: int
    bodies: List[Body]

world = World(1, [Body("Body1"), Body("Body2")])

# FROM bodies AS body
body = let(name="body", type_=Body, domain=world.bodies)

# SELECT body WHERE body.name == "Body2"
results_generator = an(entity(body, body.name == "Body2")).evaluate()
results = list(results_generator)
assert results[0].name == "Body2"

Notes

  • let declares a placeholder (variable) and its domain (data source) — analogous to FROM with an alias.

  • entity selects which variable (or tuple of variables) to return.

  • an(…) returns all matches; evaluate() yields a generator of results.

LIKE and string predicates#

SQL

SELECT body.*
FROM bodies AS body
WHERE body.name LIKE 'Body%'
  AND body.name LIKE '%2';

EQL

from entity_query_language import entity, an, let, and_
from dataclasses import dataclass
from typing_extensions import List


@dataclass(unsafe_hash=True)
class Body:
    name: str


@dataclass(eq=False)
class World:
    id_: int
    bodies: List[Body]


world = World(1, [Body("Body1"), Body("Body2")])

body = let("body", Body, domain=world.bodies)
results_generator = an(entity(body,
                              and_(body.name.startswith("Body"),
                                   body.name.endswith("2"))
                              )).evaluate()
results = list(results_generator)

Tip: You can also use contains(x, y) or in_(x, collection) for containment tests.

IN and EXISTS#

SQL (IN)

SELECT body.*
FROM bodies AS body
WHERE body.name IN ('Container1', 'Handle1');

EQL

from entity_query_language import entity, an, let, in_, contains
from dataclasses import dataclass
from typing_extensions import List

@dataclass(unsafe_hash=True)
class Body:
    name: str

@dataclass(eq=False)
class World:
    id_: int
    bodies: List[Body]

world = World(1, [Body("Container1"), Body("Container2"), Body("Handle1"), Body("Handle2")])

body = let("body", Body, domain=world.bodies)
names = {"Container1", "Handle1"}
in_results_generator = an(entity(body, in_(body.name, names))).evaluate()
contains_results_generator = an(entity(body, contains(names, body.name))).evaluate()

EXISTS in SQL is naturally expressed by just introducing a placeholder and relating it in conditions. If the relationships can be satisfied, it “exists.”

Implicit JOINs via relationships#

In SQL you join tables explicitly. In EQL you state the relationships between placeholders and attributes; the join is inferred.

SQL (conceptually)

SELECT parent_container.*, prismatic_connection.*, drawer_body.*, fixed_connection.*, handle.*
FROM bodies AS parent_container
JOIN prismatic AS prismatic_connection ON parent_container.id = prismatic_connection.parent_id
JOIN bodies AS drawer_body ON drawer_body.id = prismatic_connection.child_id
JOIN fixed AS fixed_connection ON drawer_body.id = fixed_connection.parent_id
JOIN bodies AS handle ON handle.id = fixed_connection.child_id;

EQL

from entity_query_language import entity, an, let, and_, set_of
from dataclasses import dataclass, field
from typing_extensions import List


@dataclass
class Body:
    name: str


@dataclass
class Connection:
    parent: Body
    child: Body


@dataclass
class Prismatic(Connection):
    ...


@dataclass
class Fixed(Connection):
    ...


@dataclass
class World:
    id_: int
    bodies: List[Body]
    connections: List[Connection] = field(default_factory=list)


# Construct a small world
world = World(1, [Body("Container1"), Body("Container2"), Body("Handle1"), Body("Handle2")])
c1_c2 = Prismatic(world.bodies[0], world.bodies[1])
c2_h2 = Fixed(world.bodies[1], world.bodies[3])
world.connections = [c1_c2, c2_h2]

# Declare placeholders (FROM with aliases)
parent_container = let("parent_container", Body, domain=world.bodies)
prismatic_connection = let("prismatic_connection", Prismatic, domain=world.connections)
drawer_body = let("drawer_body", Body, domain=world.bodies)
fixed_connection = let("fixed_connection", Fixed, domain=world.connections)
handle = let("handle", Body, domain=world.bodies)

# SELECT (parent_container, prismatic_connection, drawer_body, fixed_connection, handle) WHERE relationships hold
results_generator = an(set_of((parent_container, prismatic_connection, drawer_body, fixed_connection, handle),
                              and_(parent_container == prismatic_connection.parent,
                                   drawer_body == prismatic_connection.child,
                                   drawer_body == fixed_connection.parent,
                                   handle == fixed_connection.child)
                              )).evaluate()
results = list(results_generator)

Notice how equality constraints play the role of JOIN conditions. The set_of(…) returns a tuple-like result where you can access each variable by its identity if needed.

Selecting multiple outputs (projection of multiple columns)#

  • Use entity(x, …) to return one variable.

  • Use set_of((x, y, …), …) to return several variables together (akin to SELECT col1, col2, …).

Enforcing uniqueness (like expecting exactly one row)#

  • an(…) returns all matching assignments.

  • the(…) asserts there is exactly one satisfying assignment; it raises if there are 0 or >1.

from entity_query_language import entity, the, let
from entity_query_language.failures import MultipleSolutionFound
from dataclasses import dataclass
from typing_extensions import List

@dataclass(unsafe_hash=True)
class Body:
    name: str

@dataclass(eq=False)
class World:
    id_: int
    bodies: List[Body]

# Two bodies with the same name to demonstrate uniqueness enforcement
world = World(1, [Body("Body1"), Body("Body1")])

body = let("body", Body, domain=world.bodies)
# Expect exactly one
try:
    only_body = the(entity(body, body.name == "Body1")).evaluate()
    assert False  # should not reach here due to MultipleSolutionFound
except MultipleSolutionFound:
    pass

This is analogous to a query where you expect a single-row result (e.g., by primary key). Unlike LIMIT 1, the(...) enforces uniqueness by raising on multiple solutions.

Rule-based inference (beyond SQL)#

EQL can construct new objects from matched patterns using symbolic_mode and @symbol. Conceptually, this is more like rule-based reasoning than SQL aggregation. See the Inference example for full context.

from entity_query_language import entity, an, let, and_, symbolic_mode, symbol
from dataclasses import dataclass, field
from typing_extensions import List


@dataclass
class Body:
    name: str


@dataclass
class Connection:
    parent: Body
    child: Body


@dataclass
class Prismatic(Connection):
    ...


@dataclass
class Fixed(Connection):
    ...


@dataclass
class World:
    id_: int
    bodies: List[Body]
    connections: List[Connection] = field(default_factory=list)


# Build a small world
world = World(1, [Body("Container1"), Body("Container2"), Body("Handle1"), Body("Handle2")])
c1_c2 = Prismatic(world.bodies[0], world.bodies[1])
c2_h2 = Fixed(world.bodies[1], world.bodies[3])
world.connections = [c1_c2, c2_h2]

# Placeholders
parent_container = let("parent_container", Body, domain=world.bodies)
prismatic_connection = let("prismatic_connection", Prismatic, domain=world.connections)
drawer_body = let("drawer_body", Body, domain=world.bodies)
fixed_connection = let("fixed_connection", Fixed, domain=world.connections)
handle = let("handle", Body, domain=world.bodies)


@symbol
@dataclass
class Drawer:
    handle: Body
    body: Body


with symbolic_mode():
    results_generator = an(entity(Drawer(handle=handle, body=drawer_body),
                                  and_(parent_container == prismatic_connection.parent,
                                       drawer_body == prismatic_connection.child,
                                       drawer_body == fixed_connection.parent, handle == fixed_connection.child)
                                  )).evaluate()
results = list(results_generator)
assert results and results[0].body.name == "Container2" and results[0].handle.name == "Handle2"

Tips for SQL users#

  • Think: placeholders (let) are your table aliases, and their domain is your FROM source (Python collections).

  • Build predicates using and_/or_/not_, equality between attributes, and common Python string/collection methods.

  • Default “join” is whatever satisfies your equality constraints — you don’t spell JOIN explicitly.

  • Materialize results with list(…). The evaluator yields a generator so you can stream or iterate.

  • For returning multiple columns, use set_of with a tuple of placeholders.

  • For exact-one expectations, use the(…); otherwise use an(…).

For more, see the example pages in this documentation:

  • Example with the

  • Example with And + Or

  • Example with Not

  • Example with Joining Multiple Sources

  • Example with Inference