Unified Typed Query Contract¶
Status: implemented normative contract for #170 through #177.
Additive typed-query imports
The immutable facade is available from type_bridge.typed and
@type-bridge/node/typed. Existing package-root query APIs remain available
and unchanged; importing the new subpath is an explicit migration choice.
This document fixes the public behavior shared by the Rust request, Python and TypeScript facades, and TypeDB executor. When implementation details conflict with this document, this contract wins unless a later accepted design explicitly revises it.
Public Surface and Compatibility Boundary¶
There is one new immutable public Query, whether it selects one model or many.
There are no separate public model-query, match-query, or fetch-query classes.
| Language | Typed-query import | Required connection |
|---|---|---|
| Python | from type_bridge.typed import Query, QuerySession |
Database | TransactionContext |
| TypeScript | import { Query, QuerySession } from "@type-bridge/node/typed" |
RustDatabase | RustTransactionContext |
Python's package-root Query and QueryBuilder remain the mutable raw-TypeQL
builders. TypeScript's package-root TypedQuery<T, Row> remains the current
mutable two-parameter manager query. Python manager queries remain mutable too.
Their imports, ignored-return mutation, generic arity, filters, ordering,
terminals, aggregates, updates, deletes, and hydration do not migrate as part of
170.¶
QuerySession.var(Model) returns one BoundVar[Model]. Two calls for the same
model have the same static type and different opaque runtime identities. New
bindings match their declared type exactly by default. Subtype-inclusive
matching must be requested explicitly on that binding.
QuerySession.query(selection, ...) requires between 1 and 16 selections. The
arguments declare the positional output in construction order. Selecting the
same binding handle twice is invalid; selecting two different handles for the
same model is valid. Query.match(binding, ...) adds hidden predicate witnesses
without changing the selected result type.
Every builder call returns a new query handle. Its base and sibling handles stay unchanged. Python and TypeScript carry only language typing plus opaque Rust handles; they do not hold a mirrored semantic plan or raw TypeQL.
Complete Python Example¶
from dataclasses import dataclass
from typing import assert_type
from type_bridge import (
Database,
Entity,
Flag,
Integer,
Key,
Relation,
Role,
String,
TypeFlags,
)
from type_bridge.typed import BoundRole, Page, Query, QuerySession, RoleRef
class Name(String):
pass
class Age(Integer):
pass
class Industry(String):
pass
class Position(String):
pass
class Person(Entity):
flags = TypeFlags(name="person")
name: Name = Flag(Key)
age: Age
class Company(Entity):
flags = TypeFlags(name="company")
name: Name = Flag(Key)
industry: Industry
class Employment(Relation):
flags = TypeFlags(name="employment")
employee: Role[Person] = Role("employee", Person)
employer: Role[Company] = Role("employer", Company)
position: Position
# Attribute classes are owner-derived field tokens. The bound variable supplies
# the model owner; no descriptor cast or public string is part of the API.
person_name = Name
person_age = Age
company_name = Name
company_industry = Industry
db = Database(address="localhost:1729", database="typed_query_example")
db.connect()
session = QuerySession(db)
person = session.var(Person)
employment = session.var(Employment)
company = session.var(Company)
assert_type(Employment.employee, RoleRef[Person, Employment])
assert_type(Employment.employer, RoleRef[Company, Employment])
assert_type(employment.role(Employment.employee), BoundRole[Person])
The scalar, tuple, five-slot, and repeated-model forms retain their exact static shapes. The five-slot graph below is connected through one company. The repeated two-person output uses hidden bindings to connect its selected variables.
one_person: Query[Person] = session.query(person)
assert_type(one_person.one(), Person)
assert_type(one_person.rows(limit=20), list[Person])
two_slots: Query[Person, Company] = (
session.query(person, company)
.match(employment)
.where(
employment.role(Employment.employee).is_(person),
employment.role(Employment.employer).is_(company),
)
)
assert_type(two_slots.one(), tuple[Person, Company])
assert_type(two_slots.rows(limit=20), list[tuple[Person, Company]])
colleague = session.var(Person)
other_employment = session.var(Employment)
five_slots: Query[Person, Employment, Company, Employment, Person] = session.query(
person,
employment,
company,
other_employment,
colleague,
).where(
employment.role(Employment.employee).is_(person),
employment.role(Employment.employer).is_(company),
other_employment.role(Employment.employee).is_(colleague),
other_employment.role(Employment.employer).is_(company),
)
assert_type(
five_slots.rows(limit=20),
list[tuple[Person, Employment, Company, Employment, Person]],
)
person_pair: Query[Person, Person] = (
session.query(person, colleague)
.match(
employment,
other_employment,
company,
)
.where(
employment.role(Employment.employee).is_(person),
employment.role(Employment.employer).is_(company),
other_employment.role(Employment.employee).is_(colleague),
other_employment.role(Employment.employer).is_(company),
)
)
assert_type(person_pair.rows(limit=20), list[tuple[Person, Person]])
# QuerySession.query has checked overloads through 16 selections. A seventeenth
# selection is a static diagnostic and Rust rejects forged unchecked requests.
Fields and roles are bound through their owning variables. Python string
helpers take the corresponding typed string attribute while TypeScript helpers
take a string; only regex interprets that value as a regex. A disconnected
graph is invalid unless topology-level cross-join permission names its
components.
adults_in_ai = two_slots.where(
person.field(person_age).gte(Age(18)),
company.field(company_industry).eq(Industry("AI")),
person.field(person_name).starts_with(Name("Al")),
company.field(company_name).contains(Name("Research")),
company.field(company_name).ends_with(Name("Labs")),
person.field(person_name).regex(Name(r"^A[[:alpha:]]+$")),
)
# Percent is literal input here; it has no SQL wildcard meaning.
literal_percent = one_person.where(person.field(person_name).contains(Name("50%")))
# Cross joins are explicit topology permission, never a boolean predicate.
independent_pairs: Query[Person, Company] = session.query(person, company).allow_cross_join(
person, company
)
Operation order is explicit at the terminal. rows returns distinct selected
identity tuples. page_by, count_by, and exists_by use distinct root
identity. Counts and existence do not inherit row/page order or windows.
ordered_people = one_person.rows(
limit=50,
offset=0,
order_by=(person.field(person_name).asc(),),
)
person_count: int = two_slots.count_by(person)
any_person: bool = two_slots.exists_by(person)
@dataclass(frozen=True, slots=True)
class PersonWork:
person: Person
employments: tuple[Employment, ...]
companies: tuple[Company, ...]
work: Query[PersonWork] = session.query_as(
PersonWork,
person=person,
employments=employment.collect(),
companies=company.collect().distinct(),
).where(
employment.role(Employment.employee).is_(person),
employment.role(Employment.employer).is_(company),
)
page: Page[PersonWork] = work.page_by(
person,
limit=50,
offset=0,
order_by=(person.field(person_name).asc(),),
include_total=True,
)
assert_type(page.items, tuple[PersonWork, ...])
assert_type(page.total, int | None)
A database-backed terminal owns its read transaction. A caller-owned read context remains usable after a terminal; the query neither commits, rolls back, nor closes it.
# Owned: rows() opens one read transaction and closes it on success or error.
owned = QuerySession(db)
owned_person = owned.var(Person)
owned_rows = owned.query(owned_person).rows(limit=10)
# Borrowed: the surrounding context owns lifecycle and may run more work.
with db.transaction("read") as tx:
borrowed = QuerySession(tx)
borrowed_person = borrowed.var(Person)
first_page = borrowed.query(borrowed_person).rows(limit=10)
second_page = borrowed.query(borrowed_person).rows(limit=10, offset=10)
Complete TypeScript Example¶
import {
Entity,
Key,
Relation,
RustDatabase,
attr,
field,
role,
type RustTransactionContext,
} from "@type-bridge/node";
import {
QuerySession,
references,
type Page,
type Query,
} from "@type-bridge/node/typed";
class Name extends attr.String("name") {}
class Age extends attr.Integer("age") {}
class Industry extends attr.String("industry") {}
class Position extends attr.String("position") {}
class Person extends Entity("person", {
name: field(Name, Key),
age: field(Age),
}) {}
class Company extends Entity("company", {
name: field(Name, Key),
industry: field(Industry),
}) {}
class Employment extends Relation("employment", {
employee: role(Person),
employer: role(Company),
position: field(Position),
}) {}
const db = RustDatabase.connect("localhost:1729", "typed_query_example");
const session = new QuerySession(db);
const person = session.var(Person);
const employment = session.var(Employment);
const company = session.var(Company);
const personRefs = references(Person);
const companyRefs = references(Company);
const employmentRefs = references(Employment);
TypeScript uses one readonly tuple parameter because it has no variadic generic
parameter list. QueryRow<Slots> scalarizes a one-slot tuple and preserves a
readonly tuple for two or more slots.
const onePerson: Query<readonly [Person]> = session.query(person);
const scalar: Person = onePerson.one();
const scalarRows: readonly Person[] = onePerson.rows({ limit: 20 });
const twoSlots: Query<readonly [Person, Company]> = session
.query(person, company)
.match(employment)
.where(
employment.role(employmentRefs.roles.employee).is(person),
employment.role(employmentRefs.roles.employer).is(company),
);
const pairRows: readonly (readonly [Person, Company])[] = twoSlots.rows({
limit: 20,
});
const colleague = session.var(Person);
const otherEmployment = session.var(Employment);
const fiveSlots: Query<
readonly [Person, Employment, Company, Employment, Person]
> = session
.query(person, employment, company, otherEmployment, colleague)
.where(
employment.role(employmentRefs.roles.employee).is(person),
employment.role(employmentRefs.roles.employer).is(company),
otherEmployment.role(employmentRefs.roles.employee).is(colleague),
otherEmployment.role(employmentRefs.roles.employer).is(company),
);
const personPair: Query<readonly [Person, Person]> = session
.query(person, colleague)
.match(employment, otherEmployment, company)
.where(
employment.role(employmentRefs.roles.employee).is(person),
employment.role(employmentRefs.roles.employer).is(company),
otherEmployment.role(employmentRefs.roles.employee).is(colleague),
otherEmployment.role(employmentRefs.roles.employer).is(company),
);
void scalar;
void scalarRows;
void pairRows;
void fiveSlots;
void personPair;
// query() accepts 1..16 selections. Seventeen is a tsc and Rust diagnostic.
const adultsInAi = twoSlots.where(
person.field(personRefs.fields.age).gte(new Age(18n)),
company.field(companyRefs.fields.industry).eq(new Industry("AI")),
person.field(personRefs.fields.name).startsWith("Al"),
company.field(companyRefs.fields.name).contains("Research"),
company.field(companyRefs.fields.name).endsWith("Labs"),
person.field(personRefs.fields.name).regex(String.raw`^A[[:alpha:]]+$`),
);
const literalPercent = onePerson.where(
person.field(personRefs.fields.name).contains("50%"),
);
const independentPairs: Query<readonly [Person, Company]> = session
.query(person, company)
.allowCrossJoin(person, company);
void adultsInAi;
void literalPercent;
void independentPairs;
const orderedPeople: readonly Person[] = onePerson.rows({
limit: 50,
offset: 0,
orderBy: [person.field(personRefs.fields.name).asc()],
});
const personCount: bigint = twoSlots.countBy(person);
const anyPerson: boolean = twoSlots.existsBy(person);
const work: Query<readonly [Readonly<{
person: Person;
employments: readonly Employment[];
companies: readonly Company[];
}>]> = session.queryNamed({
person,
employments: employment.collect(),
companies: company.collect().distinct(),
}).where(
employment.role(employmentRefs.roles.employee).is(person),
employment.role(employmentRefs.roles.employer).is(company),
);
const page: Page<Readonly<{
person: Person;
employments: readonly Employment[];
companies: readonly Company[];
}>> = work.pageBy(person, {
limit: 50,
offset: 0,
orderBy: [person.field(personRefs.fields.name).asc()],
includeTotal: true,
});
void orderedPeople;
void personCount;
void anyPerson;
void page;
// Owned: rows() opens and closes one read transaction on every exit path.
const ownedSession = new QuerySession(db);
const ownedPerson = ownedSession.var(Person);
const ownedRows = ownedSession.query(ownedPerson).rows({ limit: 10 });
// Borrowed: only the caller closes the context, which remains reusable.
const tx: RustTransactionContext = db.transaction("read");
try {
const borrowed = new QuerySession(tx);
const borrowedPerson = borrowed.var(Person);
const firstPage = borrowed.query(borrowedPerson).rows({ limit: 10 });
const secondPage = borrowed.query(borrowedPerson).rows({
limit: 10,
offset: 10,
});
void firstPage;
void secondPage;
} finally {
tx.close();
}
void ownedRows;
Row Shapes and Terminal Semantics¶
For selected arity N:
RowOf[Query[T]] = T
RowOf[Query[A, B, ...]] = tuple[A, B, ...] # Python
RowOf[Query<[A, B, ...]>] = readonly [A, B, ...] # TypeScript
one()requires exactly one distinct selected concept-identity tuple. Zero tuples raises cardinality codeno_result; two or more raises cardinality codenot_unique.rows(limit, offset=0)requires a positive limit and non-negative safe offset. It returns one item per distinct selected identity tuple.page_by(root, ...)pages distinct root concept identities and returns an immutablePage[RowOf].count_by(root)andexists_by(root)use distinct matching root identities. They do not inherit fetch/page ordering, offset, or limit.- One terminal call is one logical executor invocation. It may issue a bounded number of statements, all in the same read transaction.
The Python page contract is a frozen envelope with tuple items, offset,
limit, and total: int | None. The TypeScript page is readonly and uses
total: bigint | undefined. include_total/includeTotal defaults to false,
so total is absent unless requested.
Identity, Multiplicity, and Hydration¶
These identities are deliberately different:
| Concept | Used for | Rule |
|---|---|---|
| Matching solution | Collection multiplicity | One complete positive assignment; satisfying overlapping OR branches does not clone it |
| Selected identity tuple | one and rows |
Ordered identities of selected bindings; hidden witnesses do not duplicate it |
| Root identity | page/count/exists | The selected root concept, distinct before offset/limit |
| Collection concept identity | CollectDistinct |
TypeDB concept identity, never model/value equality |
x.collect() preserves matching-solution multiplicity. If two different hidden
witnesses bind the same collected concept, it appears twice. x.collect().distinct()
deduplicates that output only by concept identity. Bindings never group,
deduplicate, or reconstruct collections.
Complete hydration covers declared selected concepts only, including the role data required for selected relations. It does not recursively hydrate every adjacent graph concept. Rust validates provider evidence before a binding may construct a model or result container.
The normative identity example is
tests/contracts/typed_query/expected-results-v1.json. It includes duplicate
raw solutions, a selected-tuple result, distinct root results, multiplicity-
preserving collection output, and identity-distinct collection output. The ORM
recording-backend test loads those exact four solutions and verifies every
expected value through the production selected-result executor; it does not
reimplement the identity rules in a fixture interpreter.
Graph Topology and Boolean Binding¶
- All selected and hidden positive bindings form one connected typed graph.
Disconnected positive components fail unless
allow_cross_joinexplicitly names the component connection. - Every predicate binding is selected or attached through
match(...). - Cycles are valid.
- Variables exported from
ORare definitely bound in every branch. One complete assignment satisfying overlapping branches is still one solution. NOTmay reference only bindings already positively bound outside it.- Branch-local exports, correlated subqueries, recursive paths, and unconstrained cross joins are outside the initial contract.
- Cross-owner fields, invalid field-to-field comparisons, non-relation role sources, incompatible players, foreign-session handles, and unattached bindings fail before executor invocation.
Output Shapes and Collections¶
Positional output preserves construction order. Named output is declared, not
an arbitrary dictionary projection. Python accepts a supported frozen dataclass
or NamedTuple whose field names and annotations exactly match its selections.
TypeScript infers a readonly object shape.
BoundVar[T] is a singular selection. Collected[T] selects a Python
tuple[T, ...] or readonly TypeScript array. An output containing a collection
is initially executable only with page_by(root). The root is the only singular
page slot; every non-root output is collected. Collection output in one() or
rows() is invalid before data execution.
Ordering and Bounds¶
Concept identity determines equality but is not assumed sortable.
- Every incomplete
rowsresult requires total stable order across the selected tuple. - Every incomplete root page requires total stable order on the root.
- Every collection requires deterministic binding-local order.
- The canonical validator extends public order with present, schema-declared unique scalar keys on the applicable binding. Missing, nullable, non-unique, or multi-valued keys fail closed.
- Public
rowsorder may reference selected singular bindings only. Publicpage_byorder may reference the root only. - Sorting through multi-valued fields or roles requires an explicit reduction and is outside the initial scope.
limitis a positive safe integer.offsetis a non-negative safe integer. Oversize, overflow, NaN, infinity, fractional TypeScript numbers, or negative values fail before executor invocation.
String Operators¶
contains, starts_with/startsWith, and ends_with/endsWith accept literal
text. Rust owns escaping. A percent sign is an ordinary percent sign, not a SQL
wildcard. regex is the only regex operator on the new facade. The new facade
does not expose like; legacy like behavior remains unchanged.
TypeDB Parameter Transport¶
Predicate values remain typed Rust values through request validation and
compiler lowering. On a negotiated band-9 connection, the compiler emits
deterministic $g0, $g1, ... variables and the executor supplies one
given row through the driver's bounded streaming path. Values never cross a
Python or TypeScript raw-query boundary.
Pre-3.12 provider bands compile the same validated values through Rust's inline
literal renderer. Temporal, decimal, and duration operands also remain inline
on band 9 because the current portable row adapter cannot preserve their full
canonical TypeQL spelling surface. TypeDB requires the right operand of like
to be a string literal, so prefix, suffix, and regex predicates remain inline;
Rust still owns their escaping and validation. contains can use given,
retains TypeDB's Unicode case-folded substring semantics, and regex remains
the only raw regular expression.
The route is an internal provider choice and does not change the request shape,
statement ceiling, transaction snapshot, or public API.
Transaction Ownership and Resources¶
With a database connection, a terminal opens one read transaction and closes it
on success, validation failure after opening, provider error, decode error,
timeout, and cancellation. With a caller-owned read TransactionContext, the
terminal neither commits, rolls back, closes, nor consumes it. Every internal
statement in the logical invocation uses that same transaction and snapshot.
Provider capability and schema-fingerprint checks happen before a data statement. Provider/session hard ceilings cover processed rows, collected concepts, response bytes, statement count, and transaction duration. A caller may tighten but never raise those ceilings. Limits are enforced while processing, never after unbounded materialization. Any violation fails the whole operation; no partial row or page is exposed.
Stable Error Categories¶
| Category | Meaning | Executor invoked? | Data statements? |
|---|---|---|---|
invalid_plan |
Ownership, lineage, topology, shape, order, bound, or borrowed target is invalid | No; borrowed-target preflight has entered the executor | No |
cardinality (no_result) |
one() observed zero distinct selected tuples |
Yes | Yes |
cardinality (not_unique) |
one() observed more than one distinct selected tuple |
Yes | Yes, bounded to distinguish cardinality |
unsupported_capability |
Provider lacks a canonically required feature | Yes | No |
stale_schema |
Descriptor/schema fingerprint changed before lowering | Yes | No |
resource_limit |
A processing, collection, byte, statement, or duration ceiling was crossed | Yes | Possibly; never partial output |
provider |
TypeDB/provider execution failed | Yes | Possibly; never partial output |
result_decode |
Provider evidence/result did not match the validated request and shape | Yes | Possibly; never partial output |
No error becomes None, null, an empty result, a partial page, or a raw
provider exception. Rust error categories survive Python and TypeScript
boundaries.
Stable codes are case-specific and are not aliases for broad prose labels. For
example, disconnected topology is disconnected_plan, partial OR export is
partial_or_binding, missing provider support is
missing_provider_capability with the feature in structured details, and a
wrong result shape is result_shape_mismatch. Cross-owner fields, cross-owner
roles, and incompatible role players remain three separate diagnostics. The
versioned corpus pins the complete code table, including the distinct public-
boundary and canonical-request window failures. Absence of like is a static
API fact, not a fabricated runtime error.
Legacy Compatibility Baseline¶
The following current-major behavior remains fixed while the new subpaths are added:
- Python package-root raw-TypeQL
Query/QueryBuilderimports, in-place mutation, and build output; - Python manager-query ignored-return mutation for
filter,limit, andoffset, plusall,first,execute,count,exists, aggregate, group-by, update, and delete; - dictionary/Django-style filters, string ordering, polymorphic managers, and
#117 cross-type
hasbehavior; - Pydantic class-level descriptors and instance model values;
- TypeScript package-root
TypedQuery<T, Row>export, two-parameter generic, mutation, terminals, aggregate, and group-by; - root package model, field, role, and attribute declarations.
No new path may fall back to raw TypeQL, strings, dictionaries, Any,
unknown, client-side filtering/grouping/deduplication, or binding-side
hydration.
Fixture and Implementation Handoff¶
The versioned language-neutral corpus lives at
tests/contracts/typed_query/corpus-v1.json; its schema vocabulary is
schema-v1.json, and identity outcomes are in expected-results-v1.json.
Every case references paired Python and TypeScript example IDs from this
document. #172 consumed the plan/error/capability vocabulary; #174 activated
the public facade checks; #176 and #177 prove the expected results against
recording and live providers. All ten marked examples are concatenated into
checked Pyright and tsc inputs, so marker presence alone is not accepted.
The exact logical IDs, duplicate hidden-witness solution, selected rows, and
collection ID sequences in expected-results-v1.json are recording-backed.
The separate live TypeDB dataset intentionally has different employment and
company IDs. Its source Python, extracted-wheel Python, and packed-Node readers
execute an exact one-root page plus the larger collection page, count, and
exists, then compare a normalized projection derived from the normative
manifest: distinct logical roots, the one-root page envelope, collection and
collection-distinct cardinalities, count, and existence. This is live-equivalent
semantic evidence; it is not a claim that the live dataset contains every exact
manifest identity.
The compiler fixtures import the real public typed subpaths, while the legacy artifact baselines remain permanent.