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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Palm.SurrealDB.Net.Linq" Version="0.2.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Palm.SurrealDB.Net.Linq" Version="0.2.1" />
                    
Directory.Packages.props
<PackageReference Include="Palm.SurrealDB.Net.Linq" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Palm.SurrealDB.Net.Linq --version 0.2.1
                    
#r "nuget: Palm.SurrealDB.Net.Linq, 0.2.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Palm.SurrealDB.Net.Linq@0.2.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Palm.SurrealDB.Net.Linq&version=0.2.1
                    
Install as a Cake Addin
#tool nuget:?package=Palm.SurrealDB.Net.Linq&version=0.2.1
                    
Install as a Cake Tool

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 as String.Format())
  • string.ToLower() (and other case-conversion calls)
  • Array/collection indexers (p.Tags[0])
  • is/as type 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.Contains with a custom IEqualityComparer
  • Math.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();
Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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.

Version Downloads Last Updated
0.2.1 129 9/5/2026
0.2.0 128 9/3/2026