Analyzing queries

TypeDB allows the user to "analyze" a query without having it execute against the data. During analysis, a query is parsed to the internal representation and type-checked - allowing the user to check their query for syntax and typing errors against a schema.

The envisioned use is to facilitate developer tooling around TypeDB, such as plugins to validate TypeQL queries when application code is being compiled, and debugging type errors. Such tools can also be used by AI query-generators to automatically validate generated queries.

This page gives an overview of how to analyze a query and use the response. The examples are valid python code using the TypeDB python driver. The class definitions are illustrative.

Analyzing a query

Analyzing a query follows the same pattern as running a query.

#!test[]
#{{
from typedb.driver import *

DB_NAME = "my_database"
address = "localhost:1729"
credentials = Credentials("admin", "password")
options = DriverOptions(is_tls_enabled=False, tls_root_ca_path=None)

driver = TypeDB.driver(address, credentials, options)
try:
    driver.databases.create(DB_NAME) # raises already exists exception
finally:
    pass

with driver.transaction(DB_NAME, TransactionType.SCHEMA) as tx:
    tx.query("""
        define
        attribute company-name, value string;
		attribute email, value string;
		attribute name, value string;
		attribute age, value integer;
        entity company, owns company-name, owns email, plays employment:employer;
		entity user, owns name, owns email, owns age, plays employment:employee;
        relation employment, relates employer, relates employee;
    """).resolve()
    tx.commit()

with driver.transaction(DB_NAME, TransactionType.WRITE) as tx:
    tx.query("insert $u isa user, has name 'alice', has email 'alice@example.com', has age 25;").resolve()
    tx.commit()

#}}
with driver.transaction(DB_NAME, TransactionType.READ) as tx:
    # 1. Send the analyze request
    promise = tx.analyze("""
        match { $x isa user; } or { $x isa company; };
        fetch { "email": [$x.email] };
    """)

    # 2. Resolve the promise if you want to access the result or receive an error as an exception
    analyzed = promise.resolve()

The response

The response is a type-annotated representation of the query. The following sections cover the essence, but details are left to the driver reference.

As one would expect, the response contains each part of a TypeQL query: A (possibly empty) set of preamble functions, the query-pipeline, and a fetch clause, if present.

class AnalyzedQuery:
    def pipeline(self) -> Pipeline
    def preamble(self) -> Iterator[Function]
    def fetch(self) -> Optional[Fetch]

Pipelines & conjunctions

TypeQL pipelines are made up of a sequence of stages, some of which may contain conjunctions.

class Pipeline:
    def stages(self) -> Iterator[PipelineStage]
    def conjunction(self, conjunction_id: ConjunctionID) -> Optional[Conjunction]
    # ...

The PipelineStage instances returned by the stages() method follows the familiar pattern of having an abstract base class which is a union of all the variants. it must be downcast to the appropriate variant using the is_<variant> and as_<variant> methods.

class PipelineStage(ABC):
    def is_match(self) -> bool
    def as_match(self) -> MatchStage
    # is_insert, as_insert, is_select, as_select, ...

class MatchStage(PipelineStage):
    def block(self) -> ConjunctionID

class SelectStage(PipelineStage):
    def variables(self) -> Iterator[Variable]

Stages such as Match and Insert hold a ConjunctionID. This is an indirection which can be used to retrieve the actual conjunction using the Pipeline.conjunction method.

#!test[]
#{{
from typedb.driver import *

DB_NAME = "my_database"
address = "localhost:1729"
credentials = Credentials("admin", "password")
options = DriverOptions(is_tls_enabled=False, tls_root_ca_path=None)

driver = TypeDB.driver(address, credentials, options)

with driver.transaction(DB_NAME, TransactionType.READ) as tx:
    # 1. Send the analyze request
    promise = tx.analyze("""
        match { $x isa user; } or { $x isa company; };
        fetch { "email": [$x.email] };
    """)

    # 2. Resolve the promise if you want to access the result or receive an error as an exception
    analyzed = promise.resolve()


#}}
pipeline = analyzed.pipeline()
stages = list(pipeline.stages())
block_id = stages[0].as_match().block()
root_conjunction = pipeline.conjunction(block_id)

From the returned conjunction one can access the constraints, as well as the types inferred for the variables in those constraints.

