Palm.SurrealDB.Net.SurrealQL
0.2.1
dotnet add package Palm.SurrealDB.Net.SurrealQL --version 0.2.1
NuGet\Install-Package Palm.SurrealDB.Net.SurrealQL -Version 0.2.1
<PackageReference Include="Palm.SurrealDB.Net.SurrealQL" Version="0.2.1" />
<PackageVersion Include="Palm.SurrealDB.Net.SurrealQL" Version="0.2.1" />
<PackageReference Include="Palm.SurrealDB.Net.SurrealQL" />
paket add Palm.SurrealDB.Net.SurrealQL --version 0.2.1
#r "nuget: Palm.SurrealDB.Net.SurrealQL, 0.2.1"
#:package Palm.SurrealDB.Net.SurrealQL@0.2.1
#addin nuget:?package=Palm.SurrealDB.Net.SurrealQL&version=0.2.1
#tool nuget:?package=Palm.SurrealDB.Net.SurrealQL&version=0.2.1
Palm.SurrealDB.Net.SurrealQL
The SurrealQL abstract syntax tree, emitter, parser, formatter, and statement builders for the
Palm.SurrealDB .NET ecosystem. This is the only sanctioned way to produce
SurrealQL query text anywhere in the ecosystem — never build query strings by concatenation.
Everything downstream (the LINQ provider, the F# DSL, the client's raw-query escape hatch) routes
through the AST defined here and emits via SurqlEmitter.
The project targets net10.0 only and depends on exactly one sibling package,
Palm.SurrealDB.Net.Abstractions (for SurrealValue,
RecordId, SurrealDuration, and related value types). It has no other dependencies, and nothing
in SurrealQL may reference the client or the transport layers — dependencies flow downward only.
Why this project exists
Hand-built SurrealQL strings are an injection vector and a maintenance trap: escaping rules for identifiers, record ids, durations, and geometry literals are easy to get wrong, and string templates silently drift from the server's actual grammar. This project makes parameterized, injection-safe query construction a first-class concern:
- Every AST node validates its own invariants at construction (non-empty targets, mutually exclusive clauses, valid identifier/parameter name shapes, etc.) — malformed queries fail fast, before any text is emitted.
SurqlEmitter.Emitbinds every literal to a generated$pNparameter, so query text sent to the server never embeds untrusted values directly.- The parser is round-trip verified against the emitter (see
tests/Palm.SurrealDB.Net.SurrealQL.Tests) so the AST and the grammar it claims to represent stay in sync — the emitter and parser precedence tables are required to agree.
Installation
dotnet add package Palm.SurrealDB.Net.SurrealQL
This project is packable and ships as the Palm.SurrealDB.Net.SurrealQL NuGet package. An earlier
version of this section said the opposite, citing a true observation — the .csproj sets neither
PackageId nor IsPackable — in support of a false conclusion: packability comes from the SDK
default and Directory.Build.props, and the only projects that opt
out are the three that set IsPackable=false. PackableSetGuardTests pins all 25 packable ids,
and this is one of them.
Targets net10.0 only. The package is proprietary and sets PackageRequireLicenseAcceptance,
so NuGet prompts before installing — see LICENSE.
Nothing is on NuGet.org until the first release runs (see
docs/RELEASING.md); until then, reference the project directly:
<ItemGroup>
<ProjectReference Include="path/to/src/Palm.SurrealDB.Net.SurrealQL/Palm.SurrealDB.Net.SurrealQL.csproj" />
</ItemGroup>
Building queries with the AST + builders
The typical path is: use Surql's static factory methods to build expressions, hand them to a
fluent statement builder (SelectBuilder, CreateBuilder, UpdateBuilder, DeleteBuilder,
InsertBuilder, RelateBuilder), then either call Build() to get the immutable
SelectStatement/CreateStatement/… AST node, or call ToSurql() directly on the builder to get
parameterized text.
using Palm.SurrealDB.Net.SurrealQL;
using static Palm.SurrealDB.Net.SurrealQL.Surql;
// SELECT name, age FROM person WHERE age >= 18 ORDER BY name LIMIT 10
SurqlText query = Select("name", "age")
.From(Table("person"))
.Where(Ge(Field("age"), Value(18)))
.OrderBy(new OrderByField(Field("name")))
.Limit(Value(10))
.ToSurql();
// query.Text == "SELECT name, age FROM person WHERE age >= $p0 ORDER BY name LIMIT $p1"
// query.Parameters == { ["p0"] = SurrealValue.From(18), ["p1"] = SurrealValue.From(10) }
await client.QueryAsync(query.Text, query.Parameters);
Every builder is an immutable record; every fluent method returns a new builder (this with { ... }), so builders can be safely reused as templates and branched:
var baseQuery = Surql.SelectAll().From(Table("person"));
var adults = baseQuery.Where(Ge(Field("age"), Value(18)));
var minors = baseQuery.Where(Lt(Field("age"), Value(18)));
Build() on any builder runs the same construction-time validation as calling the AST node's
constructor directly — invalid combinations (e.g. SELECT VALUE with more than one field, SPLIT
and GROUP together, a CREATE with a MERGE data clause) throw ArgumentException immediately
rather than producing malformed SurrealQL.
Surql static factory reference
Surql (in Palm.SurrealDB.Net.SurrealQL) is the single entry point for building expressions and
starting statement builders — used uniformly by this project, the LINQ provider, and the F# DSL.
| Category | Members |
|---|---|
| Expression construction | Field(params string[] path), Param(string name), Table(string name), Value(SurrealValue\|long\|double\|decimal\|string\|bool), Null, None, Record(RecordId), Record(string table, string key), Array(params SurqlExpression[]), Object(params (string Key, SurqlExpression Value)[]), Function(string name, params SurqlExpression[] args) |
| Operator helpers | Not, Negate, And, Or, Eq, ExactEq, Ne, Lt, Le, Gt, Ge, Add, Sub, Mul, Div, Contains, In |
| SET-operation helpers | Assign(IdiomExpression target, SurqlExpression value), Incr(...), Decr(...) |
| Statement builder entry points | SelectAll(), Select(params string[] fieldNames), Select(IEnumerable<SelectField>), SelectValue(SurqlExpression\|string), Create(params SurqlExpression[] targets), Update(params SurqlExpression[] targets), Upsert(params SurqlExpression[] targets), Delete(params SurqlExpression[] targets), Insert(string table, SurqlExpression values), Relate(SurqlExpression from, SurqlExpression edge, SurqlExpression to) |
The operator helper list above covers the common cases; for the remaining SurqlBinaryOperator /
SurqlUnaryOperator values (see The AST below) construct new BinaryExpression(...) /
new UnaryExpression(...) directly.
Statement builders
All six builders are sealed record types with internal constructors — obtain one only via
Surql's entry points. Every builder exposes Build() (returns the immutable statement AST node),
ToSurql() (build + SurqlEmitter.Emit, parameterized), and ToSurqlInline() (build +
SurqlEmitter.EmitInline — see the Emitting section for when this is and is not safe).
| Builder | Entry point | Notable fluent methods |
|---|---|---|
SelectBuilder |
Surql.SelectAll(), Surql.Select(...), Surql.SelectValue(...) |
From, Only, Omit, WithIndex, WithNoIndex, Where, SplitOn, GroupAll, GroupBy, OrderBy, OrderByRand, Limit, Start, Fetch, Timeout, Explain, ExplainFull |
CreateBuilder |
Surql.Create(targets) |
Only, Content, Set, Return, Timeout |
UpdateBuilder |
Surql.Update(targets), Surql.Upsert(targets) |
Only, Content, Merge, Patch, Replace, Set, Unset, Where, Return, Timeout |
DeleteBuilder |
Surql.Delete(targets) |
Only, Where, Return, Timeout |
InsertBuilder |
Surql.Insert(table, values) |
Relation, Ignore, OnDuplicateKeyUpdate, Return |
RelateBuilder |
Surql.Relate(from, edge, to) |
Only, Content, Set, Return, Timeout |
CreateBuilder/RelateBuilder restrict their Content/Set data clause to CONTENT/SET only
(matching the server grammar); UpdateBuilder accepts all six data-clause kinds. CreateStatement
and RelateStatement never emit PARALLEL; DeleteStatement and UpdateStatement never emit
PARALLEL either — that keyword is not currently modeled.
Longer example — building each statement kind:
using static Palm.SurrealDB.Net.SurrealQL.Surql;
// CREATE person CONTENT { name: $p0, age: $p1 } RETURN AFTER
Create(Table("person"))
.Content(Object(("name", Value("Ada")), ("age", Value(30))))
.Return(ReturnClause.After)
.ToSurql();
// UPDATE person SET age += $p0 WHERE name = $p1
Update(Table("person"))
.Set(Incr(Field("age"), Value(1)))
.Where(Eq(Field("name"), Value("Ada")))
.ToSurql();
// DELETE person WHERE age < $p0
Delete(Table("person")).Where(Lt(Field("age"), Value(18))).ToSurql();
// INSERT INTO person { name: $p0 } RETURN AFTER
Insert("person", Object(("name", Value("Grace"))))
.Return(ReturnClause.After)
.ToSurql();
// RELATE person:ada->knows->person:grace SET since = $p0
Relate(Record("person", "ada"), Table("knows"), Record("person", "grace"))
.Set(Assign(Field("since"), Value(2024)))
.ToSurql();
The AST
The AST lives in Palm.SurrealDB.Net.SurrealQL.Ast. Every node is an immutable record deriving
(directly or indirectly) from SurqlNode, the visitor root:
public abstract record SurqlNode
{
public abstract TResult Accept<TResult>(ISurqlVisitor<TResult> visitor);
}
public abstract record SurqlExpression : SurqlNode;
public abstract record SurqlStatement : SurqlNode;
SurqlNode constructors are private protected — only the types defined in this project can
derive new node kinds. Node families are dispatched through ISurqlVisitor<TResult>, which
SurqlEmitter implements internally; it is a public interface so other projects (the LINQ
provider's translator, future tooling) can also visit the tree. Note: the interface is
expected to grow additional Visit* members while the library is pre-1.0 — treat it as unstable
across minor versions.
Statements (SurqlStatement)
| Type | Emits | Key members |
|---|---|---|
SelectStatement |
SELECT ... |
Targets, Fields, SelectAll, IsValue, Omit, IsOnly, With, Where, Split, Group, OrderBy, Limit, Start, Fetch, Timeout, Explain |
CreateStatement |
CREATE ... |
Targets, IsOnly, Data (CONTENT/SET only), Return, Timeout |
UpdateStatement |
UPDATE/UPSERT ... (Kind: UpdateKind) |
Targets, IsOnly, Data (any of the six kinds), Where, Return, Timeout |
DeleteStatement |
DELETE ... |
Targets, IsOnly, Where, Return, Timeout |
InsertStatement |
INSERT [RELATION] [IGNORE] INTO table values [ON DUPLICATE KEY UPDATE ...] |
Table, Values, IsRelation, IsIgnore, OnDuplicateKeyUpdate, Return |
RelateStatement |
RELATE [ONLY] from->edge->to [CONTENT\|SET] [RETURN] [TIMEOUT] |
From, Edge, To, IsOnly, Data (CONTENT/SET only), Return, Timeout |
ReturnStatement |
RETURN expr |
Value |
LetStatement |
LET $name = expr |
Name, Value |
TransactionBlock |
BEGIN; stmt; ...; COMMIT |
Statements (non-empty) |
InsertStatement supports only the object / array-of-objects value form
(INSERT INTO table {...} or INSERT INTO table [{...}, {...}]); the columnar
(columns) VALUES (tuples) form is deferred to a later phase.
Expressions (SurqlExpression)
| Type | Emits | Key members |
|---|---|---|
LiteralExpression |
a bound $pN parameter (or inline literal) |
Value: SurrealValue |
ParameterExpression |
$name |
Name (validated [A-Za-z_][A-Za-z0-9_]*) |
UnaryExpression |
!x, -x, +x |
Operator: SurqlUnaryOperator, Operand |
BinaryExpression |
x OP y |
Operator: SurqlBinaryOperator, Left, Right |
CastExpression |
<type> expr |
TypeName (validated), Operand |
RangeExpression |
start..end, start>.., ..=end, etc. |
Start, IsStartExclusive, End, IsEndInclusive |
ArrayExpression |
[a, b, ...] |
Items: ImmutableArray<SurqlExpression> |
ObjectExpression |
{ k: v, ... } (write order preserved) |
Entries: ImmutableArray<ObjectEntry> |
FunctionCallExpression |
segment::segment(args) |
Name (validated), Arguments |
IdiomExpression |
field/index/graph path, e.g. address.city, ->knows->person |
Head (optional), Parts: ImmutableArray<IdiomPart> |
TableExpression |
a table name (never parameterized — syntax, not a value) | Name |
RecordIdRangeExpression |
table:start..end (bounds must be literals) |
Table, Range: RangeExpression |
RecordIdGenerateExpression |
table:rand() / table:ulid() / table:uuid() |
Table, Generator: RecordIdGenerator |
SubqueryExpression |
(SELECT ...) — a statement used as an expression |
Statement: SurqlStatement |
ObjectExpression intentionally preserves write order, unlike SurrealValue.FromObject's
unordered dictionary — object literals in query text are syntax, not a value type.
Idiom parts (IdiomPart, used inside IdiomExpression.Parts)
| Type | Emits | Notes |
|---|---|---|
FieldPart(string Name) |
.field (or bare, if leading) |
Escaped via SurqlIdent when not a bare identifier |
IndexPart(SurqlExpression Index) |
[expr] |
|
LastPart |
[$] |
Last array element |
AllPart |
[*] |
|
FilterPart(SurqlExpression Condition) |
[WHERE cond] |
|
OptionalPart |
.? |
Safe navigation — yields NONE if the preceding segment is absent |
DestructurePart(IEnumerable<string> fields) |
.{ f1, f2 } |
Requires at least one field |
GraphPart(GraphDirection Direction, string EdgeTable) |
->edge, <-edge, <->edge |
GraphDirection: Out, In, Both |
Operators
SurqlUnaryOperator: Not (!), Negate (-), Plus (+).
SurqlBinaryOperator (32 values) groups into: logical (Or, And, NullCoalesce ??,
TernaryFallback ?:), equality (Equal =, NotEqual !=, ExactEqual ==, AnyEqual ?=,
AllEqual *=), relational (LessThan, LessThanOrEqual, GreaterThan, GreaterThanOrEqual),
arithmetic (Add, Subtract, Multiply, Divide, Remainder, Power), containment
(Contains, ContainsNot, ContainsAll, ContainsAny, ContainsNone, Inside (IN),
NotInside, AllInside, AnyInside, NoneInside), spatial (Outside, Intersects), and
pattern matching (Matches, emitted as @@).
Clauses and supporting records
These are not SurqlNodes themselves (no Accept), but appear as members of statements:
| Type | Purpose |
|---|---|
SelectField(SurqlExpression Expression, string? Alias) |
A projected field, optionally AS alias |
OrderByField(SurqlExpression Field, bool Descending, bool Numeric, bool Collate) |
One ORDER BY entry |
OrderByClause |
ORDER BY RAND() (.Rand) or ORDER BY field, ... (.ByFields(...)) |
GroupClause |
GROUP ALL (.All) or GROUP BY field, ... (.ByFields(...)) |
WithClause |
WITH NOINDEX (.NoIndex) or WITH INDEX name, ... (.Index(...)) |
ExplainMode (enum) |
Explain, ExplainFull |
DataClause (abstract) |
Base of the six DML data-clause kinds below |
ContentClause(SurqlExpression Value) |
CONTENT value |
MergeClause(SurqlExpression Value) |
MERGE value |
PatchClause(SurqlExpression Value) |
PATCH value (a JSON-Patch operations array) |
ReplaceClause(SurqlExpression Value) |
REPLACE value |
SetClause(IEnumerable<SetOperation> Operations) |
SET target op value, ... |
UnsetClause(IEnumerable<IdiomExpression> Fields) |
UNSET field, ... |
SetOperation(IdiomExpression Target, SetOperator Operator, SurqlExpression Value) |
One assignment; SetOperator: Assign (=), Add (+=), Subtract (-=) |
ReturnClause |
RETURN NONE\|BEFORE\|AFTER\|DIFF\|NULL\|fields\|VALUE expr — factories .None, .Before, .After, .Diff, .Null, .OfFields(...), .OfValue(...) |
ObjectEntry(string Key, SurqlExpression Value) |
One ObjectExpression entry |
RecordIdGenerator (enum) |
Rand, Ulid, Uuid |
A statement holds at most one DataClause, which is what makes the server's "SET and UNSET are
mutually exclusive" rule structural rather than something callers must remember. CreateStatement
and RelateStatement further restrict their Data to ContentClause/SetClause at construction
(any other kind throws ArgumentException).
Emitting
SurqlEmitter (Palm.SurrealDB.Net.SurrealQL) is a static partial class with two public entry
points:
public static SurqlText Emit(SurqlNode node); // parameterized — safe for untrusted input
public static string EmitInline(SurqlNode node); // literals embedded inline
Emit walks the tree once, binding every LiteralExpression to a generated $p0, $p1, …
parameter (in order of first appearance) and returning a SurqlText — a
record SurqlText(string Text, IReadOnlyDictionary<string, SurrealValue> Parameters) — ready to
pass straight to client.QueryAsync(text.Text, text.Parameters). Generated pN names never
collide with user-supplied $pN parameters already present in the tree (the emitter pre-scans the
tree for reserved names).
EmitInline embeds every literal and identifier directly into the returned string via
SurqlLiteralWriter/SurqlIdent. EmitInline (and ToSurqlInline() on the builders) is
tooling/tests only — never call it with untrusted input. This is a hard project rule (see the
root AGENTS.md and src/AGENTS.md): identifier-emitting paths
route through SurqlIdent.Escape, but inline mode still trades the parameter boundary for
convenience, and geometry literals cannot be represented inline at all (EmitInline throws
SurrealDbSerializationException if the tree contains one).
Both entry points throw ArgumentNullException when node is null.
Parsing
SurqlParser (Palm.SurrealDB.Net.SurrealQL) is the inverse of SurqlEmitter — it turns
SurrealQL text back into the AST:
public static SurqlExpression ParseExpression(string text);
public static SurqlStatement ParseStatement(string text);
ParseExpression currently supports the full expression subset: literals, parameters, field
idioms (including index/filter/graph/destructure/optional parts), function calls, arrays, objects,
casts, ranges, and the full unary/binary operator set. ParseStatement currently supports SELECT;
other statement kinds are added incrementally as the AST/emitter grow to cover them. Both throw
SurqlParseException (carrying 1-based Line/Column) when the text is not valid or not fully
consumed, and ArgumentNullException when text is null.
The parser exists primarily to round-trip verify the emitter (every emitter-producible AST node
must appear in the parser's round-trip test cases — see the project's LESSONS-LEARNED.md
"oracle blind spot" note) rather than as a general-purpose SurrealQL frontend; the lexer
(SurqlLexer), token model (SurqlToken), and expression/statement parser internals are all
internal.
Formatting
SurqlFormatter (Palm.SurrealDB.Net.SurrealQL) formats an AST node with minimal parentheses,
driven by the same operator precedence table the parser enforces (loose→tight: nullish < or < and
< equality < relation < add/sub < mul/div < power < prefix < range):
public static string Format(SurqlNode node);
This differs from SurqlEmitter.EmitInline, which fully parenthesizes every non-atomic operand for
version-robust correctness regardless of precedence. SurqlFormatter.Format is for producing
readable output (logs, debugging, docs) and assumes the SurrealDB 3.1.4 precedence the parser
verifies; like EmitInline, its output is not injection-safe — prefer SurqlEmitter.Emit for
anything that reaches the server with untrusted values. Throws ArgumentNullException when node
is null.
Related projects
Palm.SurrealDB.Net.Abstractions— this project's only dependency:SurrealValue,RecordId,SurrealDuration, and the other core value types used throughout the AST.Palm.SurrealDB.Net.Serialization— CBOR codec and theISurrealTypeMappername-mapping source of truth.Palm.SurrealDB.Net.Protocol— WebSocket/HTTP RPC transport.Palm.SurrealDB.Net— the client (SurrealDbClient,QueryAsync, CRUD, live queries); consumesSurqlTextproduced here.Palm.SurrealDB.Net.Linq— theIQueryable<T>LINQ provider; translates LINQ expression trees into this project's AST (SurqlExpression/SurqlStatement) and emits them via the sameSurqlEmitter.Emitpath documented above.Palm.SurrealDB.FSharp— an idiomatic F# DSL that wraps this project's AST with value/operator modules and statement computation expressions.- Repository root README — ecosystem overview, quick start, and build/test instructions.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Palm.SurrealDB.Net.Abstractions (>= 0.2.1)
NuGet packages (7)
Showing the top 5 NuGet packages that depend on Palm.SurrealDB.Net.SurrealQL:
| Package | Downloads |
|---|---|
|
Palm.SurrealDB.EntityFrameworkCore.Diagnostics
Diagnostics, event definitions and command interception for the Palm.SurrealDB EF Core provider. |
|
|
Palm.SurrealDB.Net.Linq
LINQ provider for SurrealDB — translates expression trees to SurrealQL via the Palm.SurrealDB.Net.SurrealQL AST. |
|
|
Palm.SurrealDB.EntityFrameworkCore
Entity Framework Core provider for SurrealDB, built on the Palm.SurrealDB.Net client and SurrealQL AST. |
|
|
Palm.SurrealDB.EntityFrameworkCore.Migrations
Convergent schema management (EnsureSurrealSchema / GetSurrealSchemaDiff / GenerateSurrealSchemaScript) for the SurrealDB EF Core provider. Not EF migrations — SurrealDB has no relational migration model (D1). |
|
|
Palm.SurrealDB.FSharp
Idiomatic F# DSL over the SurrealQL AST for the Palm.SurrealDB ecosystem. |
GitHub repositories
This package is not used by any popular GitHub repositories.