Reading code instead of running it
A query provider reads the typed tree of an Expression<Value> instead of running it, translates what it recognizes, and fails at its own runtime for a call it does not - the language cannot know in advance what a library can translate.
An Expression<Value> hands a function two things: the ordinary value, and the typed tree of what was written (see
Quoted expressions). A query provider is code that reads the second one
instead of the first, to turn a condition into something that is not TorbScript at all - a SQL WHERE clause, a
database index lookup, a validation rule stored as data.
Example#
type Unsupported with Show, Error {
description: String
fn show(): String {
"cannot translate: {description}"
}
}
fn translate(condition: Expression<Bool>): Result<String, Unsupported> {
match condition.tree {
.Binary(.Greater, left, right, _) => "{nameOfNode(left)} > {nameOfNode(right)}"
_ => Fail Unsupported(condition.source)
}
}
fn nameOfNode(node: ExpressionNode): String {
match node {
.Captured(_, name, _) => name
.Parameter(_, name, _) => name
_ => "?"
}
}
const limit = 5
print translate(limit > 3)
print translate(limit == 3)Syntax#
fn <name>(condition: Expression<Bool>): Result<Translated, Unsupported> {
match condition.tree {
.Binary(operator, left, right, _) => ... // what the provider can translate
_ => Fail Unsupported(condition.source) // everything else, at the provider's own runtime
}
}Rules#
A provider matches on
condition.tree, an ordinaryExpressionNode. Every node already carries the type of the expression it stands for, because quoting happens after name resolution and type checking - there is nothing left to resolve inside the tree itself.What a provider does not recognize is its own failure, at its own runtime. The language cannot know ahead of time what a library can translate, so an unmatched shape is a
Fail, not a compile error - the checker only guarantees thatconditionwas a validBoolexpression to begin with.A provider's
matchends with a catch-all arm, on purpose.ExpressionNodeis an ordinary ADT that gains cases as the language grows; a provider whose last arm is_ => Fail(Unsupported(...))keeps compiling against a newer language instead of becoming non-exhaustive.The tree cannot be executed, only the value can. A provider that gives up on translating a shape can still fall back to
condition.value()and run the original code in memory - the tree does not have to be the only way to get an answer.A provider gets exactly the one tree of the one expression, and nothing else. There is no way from a
TypeReferenceon a node back to the type it names, so a provider cannot look up a related field, a supertype or anything that was not written at the call site.
What this is not#
Reading a tree is not compiling it. A provider inspects data; it never turns ExpressionNode back into
something callable, because there is no such step in the language.
fn translate(condition: Expression<Bool>): String {
condition.tree.capturedNodes().map { node =>
match node {
.Captured(_, name, _) => name
_ => "?"
}
}.joined(separator: ", ")
}
const limit = 5
print translate(limit > 3)fn wrong(condition: Expression<Bool>): Bool {
condition.tree()
}
// error: `tree` is a field, and parentheses call a functionRelated#
- Quoted expressions - what
Expression<Value>hands over, and its restrictions. - There is no reflection - why
TypeReferencecannot be turned back into a type. - Why there are no macros - the other half of "extended by functions, not by macros".