class Conjunction:
    def constraints(self) -> Iterator[Constraint]
    def annotated_variables(self) -> Iterator[Variable]
    def variable_annotations(self, variable: Variable) -> Optional[VariableAnnotations]

VariableAnnotations refer to the types the variable is annotated with by type-inference. These are not to be confused with schema-annotations

Constraints

Similar to stages, the Constraint instances returned by the constraints() method must be down-cast to the appropriate variant using the is/as methods. Sub-patterns such as or, not, and try are also constraints. These hold the ConjunctionID(s) of the nested conjunctions.

class Constraint(ABC):
    def is_isa(self) -> bool
    def as_isa(self) -> Isa
    # is_has, as_has, ...

    def is_or(self) -> bool
    def as_or(self) -> Or
    # is_not, as_not, is_try, as_try

class Isa(Constraint):
    # <instance> isa(!) <type>
    def instance(self) -> ConstraintVertex
    def type(self) -> ConstraintVertex
    def exactness(self) -> ConstraintExactness # isa or isa!

# Has, ...

class Or(Constraint):
    def branches(self) -> Iterator[ConjunctionID]
# Not, Try

To get the isa constraint from the first branch:

#!test[]
#{{
from typedb.driver import *

DB_NAME = "my_database"
address = "localhost:1729"
credentials = Credentials("admin", "password")
options = DriverOptions(is_tls_enabled=False, tls_root_ca_path=None)

driver = TypeDB.driver(address, credentials, options)

with driver.transaction(DB_NAME, TransactionType.READ) as tx:
    # 1. Send the analyze request
    promise = tx.analyze("""
        match { $x isa user; } or { $x isa company; };
        fetch { "email": [$x.email] };
    """)

    # 2. Resolve the promise if you want to access the result or receive an error as an exception
    analyzed = promise.resolve()

pipeline = analyzed.pipeline()
stages = list(pipeline.stages())
block_id = stages[0].as_match().block()
root_conjunction = pipeline.conjunction(block_id)


#}}
constraints = list(root_conjunction.constraints())
or_constraint = constraints[0]
assert or_constraint.is_or()
branches = [pipeline.conjunction(id) for id in or_constraint.as_or().branches()]
first_branch_constraints = list(branches[0].constraints())
first_branch_isa = first_branch_constraints[0]
assert first_branch_isa.is_isa()

Constraint vertices

Although constraints typically apply on variables, certain TypeQL constraints allow you to directly specify a type-label or a value. Additionally, a NamedRole vertex type exists to handle the ambiguity of unscoped role-labels. A ConstraintVertex is the union of these four.

The term vertex comes from viewing a query as a constraint graph.

A ConstraintVertex can be converted to the appropriate variant using the is/as methods.

  • A label vertex holds the resolved type.

  • A value vertex holds the value concept of the appropriate value-type.

  • A named-role vertex holds the internal variable and the unscoped name. The internal variable can be used to retrieve the resolved role-type(s) using the annotations in the conjunction.

  • A variable vertex holds a variable, which can be used in many places.

A variable is shared across constraints. The name of a variable (if it has one) can be read from the pipeline.

#!test[]
#{{
from typedb.driver import *

DB_NAME = "my_database"
address = "localhost:1729"
credentials = Credentials("admin", "password")
options = DriverOptions(is_tls_enabled=False, tls_root_ca_path=None)

driver = TypeDB.driver(address, credentials, options)

with driver.transaction(DB_NAME, TransactionType.READ) as tx:
    # 1. Send the analyze request
    promise = tx.analyze("""
        match { $x isa user; } or { $x isa company; };
        fetch { "email": [$x.email] };
    """)

    # 2. Resolve the promise if you want to access the result or receive an error as an exception
    analyzed = promise.resolve()

pipeline = analyzed.pipeline()
stages = list(pipeline.stages())
block_id = stages[0].as_match().block()
root_conjunction = pipeline.conjunction(block_id)

constraints = list(root_conjunction.constraints())
or_constraint = constraints[0]
assert or_constraint.is_or()
branches = [pipeline.conjunction(id) for id in or_constraint.as_or().branches()]
first_branch_constraints = list(branches[0].constraints())
first_branch_isa = first_branch_constraints[0]
assert first_branch_isa.is_isa()


