Palm.SurrealDB.Net.Linq
0.2.1
dotnet add package Palm.SurrealDB.Net.Linq --version 0.2.1
NuGet\Install-Package Palm.SurrealDB.Net.Linq -Version 0.2.1
<PackageReference Include="Palm.SurrealDB.Net.Linq" Version="0.2.1" />
<PackageVersion Include="Palm.SurrealDB.Net.Linq" Version="0.2.1" />
<PackageReference Include="Palm.SurrealDB.Net.Linq" />
paket add Palm.SurrealDB.Net.Linq --version 0.2.1
#r "nuget: Palm.SurrealDB.Net.Linq, 0.2.1"
#:package Palm.SurrealDB.Net.Linq@0.2.1
#addin nuget:?package=Palm.SurrealDB.Net.Linq&version=0.2.1
#tool nuget:?package=Palm.SurrealDB.Net.Linq&version=0.2.1
Palm.SurrealDB.Net.Linq
IQueryable<T> LINQ-to-SurrealQL provider for Palm.SurrealDB. The entry point
is client.Query<T>(), which returns a queryable rooted in a live ISurrealClient. Supported LINQ
operator chains (Where, Select, OrderBy/ThenBy, Skip/Take, and the terminal operators)
are translated to the Palm.SurrealDB.Net.SurrealQL AST and executed as a single parameterized
SELECT statement — there is no client-side evaluation fallback. Anything the translator cannot
express throws SurqlTranslationException at the point the expression tree is walked (i.e. when
you await a terminal, or call .ToString()/enumerate a synchronous query), not silently.
Depends on Palm.SurrealDB.Net.Abstractions (ISurrealClient, [SurrealTable]/[SurrealField],
SurrealValue) and Palm.SurrealDB.Net.SurrealQL (the AST and emitter). At runtime you also need a
Palm.SurrealDB.Net client instance (SurrealDbClient implementing ISurrealClient) to call
Query<T>() against.
Installation
dotnet add package Palm.SurrealDB.Net.Linq
This project is packable and ships as the Palm.SurrealDB.Net.Linq 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.Linq/Palm.SurrealDB.Net.Linq.csproj" />
</ItemGroup>
Quick start
using Palm.SurrealDB.Net;
using Palm.SurrealDB.Net.Linq;
[SurrealTable("person")]
public sealed record Person(
string Name,
int Age,
[property: SurrealField("email_address")] string? Email,
string City);
var client = new SurrealDbClient(new SurrealDbOptions
{
Endpoint = new Uri("ws://127.0.0.1:8000/rpc"),
Namespace = "test",
Database = "test",
Credentials = new RootCredentials("root", "root"),
});
await client.ConnectAsync();
var adults = await client.Query<Person>()
.Where(p => p.Age >= 18 && p.City == "kingston")
.OrderBy(p => p.Name)
.Take(10)
.ToListAsync();
[SurrealTable("person")] (defined in Palm.SurrealDB.Net.Abstractions) fixes the table name;
without it, the active SurrealNamingPolicy is applied to the CLR type name. [SurrealField(...)]
(also defined in Palm.SurrealDB.Net.Abstractions) does the same per-property — this project only
consumes those attributes for table/field resolution (SurrealTableName.Resolve), it does not
define them.
Query<T>()
public static ISurrealQueryable<T> Query<T>(this ISurrealClient client)
Starts a strongly-typed query over the SurrealDB table for T. Returns an
ISurrealQueryable<T> : IQueryable<T> — a marker interface that identifies a queryable rooted in
this provider (as opposed to some other IQueryable<T> source), so the async terminal extensions
can recognize it. Query<T>() throws ArgumentNullException if client is null.
Because the provider translates via reflection over T's members, Query<T>() and every terminal
in this package are annotated [RequiresDynamicCode]/[RequiresUnreferencedCode] — expect trimming
and Native AOT analyzer warnings if you consume it from a trimmed/AOT-published app, and expect it
not to work correctly under full trimming.
Supported LINQ operator surface
Only the operators and constructs below translate. Everything else throws
SurqlTranslationException (see Strict-translation behavior).
| Operator / construct | Notes |
|---|---|
Where(predicate) |
Multiple Where calls AND-merge into a single condition. Must not follow Select in the chain. |
Select(x => x.Member) / Select(x => x.Nested.Member) |
Single-member or dot-chained member projection only (SELECT VALUE ...-shaped). No anonymous types, new T(...), or member-init projections — see negative matrix. Must be the last shaping call before paging. |
OrderBy(x => x.Member) / OrderByDescending(...) |
Exactly one initial ordering call per query. |
ThenBy(x => x.Member) / ThenByDescending(...) |
Must follow an existing OrderBy/OrderByDescending in the same chain. |
Skip(n) |
Maps to START n. Must precede Take, not follow it. |
Take(n) |
Maps to LIMIT n. A negative count throws InvalidOperationException (not SurqlTranslationException). |
First() / First(predicate) |
LIMIT 1; predicate form merges into Where. |
FirstOrDefault() / FirstOrDefault(predicate) |
Same as First, non-throwing on empty results. |
Single() / Single(predicate) |
LIMIT 2 (fetches up to two rows so the provider can detect and throw on cardinality violations client-side). |
SingleOrDefault() / SingleOrDefault(predicate) |
Same as Single, non-throwing on empty results. |
Comparison operators (==, !=, >, >=, <, <=) |
Standard binary comparisons over member access and literals/captured variables. |
&&, \|\|, ! |
Boolean composition inside Where. |
string.Contains(string) |
→ string::contains(field, value). The (string, StringComparison) and (char) overloads are not supported. |
string.StartsWith(string) / string.EndsWith(string) |
→ string::starts_with(...) / string::ends_with(...). |
Enumerable.Contains on a captured/constant collection (e.g. ids.Contains(p.Id)) |
→ field IN $array. The receiver must not be rooted in the query parameter (p.Tags.Contains(...) throws) and no custom IEqualityComparer overload is supported. |
member == null / member != null |
See null handling below — compiles to SurrealValue.None, not a SQL NULL literal. |
Async terminal wrappers (ToListAsync, FirstAsync, FirstOrDefaultAsync, SingleAsync,
SingleOrDefaultAsync) build on the same translation and are documented in
Async terminals below.
Not supported — throws SurqlTranslationException
Verified against tests/Palm.SurrealDB.Net.Linq.Tests/StrictNegativeMatrixTests.cs and
MethodTranslationTests.cs, the analyzer parity corpus for this provider:
Query-shaping operators
Join, GroupJoin, SelectMany, GroupBy, Distinct, DistinctBy, Reverse, Last,
LastOrDefault, ElementAt, ElementAtOrDefault, Concat, Union, Intersect, Except, Zip,
Cast, OfType, Order, OrderDescending, Count, Any, All, Sum, Min, Max, indexed
Where/Select overloads ((x, i) => ...), a second Where/Select-shaping call in the wrong
position (e.g. Where after Select, OrderBy after Select), a second OrderBy, a second
Take, and Skip after Take.
Constructs inside a lambda body (Where/Select)
- Anonymous-type projections (
Select(p => new { p.Name })) - Constructor or member-init projections (
Select(p => new Dto(p.Name)),new Dto { Name = p.Name }) - Conditional expressions (
p.Age > 18 ? a : b) - String interpolation (
$"n{p.Age}"— reported asString.Format()) string.ToLower()(and other case-conversion calls)- Array/collection indexers (
p.Tags[0]) is/astype checks (TypeIs,TypeAs)- Virtual
Equals()calls (p.Name.Equals(...)) .Contains()rooted at a query-parameter member (p.Tags.Contains("x"),p.Numbers.Contains(p.Age))string.Contains(string, StringComparison),string.Contains(char)Enumerable.Containswith a customIEqualityComparerMath.Abs()and other math helper calls- Bitwise
&,|,^ .ToString()calls
Every SurqlTranslationException from the negative matrix carries LinqOperator set to the
offending LINQ method name (ex.LinqOperator), and — for lambda-body construct failures —
Construct set to a description of the offending CLR node (e.g. "String.ToLower()",
"ArrayIndex", "Enumerable.Contains (parameter-rooted source)"). The message shape is:
'{Construct}' cannot be translated to SurrealQL inside '{LinqOperator}'.
or, for an unsupported operator with no specific construct:
The LINQ operator '{LinqOperator}' is not supported by the Palm.SurrealDB LINQ provider.
Null handling
Where(p => p.Email == null) does not compile to a literal SurrealQL NULL comparison.
Captured/literal values — null included — flow through the same reflection-based type mapper used
for storage (ISurrealTypeMapper.ToSurrealValue), which serializes CLR null as
SurrealValueKind.None (SurrealDB's "absent field" marker, NONE), not SurrealValueKind.Null.
This matches SurrealDB's storage semantics: a property serialized from a null CLR value is stored
as an absent field, and = NONE — not = NULL — is what matches an absent field. If your data can
contain an explicit stored NULL (distinct from a field that was never set), a naive
== null comparison here will not match it; that distinction is not currently exposed through this
LINQ surface.
Async terminals
Async terminals are extension methods on IQueryable<T> in SurrealQueryableExtensions
(SurrealQueryableAsyncExtensions.cs). All support cancellation and require the operator chain to
be gathered up to and including any predicate before executing a single request:
public static Task<IReadOnlyList<T>> ToListAsync<T>(this IQueryable<T> source, CancellationToken cancellationToken = default);
public static Task<T> FirstAsync<T>(this IQueryable<T> source, CancellationToken cancellationToken = default);
public static Task<T> FirstAsync<T>(this IQueryable<T> source, Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default);
public static Task<T?> FirstOrDefaultAsync<T>(this IQueryable<T> source, CancellationToken cancellationToken = default);
public static Task<T?> FirstOrDefaultAsync<T>(this IQueryable<T> source, Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default);
public static Task<T> SingleAsync<T>(this IQueryable<T> source, CancellationToken cancellationToken = default);
public static Task<T> SingleAsync<T>(this IQueryable<T> source, Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default);
public static Task<T?> SingleOrDefaultAsync<T>(this IQueryable<T> source, CancellationToken cancellationToken = default);
public static Task<T?> SingleOrDefaultAsync<T>(this IQueryable<T> source, Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default);
The predicate overloads of FirstAsync/FirstOrDefaultAsync/SingleAsync/SingleOrDefaultAsync
are equivalent to calling .Where(predicate) before the parameterless overload — the predicate
merges into the query's WHERE clause rather than issuing a separate round trip.
FirstAsync/SingleAsync throw when the result set is empty (no rows matched); FirstOrDefaultAsync
/SingleOrDefaultAsync return default(T) in that case. SingleAsync/SingleOrDefaultAsync also
throw if more than one row matches.
These extensions only work on a queryable produced by client.Query<T>(). Passing any other
IQueryable<T> (e.g. an in-memory List<T>.AsQueryable(), or an EF Core DbSet<T>) throws
InvalidOperationException with the message:
The source IQueryable is not a Palm.SurrealDB LINQ query; async terminals require ISurrealClient.Query<T>().
Worked examples
1. An annotated entity
[SurrealTable("person")]
public sealed record Person(
string Name,
int Age,
[property: SurrealField("email_address")] string? Email,
string City);
2. Sync-built, async-executed filter + sort + page
var page = await client.Query<Person>()
.Where(p => p.Age > 18)
.OrderByDescending(p => p.Age)
.ThenBy(p => p.Name)
.Skip(5)
.Take(10)
.ToListAsync();
3. FirstAsync with a predicate
var ada = await client.Query<Person>()
.FirstAsync(p => p.Name == "Ada", cancellationToken);
4. string.Contains and Enumerable.Contains
// string::contains(name, $p0)
var matches = await client.Query<Person>()
.Where(p => p.Name.Contains("ad"))
.ToListAsync();
// name IN $p0 (array parameter)
var wanted = new[] { "Ada", "Bo", "Cy" };
var known = await client.Query<Person>()
.Where(p => wanted.Contains(p.Name))
.ToListAsync();
5. Null comparison compiles to SurrealValue.None, not SQL NULL
// Matches rows where "email_address" is an absent field (NONE), per the null-handling note above.
var noEmail = await client.Query<Person>()
.Where(p => p.Email == null)
.ToListAsync();
6. An unsupported construct throws SurqlTranslationException
// Anonymous-type projections are not translated — throws before any request is sent.
var ex = Assert.Throws<SurqlTranslationException>(() =>
client.Query<Person>().Select(p => new { p.Name }).ToString());
// ex.LinqOperator == "Select"
// ex.Construct == "New"
// ex.Message == "'New' cannot be translated to SurrealQL inside 'Select'."
GroupBy, Join, and the other operators listed under
Not supported throw the same way, with
Construct unset and LinqOperator set to the operator name, e.g.:
// ex.LinqOperator == "GroupBy"
// ex.Message == "The LINQ operator 'GroupBy' is not supported by the Palm.SurrealDB LINQ provider."
client.Query<Person>().GroupBy(p => p.City).ToString();
Related projects
Palm.SurrealDB.Net.Abstractions—ISurrealClient,RecordId,SurrealValue,[SurrealTable]/[SurrealField].Palm.SurrealDB.Net.Serialization— the CBOR codec andISurrealTypeMapperthis provider uses for literal/field name resolution.Palm.SurrealDB.Net.Protocol— WebSocket/HTTP transport underneath the client.Palm.SurrealDB.Net.SurrealQL— the AST andSurqlEmitterthis provider translates into.Palm.SurrealDB.Net—SurrealDbClient, theISurrealClientimplementationQuery<T>()runs against.Palm.SurrealDB.FSharp— the F# DSL over the SurrealQL AST.- Root README — ecosystem overview, quick start, 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 (>= 0.2.1)
- Palm.SurrealDB.Net.Abstractions (>= 0.2.1)
- Palm.SurrealDB.Net.Serialization (>= 0.2.1)
- Palm.SurrealDB.Net.SurrealQL (>= 0.2.1)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Palm.SurrealDB.Net.Linq:
| Package | Downloads |
|---|---|
|
Palm.SurrealDB.EntityFrameworkCore
Entity Framework Core provider for SurrealDB, built on the Palm.SurrealDB.Net client and SurrealQL AST. |
GitHub repositories
This package is not used by any popular GitHub repositories.