#}}
var_x = first_branch_isa.instance()
assert var_x.is_variable() and pipeline.get_variable_name(var_x.as_variable()) == "x"

If the variable is an output of the pipeline, the name can be used to read answers from query responses. The possible types of a variable in a conjunction can be read from the annotations in the conjunction.

Variable and ConjunctionID are scoped to a pipeline. Trying to resolve either of these using a pipeline other than the one it originated from (e.g. a pipeline of a preamble function) is undefined behaviour.

Annotations

Type-checking is a central feature of TypeDB. Analyze returns the final set of inferred types for every variable in a conjunction.

#!test[]
#{{
from typedb.driver import *

DB_NAME = "my_database"
address = "localhost:1729"
credentials = Credentials("admin", "password")
options = DriverOptions(is_tls_enabled=False, tls_root_ca_path=None)

driver = TypeDB.driver(address, credentials, options)

with driver.transaction(DB_NAME, TransactionType.READ) as tx:
    # 1. Send the analyze request
    promise = tx.analyze("""
        match { $x isa user; } or { $x isa company; };
        fetch { "email": [$x.email] };
    """)

    # 2. Resolve the promise if you want to access the result or receive an error as an exception
    analyzed = promise.resolve()

pipeline = analyzed.pipeline()
stages = list(pipeline.stages())
block_id = stages[0].as_match().block()
root_conjunction = pipeline.conjunction(block_id)

constraints = list(root_conjunction.constraints())
or_constraint = constraints[0]
assert or_constraint.is_or()
branches = [pipeline.conjunction(id) for id in or_constraint.as_or().branches()]
first_branch_constraints = list(branches[0].constraints())
first_branch_isa = first_branch_constraints[0]
assert first_branch_isa.is_isa()


#}}
var_x = first_branch_isa.instance().as_variable()
assert var_x in list(root_conjunction.annotated_variables())

x_annotations_in_root = root_conjunction.variable_annotations(var_x)
assert x_annotations_in_root.is_instance()

x_types_in_root = list(x_annotations_in_root.as_instance())
labels = set(map(lambda t: t.get_label(), x_types_in_root))
assert labels == {"user", "company"}

We return annotations per conjunction because a variable may have different types in different conjunctions.

# user in the left branch, company in the right, Either of them at the root.
match { $p isa user; } or { $p isa company; };

or

match $p has email $email;  # $p is any type that owns email
match $p has name $name;    # The type of $p must also own name

Functions

A Function is a pipeline with a set of arguments, and returns.

class Function:
    def body(self) -> Pipeline

    def argument_variables(self) -> Iterator[Variable]
    def argument_annotations(self) -> Iterator[VariableAnnotations]

    def return_operation(self) -> ReturnOperation
    def return_annotations(self) -> Iterator[VariableAnnotations]

Fetch

An analyzed Fetch is one of a Dictionary, a List, or a collection of values.

class Fetch(ABC):
    # is/as methods

class FetchObject(Fetch):
    def keys(self) -> Iterator[str]
    def get(self, key: str) -> Fetch

class FetchList(Fetch):
    def element(self) -> Fetch

class FetchLeaf(Fetch):
    def annotations(self) -> Iterator[str]

The JSON-like structure of the analyzed Fetch reflects that of the Fetch stage itself, with leaves being annotated with the value types.

match $u isa user;
fetch {
    "email": [ $u.email ],
};

To inspect the value-type of the emails:

#!test[]
#{{
from typedb.driver import *

DB_NAME = "my_database"
address = "localhost:1729"
credentials = Credentials("admin", "password")
options = DriverOptions(is_tls_enabled=False, tls_root_ca_path=None)

driver = TypeDB.driver(address, credentials, options)

with driver.transaction(DB_NAME, TransactionType.READ) as tx:
    # 1. Send the analyze request
    promise = tx.analyze("""
        match { $x isa user; } or { $x isa company; };
        fetch { "email": [$x.email] };
    """)

    # 2. Resolve the promise if you want to access the result or receive an error as an exception
    analyzed = promise.resolve()


#}}
root_object = analyzed.fetch().as_object()
email_field = root_object.get("email")
email_list_element = email_field.as_list().element()
email_value_types = list(email_list_element.annotations())
assert email_value_types == ["string"]

References