CamusDB.EntityFrameworkCore
0.15.3
See the version list below for details.
dotnet add package CamusDB.EntityFrameworkCore --version 0.15.3
NuGet\Install-Package CamusDB.EntityFrameworkCore -Version 0.15.3
<PackageReference Include="CamusDB.EntityFrameworkCore" Version="0.15.3" />
<PackageVersion Include="CamusDB.EntityFrameworkCore" Version="0.15.3" />
<PackageReference Include="CamusDB.EntityFrameworkCore" />
paket add CamusDB.EntityFrameworkCore --version 0.15.3
#r "nuget: CamusDB.EntityFrameworkCore, 0.15.3"
#:package CamusDB.EntityFrameworkCore@0.15.3
#addin nuget:?package=CamusDB.EntityFrameworkCore&version=0.15.3
#tool nuget:?package=CamusDB.EntityFrameworkCore&version=0.15.3
CamusDB Connector for .NET
.NET idiomatic client libraries for CamusDB
This repository contains three packages:
| Package | Description |
|---|---|
CamusDB.Client |
ADO.NET provider — recommended for direct database access from .NET |
CamusDB.EntityFrameworkCore |
Entity Framework Core provider built on top of CamusDB.Client |
CamusDB.Dapper |
Dapper support: typed parameters, native array parameters and type handlers |
CamusDB.Client (ADO.NET)
Installation
dotnet add package CamusDB.Client
Or via the Package Manager Console:
Install-Package CamusDB.Client
Configuration
Create a CamusConnectionStringBuilder with a connection string:
using CamusDB.Client;
CamusConnectionStringBuilder builder = new("Endpoint=http://localhost:8082;Database=test");
await using CamusConnection connection = new(builder);
await connection.OpenAsync();
Supported connection string keys:
| Key | Required | Description |
|---|---|---|
Endpoint |
Yes | Base URL for the CamusDB endpoint (REST or gRPC — see Transport). |
Database |
Yes | Database name sent with requests. |
Timeout |
No | Request timeout in seconds (default: 10). |
Protocol |
No | Wire protocol: rest (default) or grpc. See Transport. |
HttpVersion |
No | Highest HTTP version for REST calls: 1.1 (default), 2 or 3. See HTTP/2 and HTTP/3 over REST. |
User |
No | User to authenticate as. Aliases: UserId, Uid, Username. See Authentication. |
Password |
No | That user's password. Alias: Pwd. |
AccessToken |
No | A bearer token obtained elsewhere, used verbatim instead of logging in. |
TokenLifetime |
No | Fallback seconds to reuse a token when the server reports no expiry (default: 600). |
MaxAutoPrepare |
No | How many statements to keep prepared (default: 128; 0 disables). See Prepared Statements. |
AutoPrepareMinUsages |
No | Executions of the same SQL before it is prepared (default: 2). |
ChannelPoolSize |
No | gRPC only: long-lived BatchExecute streams per endpoint (default: 2). See Transport. |
CoalescingThreshold |
No | gRPC only: ops a drain must produce before the pump stops waiting for more (default: 10; 1 disables coalescing). |
CoalescingDelay |
No | gRPC only: milliseconds the pump waits after a multi-op drain to accumulate a larger burst (default: 0, off; a single-op drain never waits). |
RequestFrames |
No | gRPC only: write operations that wait together as one stream message, to a server that announced support (default: true). Also gates pipelined transactions. See Transport. |
BackupEndpoint |
No | HTTP endpoint for the backup admin API. Defaults to Endpoint; required with Protocol=grpc. See Backups. |
BackupTimeout |
No | Backup admin request timeout in seconds (default: 300). See Backups. |
AllowInsecureCredentials |
No | true waives the client-side refusal to send credentials to a remote plaintext endpoint. See TLS. |
RoutingMode |
No | Learned statement routing: Auto (default), Learned, or Off. See Learned routing. |
RoutingNodes |
No | Trust map from server node identities to Endpoint pool members. See Learned routing. |
RoutingMaxHintAge |
No | Ceiling in milliseconds on how long a learned route may be reused (default: 5000). |
Endpoint also supports a comma-separated pool. The client selects endpoints with round-robin routing:
CamusConnectionStringBuilder builder = new(
"Endpoint=http://localhost:8082,http://localhost:8084,http://localhost:8086;Database=test");
When a request fails because an endpoint is unreachable, that endpoint is set aside for 30 seconds and skipped meanwhile; a node that is still down is set aside again by the next request that draws it. The rotation and that health are shared by every connection carrying the same Endpoint value, so they still mean something under EF Core, which builds a connection-string builder per connection.
Learned routing
A CamusDB server (with sql_routing_advice_enabled, on by default) can attach advisory routing metadata to a successful SQL response: which node leads the data behind that statement. With routing enabled, the driver learns from it and sends the statement's future executions straight to that node, skipping a forwarding hop. This is a latency optimization only — it changes no result, no isolation, and no commit behavior, and the server still executes correctly wherever a request lands.
CamusConnectionStringBuilder builder = new(
"Endpoint=http://a:5095,http://b:5095,http://c:5095;Database=test;RoutingMode=Learned;" +
"RoutingNodes='camus-a:7070=http://a:5095,camus-b:7070=http://b:5095,camus-c:7070=http://c:5095'");
RoutingNodesis the routing authority. The server advertises an opaque node identity (itshost:portcluster identity); it becomes a destination only through this map, and only when the mapped address is also a member of theEndpointpool. The driver never dials a server-provided address, so a response can never steer traffic anywhere the operator did not list. Quote the value — it contains=.- Modes.
Auto(default) negotiates only when the map names at least two distinct endpoints — a single load-balancer URL is not a set of routable nodes — so the trust map is the opt-in: a connection string withoutRoutingNodessends requests byte-identical to a pre-routing driver.Learnednegotiates when at least one identity is mapped.Offnever negotiates and is the explicit kill switch. - What is learned. Advice is cached per (database, exact SQL text, statement kind) with a TTL of
min(server maxAgeMs, RoutingMaxHintAge), measured monotonically from receipt. Parameterized and prepared workloads are where reuse pays; a hint never generalizes from one SQL string to another. Late replies cannot overwrite newer routes, and the cache is bounded (4096 entries / 4 MiB). - Precedence. An explicit transaction stays pinned to the endpoint it started on — advice may inform the next transaction, never relocate a live one. A learned route pointing at a quarantined endpoint falls back to rotation. Routing adds no retry of any kind.
- Where a transaction starts. With routing on,
BEGINis deferred:BeginTransactionsends nothing, and the transaction's first statement starts it on that statement's learned endpoint, so a transaction whose first statement touches tableTruns whereT's leader is. Every later statement and the finalize stay on that endpoint. Statements inside the transaction still negotiate and learn, for the next transaction's benefit. To pick the endpoint from a later, hotter statement instead, pass its exact SQL asCamusTransactionOptions.Affinity;BEGINis then sent at once to that statement's learned endpoint (rotation when nothing is learned). With routing off,BEGINis sent insideBeginTransactionon the pool's rotation, exactly as before. - What the deferral changes for a caller. In routed mode
CamusTransaction.TxnIdPT,TxnIdCounterandTransactionIdread as zero until the first statement (IsStartedsays which state the transaction is in), and a failure to begin surfaces from that first statement with the same exceptionBeginTransactionwould have thrown. A transaction that runs no statement is started by itsCommit/Rollback, on rotation, so the server sees the sameBEGIN-then-finalize pair. Set locking and isolation throughBeginTransactionAsync(CamusTransactionOptions)or the connection-string defaults, not with aSET TRANSACTIONstatement: as the first statement it carries no routing advice and starts the transaction on rotation. - Learned state is shared per deployment-and-configuration, like the endpoint pool, so it stays warm under EF Core.
- The eligible statements and the full wire contract are documented in the server repo:
docs/sql-routing-advice.md. Older servers ignore the negotiation field, and the driver works against them unchanged.
The most recent advice a command received is exposed on CamusCommand.LastRoutingAdvice for diagnostics.
How the string is parsed. Keys are matched without regard to case and are trimmed, so password=, Password= and Password all reach the same key. A repeated key is an error rather than a silent first-wins.
An unquoted value is trimmed and ends at the next ;. Wrap a value in single or double quotes to keep a semicolon or surrounding spaces, and double the quote character to include one:
CamusConnectionStringBuilder builder = new(
"Endpoint=https://db.example:5095;Database=test;User=app;Password='pa;ss''word'");
// Password is: pa;ss'word
builder.ToString() returns the string as written, credentials included. Use builder.ToRedactedString() — or the static CamusConnectionStringBuilder.Redact(...) — for anything that is logged: it masks Password, Pwd and AccessToken and leaves everything else readable. The EF Core provider reports the redacted form in its options debug info.
A CamusConnection keeps the connection string it was built from. Assigning a different one throws NotSupportedException, because nothing re-reads the property: the connection would keep authenticating with the original credentials while reporting the new ones. Build a new connection instead.
Authentication
CamusDB authentication is off by default; a connection string without credentials behaves exactly as before and sends no Authorization header. Against a server started with CAMUSDB_AUTH_ENABLED=true (see the server's docs/sql-authentication.md), add credentials:
CamusConnectionStringBuilder builder = new(
"Endpoint=http://localhost:5095;Database=test;User=app;Password=app-secret");
await using CamusConnection connection = new(builder);
await connection.OpenAsync();
// Nothing else changes — the first statement authenticates transparently.
await using CamusCommand command = connection.CreateCamusCommand("SELECT * FROM orders");
The password is exchanged once for a short-lived bearer token — POST /login over REST, the CamusAuth service over gRPC — and every statement then carries that token, never the password. The driver handles the token for you:
- One login per credential set, not per connection. The token is cached process-wide keyed by credentials + server, so a connection pool — or Entity Framework, which builds a fresh connection per operation — performs a single login. This matters: password verification is deliberately expensive server-side (PBKDF2, 600k iterations by default) and the server rate-limits logins to 20 per account per minute.
- Renewal is automatic. The login reply reports how long the token is good for, and the driver renews at 80% of that — the server's
AccessTokenTtlis configurable and may be shorter than any lifetime the driver would assume, so its value wins. Against a server that reports no expiry the driver falls back toTokenLifetime(10 minutes by default, comfortably inside the server's 15-minute default). A token rejected before then — after a password rotation, aDROP USER, a logout elsewhere, or a server restart — is caught reactively: the driver discards it, authenticates again, and replays the statement once. A rejected request never reached execution, so the replay is as safe as the serializable-conflict retry the driver already performs. - A gRPC stream is rotated when the token is renewed. With
Protocol=grpc, statements ride on long-livedBatchExecutestreams, and a stream can present a token only once, when it opens. The driver does not rely on a stream outliving that token. On the first statement after a renewal, each stream is replaced by one opened under the new token. A transaction cannot change streams, so the old stream stays open for the transactions that began on it, takes no new work, and closes when the last of them commits or rolls back — or after five minutes, so that an abandoned transaction cannot hold it, and its locks, open. While its stream drains, such a transaction runs its statements unprepared. Nothing is needed from the caller, and no statement fails because of a rotation. A token supplied withAccessToken=is never renewed, so its streams are never rotated. CADB0517(insufficient privilege) is never retried — re-authenticating as the same user cannot grant a privilege. Grant it withGRANT … ON db.* TO user.
Authenticating explicitly
When the password comes from a secret manager at runtime, keep it out of the connection string and log in on the open connection:
await using CamusConnection connection = new(new CamusConnectionStringBuilder("Endpoint=http://localhost:5095;Database=test"));
await connection.OpenAsync();
string token = await connection.LoginAsync("app", await secrets.GetAsync("camus-password"));
// … statements …
await connection.LogoutAsync(); // revokes the token server-side
connection.AccessToken exposes the cached token (null when none has been minted) — useful to hand to another process via AccessToken=. A token supplied that way is used verbatim and never renewed: the driver has no password to mint a replacement with, so a rejection surfaces as CamusException with code CADB0516 rather than being retried.
LogoutAsync revokes the token but keeps the configured credentials, so a later statement authenticates again transparently.
Authenticating over gRPC
Nothing extra to configure — credentials work the same as over REST:
CamusConnectionStringBuilder builder = new(
"Endpoint=http://localhost:5096;Database=test;Protocol=grpc;User=app;Password=app-secret");
The exchange rides the server's dedicated CamusAuth service (Login/Logout) on the very channel that carries the statements, so a gRPC-only deployment never has to expose the HTTP port just to obtain a token. The token then travels in the authorization call metadata.
One consequence worth knowing: the token is attached when a BatchExecute stream opens, because the server resolves the principal once for the whole stream. A refreshed token therefore reaches the server on the next stream the client builds, not mid-stream.
TLS
With authentication enabled the server refuses credential-bearing requests over plaintext (RequireTlsWhenAuthEnabled, on by default), exempting loopback so single-host development works without certificates. Against any non-loopback deployment use https:// endpoints, or the server answers CADB0519.
When TLS terminates in front of the node (ingress, sidecar, service mesh) that hop is invisible to the server, so it would otherwise reject every forwarded request. Start it with --require-tls-when-auth-enabled false, or set require_tls_when_auth_enabled: false in config.yml, and keep the plaintext hop inside the trust boundary.
The client refuses first. A connection string that carries credentials — Password= or AccessToken= — against an endpoint that is neither https:// nor loopback is rejected when the builder is constructed, with CADB0519. The server's own check is the right one, but it only fires after the password has crossed the wire, and a deployment can switch it off; this one fires before anything leaves the process. BackupEndpoint is held to the same rule — it receives the connection's own token, which the backup admin API requires to be superuser-grade — and must name the same deployment as Endpoint, since a token is only meaningful to the deployment that minted it.
Set AllowInsecureCredentials=true for the terminating-proxy case above, where the plaintext hop is inside the trust boundary:
CamusConnectionStringBuilder builder = new(
"Endpoint=http://camus.internal:5095;Database=test;User=app;Password=app-secret;AllowInsecureCredentials=true");
Error codes
| Code | HTTP | Meaning |
|---|---|---|
CADB0516 |
401 | Authentication failed — missing/invalid/expired token, unknown user, or wrong password. The server returns the same code for all of them so replies cannot be used to enumerate accounts. |
CADB0517 |
403 | Authenticated, but lacking the privilege the statement requires on some table it touches. |
CADB0518 |
429 | Too many login attempts for that account. |
CADB0519 |
400 | Credentials sent over a plaintext connection where the server requires TLS. |
Users and grants are managed with SQL (CREATE USER, GRANT, REVOKE, SHOW GRANTS), which the driver runs like any other statement — as a superuser. Bind the password as a parameter rather than inlining it, so it does not reach query logs or tracing:
await using CamusCommand command = connection.CreateCamusCommand("CREATE USER app IDENTIFIED BY @password");
command.Parameters.Add("@password", ColumnType.String, "app-secret");
await command.ExecuteNonQueryAsync();
Transport (REST / gRPC)
CamusDB.Client can talk to the server over either the REST/JSON API or the gRPC API. The transport is chosen per connection with the Protocol connection-string key and defaults to REST, so existing connection strings are unaffected. The whole ADO.NET surface — CamusConnection, CamusCommand, CamusTransaction, the data reader, parameters, and the EF Core provider — is identical on both; only the wire protocol changes.
// REST (default) — no change needed
CamusConnectionStringBuilder rest = new("Endpoint=http://localhost:5095;Database=test");
// gRPC — set Protocol=grpc and point Endpoint at the gRPC port
CamusConnectionStringBuilder grpc = new("Endpoint=http://localhost:5096;Database=test;Protocol=grpc");
Protocol accepts rest or grpc (case-insensitive); any other value falls back to REST.
The server exposes REST and gRPC on separate ports (for example 5095 for REST and 5096 for gRPC), so when selecting Protocol=grpc the Endpoint must address the gRPC port. Use http:// for plaintext HTTP/2 (h2c) in local development, or https:// against a TLS-terminated deployment. The comma-separated endpoint pool works with either protocol.
Under gRPC the data plane — queries, non-queries, and the transaction lifecycle — is multiplexed over a small pool of long-lived BatchExecute duplex streams, so concurrent operations coalesce onto shared streams instead of each paying a unary round-trip. Autocommit statements fan out across the pool; a transaction's BEGIN/statements/COMMIT are pinned to one stream so the server orders them. DDL and ping stay on the unary RPCs. This is transparent — the same CamusCommand/CamusTransaction API drives it.
The stream pool is sized by ChannelPoolSize (default 2). It is not a cap on in-flight transactions — many transactions hash onto the same streams and interleave — so the default suits most workloads; raise it when many long-running streaming queries against one endpoint would otherwise queue behind each other on a shared stream. CoalescingThreshold and CoalescingDelay trade latency for fuller frames when a burst of operations arrives together on one connection; the delay is off by default and is never applied after a single-op drain, because a caller in request/response lockstep would pay it on every round trip (measured: ~3.5 ms per statement, 12% of throughput at 128 workers).
Stream frames. When several operations wait for the same stream at the same moment, the client writes them as one stream message, a frame, and it reads a result that the server packed in the same manner. This removes the fixed cost that each stream message has on the two ends. A frame never waits for more operations: an operation that is alone travels as the same single message as before, so a client with one caller sees no added latency. Frames are negotiated per stream and are safe against an older server: the server announces support in the response headers of the stream (camusdb-batch-frames), and the client sends a frame only after it sees that announcement on that same stream. A server that announces nothing receives one message per operation, as before. In the other direction, the client tells the server that it reads response frames with the request header camusdb-batch-frames-accept when the stream opens, so a multi-row result from one caller can arrive as one message; an older server ignores that header. A frame is a transport optimization only. It gives no atomicity and no ordering that the stream does not already give; use a transaction for that. If a frame write fails, each operation in it fails with the transport error and the usual retry contract applies; the client never sends a frame again. Set RequestFrames=false to write one operation per message to every server, for A/B measurements or as a kill switch.
If you are coming from Cloud Spanner: ChannelPoolSize is the analogue of NumChannels. There is no analogue of MinSessions/MaxSessions, because CamusDB has no server-side session object to pool — nothing is created ahead of a statement, nothing expires while idle, and there is no per-node session ceiling. The SessionPoolManager/SessionPoolOptions types are obsolete no-ops kept only so Spanner-shaped code still compiles.
Both transports raise the same CamusException (carrying the server's CADBxxxx code), so error handling and the transaction retry contract are unchanged when you switch. A couple of differences are inherent to the gRPC API surface:
- Database-admin operations (
CreateDatabaseAsync,DropDatabaseAsync,CreateBranchDatabaseAsync,ShowBranchesAsync,ShowAncestorsAsync) are issued as SQL over gRPC (the gRPC service has no dedicated admin RPCs); they behave the same from the caller's side.
HTTP/2 and HTTP/3 over REST
The REST transport sends HTTP/1.1 by default. Set HttpVersion to ask for a later version:
// HTTP/3 (QUIC) against a server with http3_enabled: true
CamusConnectionStringBuilder h3 = new("Endpoint=https://db.example.com:7141;Database=test;HttpVersion=3");
HttpVersion accepts 1.1, 2 or 3 (2.0 and 3.0 also work). Any other value falls back to 1.1. The key applies to every REST call: statements, login and logout, and the backup admin API on any Protocol. Protocol=grpc statements always use HTTP/2 and ignore the key.
The value is the highest version the client asks for, not a demand. When the client knows that a later version is not available, it uses an earlier one:
HttpVersion=2gives HTTP/2 on anhttps://endpoint. On a plainhttp://endpoint the client stays on HTTP/1.1.HttpVersion=3sends the first request to an endpoint over TCP. The server'sAlt-Svcresponse header tells the client that HTTP/3 is available, and the next requests to that endpoint use QUIC. If the client host has no QUIC library, all requests stay on TCP.- If the QUIC connection to an endpoint fails, for example because the server aborts the TLS handshake or UDP is blocked, the client sends that request again over TCP and stops using HTTP/3 for that endpoint. The request did not reach the server on the first attempt, so a statement never runs twice. The second attempt adds a delay to that one request only.
HTTP/3 needs all of these:
- An
https://endpoint on the server's HTTPS port. The plain HTTP port does not serve HTTP/3. http3_enabled: trueandhttps_certificateon the server.- The HTTPS port open for UDP as well as TCP, which includes Docker port mappings and firewall rules.
- The platform QUIC library on the client host:
libmsquicon Linux and macOS (brew install libmsquic), or Windows 11 / Windows Server 2022 and later. On macOS, .NET does not search the Homebrew library folder, so start the application withDYLD_FALLBACK_LIBRARY_PATH=/opt/homebrew/lib. macOS removesDYLD_*variables for system binaries such asnohup, so set the variable in the environment of the application itself.
To confirm that the client uses HTTP/3, run SHOW CONNECTIONS on the server. The protocol column shows HTTP/3 for a QUIC connection.
Prepared Statements
A prepared statement is registered with the server once and executed many times with different values. Neither the SQL text nor the parameter names travel again per execution, and — the larger win — the server stops re-parsing the statement on every request just to decide how to route it. Both transports support it.
You do not have to do anything to use it. The driver watches the SQL it sends and, once it has seen the same statement twice, registers it and runs it prepared from then on:
// Nothing here mentions preparing. The second and later executions are prepared automatically.
for (int i = 0; i < 100; i++)
{
await using CamusCommand cmd = connection.CreateCamusCommand(
"SELECT name FROM robots WHERE year = @year");
cmd.Parameters.Add("@year", ColumnType.Integer64, 1984);
await using CamusDataReader reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
Console.WriteLine(reader.GetString(0));
}
This is what makes Entity Framework Core benefit: EF never calls Prepare() on any provider, but it does issue the same handful of statement shapes for the lifetime of a process, and those are exactly what the driver picks up. No provider configuration is needed. The decision is shared per deployment rather than per connection, so a request-scoped DbContext — a new connection and a new command every request — still crosses the threshold.
Call Prepare() (or PrepareAsync()) to skip the warm-up for a statement you already know is hot:
await using CamusCommand insert = connection.CreateCamusCommand(
"INSERT INTO robots (id, name, year) VALUES (@id, @name, @year)");
await insert.PrepareAsync();
foreach (Robot robot in robots)
{
insert.Parameters.Clear();
insert.Parameters.Add("@id", ColumnType.Id, robot.Id);
insert.Parameters.Add("@name", ColumnType.String, robot.Name);
insert.Parameters.Add("@year", ColumnType.Integer64, robot.Year);
await insert.ExecuteNonQueryAsync();
}
Tuning:
// Prepare on first sight, and keep more statements registered.
CamusConnectionStringBuilder eager = new(
"Endpoint=http://localhost:5095;Database=test;MaxAutoPrepare=512;AutoPrepareMinUsages=1");
// Turn automatic preparation off entirely. Explicit Prepare() still works.
CamusConnectionStringBuilder off = new(
"Endpoint=http://localhost:5095;Database=test;MaxAutoPrepare=0");
Things worth knowing:
- A prepared execution is indistinguishable from an inline one. Same rows, same affected counts, same transaction, isolation, locking and cache-hint behavior. Parameters are still bound by name in this API — the driver maps them onto the server's ordinals before writing — and names must match the placeholder exactly,
@included, just as they must inline. - Only
SELECT,INSERT,UPDATE,DELETEandSHOWare preparable. Schema and database administration statements are one-shot;Prepare()on one is a no-op and the statement runs normally. - Preparing never causes a failure. If the server declines to register a statement — an older node with no prepared-statement support, or a full server-side cap — it runs inline exactly as before, and a node that has no support at all is only asked once.
- Handles are node-local and expire. The driver treats "unknown statement" as routine, re-registers, and replays the execution once; a load-balanced deployment settles at one registration per node per statement. Under gRPC a handle belongs to the
BatchExecutestream that minted it, so a rebuilt stream is re-registered transparently. MaxAutoPreparebounds both sides. The least recently used statement is dropped and its server-side handle closed, so a workload that generates unbounded distinct SQL — string-concatenated queries, or EF'sIN (…)expansion, whose text changes with the list length — degrades to inline execution instead of exhausting the server's per-principal cap.- Registrations belong to the deployment, not to the connection. The driver holds one transport per endpoint, protocol and identity for the life of the process, so a statement is registered once no matter how many connections a request-scoped
DbContextopens.MaxAutoPrepareis a ceiling on what is registered, not a quota each connection spends. Note that the server's cap is per principal, spanning every database that principal opens: a deployment with many databases under one login should sizeMaxAutoPrepareagainst that shared budget.
CamusConnectionStringBuilder.PreparedStatementCount and IsPrepared(sql) report what is currently registered, for diagnostics.
Backups
connection.Backups exposes the server's online backup and point-in-time-recovery administration: taking backups, listing the catalog, resolving a restore chain, and running retention. All of these are safe while the server serves traffic.
// Take a full backup, then an incremental chained onto it.
CamusBackupInfo full = await connection.Backups.TakeFullBackupAsync();
CamusBackupInfo incremental = await connection.Backups.TakeIncrementalBackupAsync(full.BackupId);
// Resolve and validate the chain ending at a leaf. Returned root-first.
IReadOnlyList<CamusBackupInfo> chain = await connection.Backups.GetChainAsync(incremental.BackupId);
// The chain's restorable window is reported on the ROOT entry, not the leaf.
long? from = chain[0].MinRecoverablePhysicalMs; // Unix epoch milliseconds
long? to = chain[0].MaxRecoverablePhysicalMs;
// List everything in the catalog.
IReadOnlyList<CamusBackupInfo> catalog = await connection.Backups.ListBackupsAsync();
// Retention: preview first, then apply.
CamusBackupGcResult preview = await connection.Backups.PreviewGarbageCollectionAsync(); // Applied == false
CamusBackupGcResult applied = await connection.Backups.CollectGarbageAsync();
Things worth knowing before you use it:
- Backups are node-wide, not per-database. Every database on a CamusDB server shares one storage node, so a backup captures all of them at once. Nothing here is scoped to the connection's
Database. - The server must opt in. Backups are off until
kahuna.backup_diris set in the server'sconfig.yml; until then every call fails withBackupNotConfigured(HTTP 503). - Superuser only. With authentication enabled, every call needs a superuser bearer token — authenticate the connection with a superuser account. With authentication disabled the server restricts this surface to loopback callers, so a remote client is refused rather than allowed to take an anonymous node-wide backup.
- An incremental can silently become a full. If the parent has aged past the retention floor, the server takes a full backup instead. The call still succeeds; check
WasSubstitutedandSubstitutionReasonif you want that in an operator log. TakeCoordinatedBackupAsyncmust reach the coordinator. Another node refuses withBackupNotCoordinator. PinBackupEndpoint=to the coordinator whenEndpoint=is a multi-node pool.GetChainAsyncis the validating read. A chain that could not be assembled is rejected here rather than at restore time, so it doubles as a "would this backup actually restore?" check.BackupTimeoutdefaults to 300s, not the 10s statementTimeout— a full backup copies a whole node's base image.- REST only. These endpoints have no SQL form and no gRPC service, so a
Protocol=grpcconnection must setBackupEndpoint=to the server's HTTP endpoint; otherwise the call is refused with a message naming the key.
Restore is not exposed here. POST /v1/restore rebuilds into a fresh data root, after which the operator stops the server and boots a new one against it — there is no hot in-place restore, so it is not an operation an application can drive to completion. It remains an operator runbook step; see the server's backups-and-point-in-time-recovery guide.
Node stats
connection.GetNodeStatsAsync() returns the disk, CPU and memory of one node. It works over both Protocol=rest (GET /v1/admin/stats) and Protocol=grpc (GetNodeStats).
CamusNodeStats stats = await connection.GetNodeStatsAsync(); // the next endpoint in rotation
CamusDiskVolumeStats data = stats.DataVolume!; // the volume that holds data_dir
Console.WriteLine($"{data.AvailableBytes} bytes free of {data.TotalBytes}");
Console.WriteLine($"writes refused (below min_free_disk_bytes): {data.BelowWatermark}");
foreach (string endpoint in connection.Endpoints) // one call per node of a cluster
{
CamusNodeStats node = await connection.GetNodeStatsAsync(endpoint);
Console.WriteLine($"{node.Address}: {node.Cpu.ProcessCpuPercent:F1}% CPU, {node.Memory.HostAvailableBytes} bytes of memory free");
}
Things worth knowing before you use it:
- Each node answers for itself.
Addresssays which configured endpoint answered.GetNodeStatsAsync(endpoint)accepts only an endpoint fromEndpoints, so the connection's credentials never go to another address. -1means unknown. A value the node could not read is-1, andErrorsnames the field and the reason. One failed probe never fails the call.BelowWatermarkis the write gate's own answer. While it istrue, writes on that node fail withCADB0536.MONITOR, not superuser. With authentication enabled, the account needs the superuser attribute or theMONITORprivilege:GRANT MONITOR ON *.* TO monitoring;. Other accounts getCADB0517.- Cheap enough to poll. The server answers from caches and a background sampler, so one call every minute from several monitors costs nothing measurable. See the server's
node-statsguide for every field.
Usage
Database Management
CamusDB requires databases to be explicitly created before use. Call CreateDatabaseAsync once during application startup or provisioning:
// Create the database (no-op if it already exists)
await connection.CreateDatabaseAsync(ifNotExists: true);
To drop a database:
await connection.DropDatabaseAsync();
Both methods operate on the database named in the connection string. An explicit name can also be passed:
await connection.CreateDatabaseAsync("otherdb", ifNotExists: true);
await connection.DropDatabaseAsync("otherdb");
Ping
await using CamusCommand ping = connection.CreatePingCommand();
int result = await ping.ExecuteNonQueryAsync();
Execute DDL
await using CamusCommand command = connection.CreateCamusCommand("""
CREATE TABLE robots (
id OID PRIMARY KEY NOT NULL,
name STRING NOT NULL,
type STRING,
year INT64,
price FLOAT64,
enabled BOOL
)
""");
bool created = await command.ExecuteDDLAsync();
CamusDB supports CHECK and named NOT NULL constraints, addable and droppable via ALTER TABLE.
A CHECK is a deterministic single-row predicate evaluated on every insert and update; a row is
rejected only when the predicate is FALSE (a NULL operand yields UNKNOWN, which passes). A
violation throws a CamusException with code CADB0303; a named NOT NULL violation throws
CADB0301. Both kinds are dropped by name with DROP CONSTRAINT:
await connection.CreateCamusCommand("""
CREATE TABLE products (
id OID PRIMARY KEY NOT NULL,
name STRING CONSTRAINT products_name_required NOT NULL,
price INT64 CHECK (price > 0),
discount INT64,
CONSTRAINT valid_discount CHECK (price >= discount)
)
""").ExecuteDDLAsync();
// Add / drop after the fact (ADD CHECK scans existing rows and rejects if any violate it)
await connection.CreateCamusCommand(
"ALTER TABLE products ADD CONSTRAINT positive_price CHECK (price > 0)").ExecuteDDLAsync();
await connection.CreateCamusCommand(
"ALTER TABLE products DROP CONSTRAINT positive_price").ExecuteDDLAsync();
Data Types
CamusDB columns are declared with these SQL types; each maps to a ColumnType on the wire and a CLR type the reader/parameters understand:
| SQL DDL type | ColumnType |
CLR type(s) | Notes |
|---|---|---|---|
OID (alias OBJECT_ID) |
Id |
string, CamusObjectIdValue |
Native identifier; shares string key encoding. A Guid value is always sent as a UUID. |
UUID (alias GUID) |
Uuid |
Guid |
Native 128-bit UUID; indexable, ordered by big-endian byte order. gen_uuid_v4() / gen_uuid_v7() generate values server-side. |
INT64 (aliases INT, INTEGER) |
Integer64 |
long, int, short, byte |
64-bit signed. |
FLOAT64 |
Float64 |
double |
IEEE-754 double. |
NUMERIC (alias DECIMAL) |
Numeric |
decimal, string |
Exact decimal: 38 digits, 9 after the point. See NUMERIC. |
FLOAT32 (alias REAL) |
Float32 |
float |
Stored at single precision. |
BOOL (alias BOOLEAN) |
Bool |
bool |
|
STRING / STRING(N) |
String |
string |
N bounds the length (UTF-16 code units); over-length is rejected. |
DATE |
Date |
DateOnly, DateTime |
Calendar date; stored as UTC ticks at midnight. |
DATETIME (alias TIMESTAMP) |
DateTime |
DateTime, DateTimeOffset |
Instant; normalized to UTC and read back as DateTimeKind.Utc. |
BYTES (alias BLOB) |
Bytes |
byte[] |
base64 over JSON, 0x-hex in SQL literals. Default max 10 MB. |
ARRAY(T) |
Array |
any IEnumerable of T |
Homogeneous scalar list; not indexable, no inline SQL literal. |
NUMERIC
NUMERIC (alias DECIMAL) is an exact decimal with precision 38 and scale 9, as Spanner's NUMERIC. Its
range is -99999999999999999999999999999.999999999 to 99999999999999999999999999999.999999999. The server
refuses NUMERIC(P, S), because the precision and the scale are fixed. A NUMERIC value travels as decimal
text on REST and on gRPC, so no digit goes through a double.
- A
decimalparameter binds asNUMERICwith every digit, also with noColumnTypeset and withDbType.Decimal. The server rounds more than 9 fraction digits half away from zero. A value past the range fails withCADB0417. - A
stringparameter typedColumnType.Numericbinds its text as it is. Use it for a value that adecimalcannot hold. Along, anintor a finitedoubletypedColumnType.Numericalso binds. GetDecimal,GetValueandGetFieldValue<decimal>read a NUMERIC column as adecimal. Adecimalholds 28 or 29 significant digits, so a wider value throwsInvalidCastExceptionand is never rounded.GetStringgives the exact canonical text of every value.GetDoublerounds to the nearest double.- An array of
decimalstill binds asARRAY(FLOAT64), because the server has noARRAY(NUMERIC).
insert.Parameters.Add("price", ColumnType.Numeric, 19.99m);
insert.Parameters.Add("total", ColumnType.Numeric, "12345678901234567890.123456789");
decimal price = reader.GetDecimal(0);
string exact = reader.GetString(1);
Before this version a
decimalparameter was sent as aFLOAT64. It now binds asNUMERIC, which needs a server that supports the type. Into aFLOAT64column the server converts a NUMERIC value to a double, and a comparison of NUMERIC with FLOAT64 compares as doubles.
await using CamusCommand command = connection.CreateCamusCommand("""
CREATE TABLE events (
id OID PRIMARY KEY NOT NULL,
ref UUID DEFAULT gen_uuid_v7(),
name STRING(64),
payload BYTES,
score FLOAT32,
happened DATETIME,
day DATE,
tags ARRAY(INT64)
)
""");
await command.ExecuteDDLAsync();
Dates/datetimes are normalized to UTC before being sent. Arrays carry a scalar element type, inferred from the values or set explicitly (required for an empty array):
await using CamusCommand insert = connection.CreateInsertCommand("events");
insert.Parameters.Add("id", ColumnType.Id, CamusObjectIdGenerator.Generate());
insert.Parameters.Add("ref", ColumnType.Uuid, Guid.NewGuid());
insert.Parameters.Add("name", ColumnType.String, "launch");
insert.Parameters.Add("payload", ColumnType.Bytes, new byte[] { 0xDE, 0xAD });
insert.Parameters.Add("score", ColumnType.Float32, 9.5f);
insert.Parameters.Add("happened", ColumnType.DateTime, DateTime.UtcNow);
insert.Parameters.Add("day", ColumnType.Date, new DateOnly(2026, 5, 1));
insert.Parameters.Add("tags", ColumnType.Integer64, new long[] { 1, 2, 3 }, isArray: true);
await insert.ExecuteNonQueryAsync();
Reading them back uses the typed CamusDataReader accessors:
Guid reference = reader.GetGuid(reader.GetOrdinal("ref"));
byte[] payload = reader.GetFieldValue<byte[]>(reader.GetOrdinal("payload"));
float score = reader.GetFloat(reader.GetOrdinal("score"));
DateTime happened = reader.GetDateTime(reader.GetOrdinal("happened"));
DateOnly day = reader.GetFieldValue<DateOnly>(reader.GetOrdinal("day"));
object?[] tags = (object?[])reader.GetValue(reader.GetOrdinal("tags"));
Both transports give the same values. On REST, an array element is a plain JSON value, so the client types
it from the elementType that the server sends with each ARRAY(T) column of a result. An ARRAY(DATE)
element is then a Date cell (a DateTime from GetValue), an ARRAY(BYTES) element a Bytes cell, an
ARRAY(UUID) element a Uuid cell, and a 0 or -0 in an ARRAY(FLOAT64) a Float64 cell. A server
older than the field sends no elementType. The elements are then typed from their JSON tokens, as
before: a date or a whole float comes back as Integer64, a byte string as String and a UUID as null.
A FLOAT32 value, scalar or array element, reads back as the stored single-precision value, which
GetDouble widens exactly, on both transports. A -0.0 parameter or array element is sent as negative
zero, and a negative zero in a result keeps its sign.
Large values
A server with large-value storage can store a large string, bytes or ARRAY(T) value compressed (LZ4), or under its own key outside the row. The form never changes a query result or the wire format, so reads and parameters work as before. It changes the I/O a query does and the size of each stored version: a query that does not name a column stored out of line never fetches it, and an update of another column does not rewrite it.
Each such column has a storage strategy. The names follow PostgreSQL:
| Strategy | Compresses | Moves out of the row | Use it for |
|---|---|---|---|
EXTENDED (default) |
When it pays | At or above large_value_threshold_bytes (2048) |
Text, JSON, documents |
MAIN |
When it pays | Never | Compressible values that a typical query reads |
EXTERNAL |
Never | At or above the threshold | Images, archives and other large values that do not compress and that queries often skip |
PLAIN |
Never | Never | Embeddings that a KNN query reads on every row |
Set the strategy in the column definition, and change it later with SET STORAGE. CamusColumnStorage and its ToSql() give the SQL keyword when you compose DDL:
await connection.CreateCamusCommand("""
CREATE TABLE docs (
id OID PRIMARY KEY NOT NULL,
title STRING,
body STRING,
thumbnail BYTES STORAGE EXTERNAL,
embedding BYTES(3072) STORAGE PLAIN
)
""").ExecuteDDLAsync();
await connection.CreateCamusCommand(
$"ALTER TABLE docs ALTER COLUMN thumbnail SET STORAGE {CamusColumnStorage.Plain.ToSql()}").ExecuteDDLAsync();
SET STORAGE changes the form of future writes only, and returns at once. To convert the rows that already exist, run REWRITE STORAGE. REWRITE STORAGE INLINE stores every value inside its row and uncompressed, which is the form a server without large-value storage can read. Run it on every table before such a downgrade:
await using CamusCommand rewrite = connection.CreateCamusCommand("ALTER TABLE docs REWRITE STORAGE");
rewrite.CommandTimeout = 3600; // the time is proportional to the table
await rewrite.ExecuteDDLAsync();
The rewrite runs in its own bounded transactions on the server, not in the caller's transaction, so a ROLLBACK does not undo it. It is idempotent and resumable: when a run stops, a second run continues after the last committed batch. It never overwrites a user write, but a concurrent write to a row in a committing batch can fail with the retryable CADB0502.
Keep two costs in mind:
- Embeddings. A 768-dimension float32 embedding is 3072 bytes, which is above the default threshold. Under
EXTENDED, each embedding moves out of its row and a KNN query pays one more batched fetch per scanned batch. Declare an embedding column that a KNN query scans withSTORAGE PLAIN. - Mutation budget. Each out-of-line value is one more key, so an insert or a delete of a row with
kout-of-line values costskmore mutations. A bulk insert of rows with several large columns fits fewer rows in one transaction.
| Code | Name | Meaning |
|---|---|---|
CADB0414 |
ColumnStorageNotApplicable |
A storage strategy on a column type with no variable-length value. |
CADB0540 |
LargeValueCorrupt |
A stored value failed to decompress, or did not match its checksum. |
CADB0541 |
LargeValueNotResolved |
A server defect: a read path decoded a value it did not fetch. Report it. |
A read without a snapshot that keeps seeing a row change under it fails with the retryable CADB0504 (TransactionMustRetry), which SerializableRetryHelper and EnableRetryOnFailure already retry.
Identifiers and inline literals
The driver never puts a parameter value into SQL text: values travel as typed wire objects and the server binds them. A few places do compose text — the admin DDL the gRPC transport builds (CREATE DATABASE, DROP DATABASE, SHOW BRANCHES), the EF Core migration and EnsureCreated DDL, and the cache hints — and those follow two rules that come from CamusDB's lexer:
- An identifier is delimited with backticks, and a name carrying a backtick is refused. The server trims the delimiters rather than decoding a doubled backtick, so doubling would neutralize nothing.
- A string literal doubles its quotes, and a value with a backslash immediately before a quote, or a trailing backslash, is refused. The lexer reads a backslash and the character after it as one unit, so neither shape can be spelled: the value's own text would end the literal and the rest would parse as SQL.
Both raise ArgumentException naming the offending value. They apply to identifiers and seed data that an application derives at runtime — tenant provisioning, a dynamic schema, a data-driven migration — which is the case where a name is not something the developer wrote.
Insert Rows
using CamusDB.Core.Util.ObjectIds;
await using CamusCommand insert = connection.CreateInsertCommand("robots");
insert.Parameters.Add("id", ColumnType.Id, CamusObjectIdGenerator.Generate());
insert.Parameters.Add("name", ColumnType.String, "T-800");
insert.Parameters.Add("type", ColumnType.String, "cyborg");
insert.Parameters.Add("year", ColumnType.Integer64, 1984);
insert.Parameters.Add("price", ColumnType.Float64, 10.0);
insert.Parameters.Add("enabled", ColumnType.Bool, true);
int insertedRows = await insert.ExecuteNonQueryAsync();
You can also execute parameterized SQL:
const string sql = """
INSERT INTO robots (id, name, year, type, price, enabled)
VALUES (GEN_ID(), @name, @year, @type, @price, @enabled)
""";
await using CamusCommand insert = connection.CreateCamusCommand(sql);
insert.Parameters.Add("@name", ColumnType.String, "R2-D2");
insert.Parameters.Add("@year", ColumnType.Integer64, 1977);
insert.Parameters.Add("@type", ColumnType.String, "mechanical");
insert.Parameters.Add("@price", ColumnType.Float64, 25.5);
insert.Parameters.Add("@enabled", ColumnType.Bool, true);
int insertedRows = await insert.ExecuteNonQueryAsync();
CreateInsertCommand names a table and binds its columns, so no SQL is composed. It runs through the connection's transport like every other statement — carrying the bearer token, replaying a rejected one, and marking an unreachable endpoint — and works on both Protocol=rest and Protocol=grpc.
See Data Types above for inserting bytes, float32, date, datetime and array(T) values.
RETURNING
An INSERT, UPDATE or DELETE can send back values from the rows that it wrote. The values are the stored values, so they include column defaults, sequence and identity values, and coerced types. Use ExecuteReaderAsync to read them:
await using CamusCommand insert = connection.CreateCamusCommand("""
INSERT INTO robots (id, name, year) VALUES (GEN_ID(), @name, @year)
RETURNING id, name, year * 2 AS doubled
""");
insert.Parameters.Add("@name", ColumnType.String, "R2-D2");
insert.Parameters.Add("@year", ColumnType.Integer64, 1977);
await using CamusDataReader reader = await insert.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
string id = reader.GetString(0);
long doubled = reader.GetInt64(2);
}
int insertedRows = reader.RecordsAffected; // one row for each inserted row
An UPDATE returns the new row, after the SET clause. A DELETE returns the deleted row. In both statements, the RETURNING list comes after the LIMIT, if there is one:
await using CamusCommand update = connection.CreateCamusCommand(
"UPDATE robots SET year = year + 1 WHERE year < @year RETURNING id, year");
update.Parameters.Add("@year", ColumnType.Integer64, 1980);
await using CamusDataReader reader = await update.ExecuteReaderAsync();
await using CamusCommand delete = connection.CreateCamusCommand(
"DELETE FROM robots WHERE year > 2000 LIMIT 10 RETURNING *");
ExecuteScalarAsyncreturns the first column of the first row, for example a generated key.ExecuteNonQueryAsyncreturns the count only. The driver tells the server not to send the rows. The server still checks the RETURNING list and theSELECTprivilege.- The RETURNING list accepts
*, columns, expressions, aliases and parameters. It does not accept aggregates, subqueries or sequence functions. - A
RETURNINGlist needs theSELECTprivilege on the table, in addition toINSERT,UPDATEorDELETE. - An
INSERTreturns its rows in insert order. AnUPDATEor aDELETEreturns its rows in no defined order. - An
UPDATEcannot return the old values. The server has noOLDandNEWqualifiers. RETURNINGis a reserved word. A column or a table calledreturningneeds backticks.
ExecuteReaderAsync and ExecuteScalarAsync receive all rows in one reply. On gRPC, the server refuses a reply that is larger than 4 MiB with CADB0550, before the commit, so the statement stores nothing. For a large result, such as an INSERT … SELECT … RETURNING or an UPDATE of many rows, use ExecuteStreamReaderAsync. It sends the statement to the query endpoint, which streams the rows. On that path, RecordsAffected is -1, so count the rows as you read them.
See docs/returning.md in the server repository for the full rules.
Select Rows
await using CamusCommand select = connection.CreateSelectCommand(
"SELECT * FROM robots WHERE year = @year");
select.Parameters.Add("@year", ColumnType.Integer64, 1977);
CamusDataReader reader = await select.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
string id = reader.GetString(0);
string name = reader.GetString(1);
string type = reader.GetString(2);
long year = reader.GetInt64(3);
}
Streaming large results
ExecuteReaderAsync buffers the whole result set before returning. For a large SELECT, call
ExecuteStreamReaderAsync instead: it uses the server's streaming endpoint
(/execute-sql-query-stream, newline-delimited JSON) and pulls rows from the network as you advance
the reader, so a multi-thousand-row / multi-MB result never fully materializes client-side. The API is
identical — same CamusDataReader, same accessors — only the delivery differs:
await using CamusCommand select = connection.CreateSelectCommand(
"SELECT * FROM robots WHERE year > @year");
select.Parameters.Add("@year", ColumnType.Integer64, 1900);
// Rows arrive incrementally; dispose the reader to release the underlying HTTP response.
await using CamusDataReader reader = await select.ExecuteStreamReaderAsync();
while (await reader.ReadAsync())
{
string name = reader.GetString(1);
long year = reader.GetInt64(3);
}
The schema (field count, names, types) is available before the first row, so FieldCount / GetName
/ HasRows work on an empty result too.
Trade-offs to know:
- No transparent serializable retry. Because rows can reach you before the autocommit transaction
commits, a serializable conflict that surfaces mid-stream can't be replayed — it is reported in-band
as a
CamusExceptionthrown from theReadAsyncthat reaches it. When you need automatic retry, use the bufferedExecuteReaderAsync, or drive an explicit transaction and retry yourself. - No cache metadata. The streaming trailer carries no
{cache=…}resolution, soCamusDataReader.CacheMetadatais null on this path. - gRPC. On the
Protocol=grpctransport,ExecuteStreamReaderAsynccurrently buffers server-side and replays through the same reader (the streaming endpoint is REST-only); the call still works, it is just not row-incremental. - DML. Calling it on an
INSERT/UPDATE/DELETEfalls back to the buffered affected-row reader.
Query Result Cache
CamusDB has an opt-in, per-node, in-memory cache of fully materialized SELECT results. A query
joins it with an inline {cache=name} hint placed right after a table reference; an identical later
query (same shape, same bound values, same schema) can then be served from memory. The cache only
serves single-table, autocommit reads — hints on joins, on reads through a view, or inside explicit
transactions are inert, and the response says which of those it was (BypassReason).
Add the hint directly in your SQL, optionally with a per-entry TTL or strict (validate every hit
against live storage). CamusCacheHint.Build(...) assembles the fragment for you:
string hint = CamusCacheHint.Build("recent_orders", ttl: TimeSpan.FromSeconds(30));
await using CamusCommand select = connection.CreateSelectCommand(
$"SELECT id, total FROM orders {hint} WHERE status = @status ORDER BY total DESC LIMIT 20");
select.Parameters.Add("@status", ColumnType.Integer64, 1);
CamusDataReader reader = await select.ExecuteReaderAsync();
// Inspect how the server resolved the cache for this query.
CamusCacheMetadata? cache = reader.CacheMetadata; // also on select.LastCacheMetadata
if (cache is not null)
Console.WriteLine($"{cache.Status} ({cache.Name}), age={cache.AgeMs}ms");
CamusCacheMetadata exposes Status (Hit, Miss, Bypass, StaleRevalidated,
EvictedBeforePublish), BypassReason, Name, CachedAtHlc, and AgeMs. It is null for any
query that carried no hint, so ordinary queries are unaffected.
Both transports report it. Over gRPC the verdict rides the query terminator rather than a response
envelope — the server only knows it once the result set has been produced — so it is available on the
buffered ExecuteReaderAsync path, not on ExecuteStreamReaderAsync (see the streaming caveats above).
A cache family name is emitted into the SQL as text, where nothing can be escaped, so it is held to a safe character set: letters, digits and _ - . :, at most 128 characters. Anything else raises ArgumentException rather than being composed into the statement — which matters when the name comes from request-derived text, such as a tenant name keying a cache family.
Evict entries manually — both are scoped to the current database:
await connection.EvictCacheAsync("recent_orders"); // one family
await connection.EvictAllCacheAsync(); // every entry for this database
Transactions
CamusTransaction transaction = await connection.BeginTransactionAsync();
await using CamusCommand insert = connection.CreateInsertCommand("robots");
insert.Transaction = transaction;
insert.Parameters.Add("id", ColumnType.Id, CamusObjectIdGenerator.Generate());
insert.Parameters.Add("name", ColumnType.String, "HAL 9000");
insert.Parameters.Add("type", ColumnType.String, "electronic");
insert.Parameters.Add("year", ColumnType.Integer64, 1968);
insert.Parameters.Add("price", ColumnType.Float64, 42.0);
insert.Parameters.Add("enabled", ColumnType.Bool, true);
await insert.ExecuteNonQueryAsync();
await transaction.CommitAsync();
Use await transaction.RollbackAsync() to roll back instead.
Pipelined transactions
A transaction that awaits each statement pays one round trip per statement: six for a bank transfer
(BEGIN, two reads, two writes, COMMIT). At high concurrency those round trips, not the statements,
set its latency. A CamusPipeline sends the statements whose inputs are known together, as one stream
message, and completes each statement's task from its own answer. BEGIN rides the first send when the
transaction was begun with DeferBegin = true (or with learned routing on, which defers it already), and
CommitAsync adds a commit that the server performs only if every statement of the transaction succeeded.
CamusTransaction tx = await connection.BeginTransactionAsync(new CamusTransactionOptions
{
Locking = CamusLocking.Optimistic,
DeferBegin = true, // BEGIN travels inside the first send
});
CamusPipeline pipeline = tx.CreatePipeline();
// Exchange 1: [BEGIN, SELECT a, SELECT b]
CamusCommand readA = connection.CreateSelectCommand("SELECT balance FROM accounts WHERE id = @id");
readA.Parameters.Add("@id", ColumnType.Id, a);
CamusCommand readB = connection.CreateSelectCommand("SELECT balance FROM accounts WHERE id = @id");
readB.Parameters.Add("@id", ColumnType.Id, b);
Task<CamusDataReader> balanceA = pipeline.QueueReader(readA);
Task<CamusDataReader> balanceB = pipeline.QueueReader(readB);
await pipeline.SendAsync();
long fromBalance = await ReadBalance(await balanceA);
long toBalance = await ReadBalance(await balanceB);
// Exchange 2: [UPDATE a, UPDATE b, COMMIT] — the commit happens only if both updates succeeded.
CamusCommand debit = connection.CreateCamusCommand("UPDATE accounts SET balance = @v WHERE id = @id");
debit.Parameters.Add("@v", ColumnType.Integer64, fromBalance - amount);
debit.Parameters.Add("@id", ColumnType.Id, a);
CamusCommand credit = connection.CreateCamusCommand("UPDATE accounts SET balance = @v WHERE id = @id");
credit.Parameters.Add("@v", ColumnType.Integer64, toBalance + amount);
credit.Parameters.Add("@id", ColumnType.Id, b);
Task<int> debited = pipeline.QueueNonQuery(debit);
Task<int> credited = pipeline.QueueNonQuery(credit);
await pipeline.CommitAsync();
Console.WriteLine($"{pipeline.Exchanges} round trips"); // 2 over gRPC
What to expect:
- Results. Every queued task completes with its own result or its own
CamusException, exactly as a lone execution would.SendAsyncandCommitAsyncalso throw the first failure, so a caller that awaits only the send still learns of it. - Failure. The statements after a failed one in the same exchange still get an answer; the
transaction has ended, so they fail too, and
CommitAsyncthrows the first failure instead of committing. The server has rolled the transaction back;RollbackAsyncis then a harmless no-op. Replay the whole transaction, fromBEGIN, to retry — the rule for any failed statement. - Order. Queued statements run in queue order, after every statement of the transaction sent before them. A pipeline can be sent more than once on one transaction, and an ordinary command can run between sends. One send at a time per pipeline.
- Exchanges.
pipeline.Exchangesreports the round trips actually used. It is one per send over gRPC to a server that announces frame contract version 2 or higher. Over REST, withRequestFrames=false, or against an older server, the client runs the same statements one exchange per step with the same results, and the count says so. Nothing is ever sent that an older server could misread. - What cannot be queued. DDL (it does not run inside the transaction),
TRUNCATE, the typedCamusInsertCommand, and a command bound to another transaction. These throw at queue time or at send; the pipeline is still usable.
Isolation level, mode, and optimistic locking
A transaction has three independent concurrency knobs, passed as CamusTransactionOptions:
| Knob | Values | Default |
|---|---|---|
IsolationLevel |
Serializable, ReadCommitted |
server default (Serializable) |
Mode |
ReadWrite, ReadOnly |
ReadWrite |
Locking |
Pessimistic, Optimistic |
server default (Pessimistic) |
Optimistic locking skips the explicit write lock: writes are staged and the rows read are
version-recorded, and write–write / read–write conflicts are validated at commit, where the loser
throws a CamusException you replay (see Serializable Isolation & Retries). It trades write-time
blocking for commit-time validation — good for low-contention, read-mostly workloads.
Optimistic is orthogonal to the isolation level, and it does not mean "lock-free" at both levels:
- Read Committed + Optimistic is fully lock-free, but phantom-vulnerable — its validation covers only the specific rows it read, not predicates.
- Serializable + Optimistic is a hybrid: it still takes the shared range/point (predicate) locks, so it stays phantom-free, while validating its write/read set at commit.
Phantom protection is gated on the isolation level (Serializable), not on the locking mode — so use
Serializable (pessimistic or optimistic) when you need it; either locking mode under ReadCommitted
is phantom-vulnerable.
// Optimistic transaction
CamusTransaction tx = await connection.BeginTransactionAsync(CamusTransactionOptions.Optimistic);
// Or select knobs explicitly
CamusTransaction rc = await connection.BeginTransactionAsync(new CamusTransactionOptions
{
IsolationLevel = CamusIsolationLevel.ReadCommitted,
Locking = CamusLocking.Optimistic,
});
// A lock-free consistent snapshot (Serializable read-only), resumable across requests
CamusTransaction snap = await connection.BeginTransactionAsync(CamusTransactionOptions.Snapshot);
Any knob left unset defers to the connection-string defaults, then the server default. Set connection-wide
defaults (which also apply to autocommit statements) via the IsolationLevel=, TransactionMode=, and
Locking= connection-string keys (values are case-insensitive):
Endpoint=http://localhost:5095;Database=test;Locking=Optimistic;IsolationLevel=ReadCommitted;TransactionMode=ReadWrite
Precedence: per-transaction options › connection-string defaults › server default. The equivalent
SQL passthrough — SET TRANSACTION LOCKING { PESSIMISTIC | OPTIMISTIC } and
SET TRANSACTION ISOLATION LEVEL …, issued as the first statement of an explicit transaction — also works.
Serializable Isolation & Retries
Serializable is the default isolation level in CamusDB. When two serializable transactions conflict, one is aborted immediately and must be replayed from BEGIN — retrying a single statement is not safe.
Three error codes indicate a transient conflict that a full retry can resolve:
| Code | Name | When raised |
|---|---|---|
CADB0502 |
TransactionConflict |
Lock conflict; server aborted at lock-acquire time |
CADB0504 |
TransactionMustRetry |
Routing retry budget exhausted; no data written |
CADB0505 |
TransactionLifetimeExceeded |
Transaction held open past the server lifetime limit |
A fourth code, CADB0509 (TransactionFinalizeUnresolved), is a different kind of retry and is
not replay-retryable: it means a COMMIT/ROLLBACK outcome is not yet resolved, and the fix is to
re-issue the same finalize on the same transaction — never to replay the operation (the commit may
already have durably applied). CamusTransaction.CommitAsync/RollbackAsync handle this transparently
(bounded re-issue with back-off), and SerializableRetryHelper.IsRetryable returns false for it so it
never enters a replay-from-BEGIN loop.
Use SerializableRetryHelper.IsRetryable(ex) to test any exception, and SerializableRetryHelper.ExecuteAutocommitAsync for bounded automatic retry of single-statement (autocommit) operations:
await SerializableRetryHelper.ExecuteAutocommitAsync(async ct =>
{
CamusTransaction tx = await connection.BeginTransactionAsync(ct);
try
{
await using CamusCommand cmd = connection.CreateCamusCommand(
"UPDATE robots SET price = @price WHERE name = @name");
cmd.Transaction = tx;
cmd.Parameters.Add("@price", ColumnType.Float64, 99.0);
cmd.Parameters.Add("@name", ColumnType.String, "T-800");
await cmd.ExecuteNonQueryAsync(ct);
await tx.CommitAsync(ct);
}
catch
{
await tx.RollbackAsync(ct);
throw;
}
}, maxAttempts: 5, cancellationToken);
Back-off schedule: min(20 ms × 2^attempt, 400 ms) ± 25 % jitter. Any non-retryable exception propagates immediately.
For explicit multi-statement transactions, own the retry loop yourself so you can replay every read and write from scratch:
const int MaxAttempts = 5;
int attempt = 0;
while (true)
{
CamusTransaction tx = await connection.BeginTransactionAsync();
try
{
// re-execute ALL reads and writes on every attempt
long balance = await ReadBalance(tx, accountId);
if (balance < amount)
throw new InvalidOperationException("Insufficient funds");
await Debit(tx, accountId, balance - amount);
await tx.CommitAsync();
break;
}
catch (CamusException ex) when (SerializableRetryHelper.IsRetryable(ex))
{
await tx.RollbackAsync();
if (++attempt >= MaxAttempts)
throw;
await Task.Delay(20 * (1 << attempt));
}
catch
{
await tx.RollbackAsync();
throw;
}
}
CamusDB.EntityFrameworkCore (EF Core)
Installation
dotnet add package CamusDB.EntityFrameworkCore
Or via the Package Manager Console:
Install-Package CamusDB.EntityFrameworkCore
Configuration
Register the provider via UseCamusDB in your DbContext options:
using CamusDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseCamusDB("Endpoint=http://localhost:8082;Database=mydb;Timeout=30")
.Options;
Or configure it inside OnConfiguring:
public class AppDbContext : DbContext
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.UseCamusDB("Endpoint=http://localhost:8082;Database=mydb");
}
The same connection string keys are supported as in CamusDB.Client — see the connection string reference above. This includes Protocol=grpc, so the EF Core provider can run over gRPC by pointing Endpoint at the gRPC port; see Transport.
It also includes User / Password (see Authentication), which is all the provider needs against an authenticated server:
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseCamusDB("Endpoint=http://localhost:8082;Database=mydb;User=app;Password=app-secret")
.Options;
EF opens a fresh connection per operation, but the token is cached per credential set rather than per connection, so the whole context still performs one login. Every table an EF query touches — including joins and subqueries — must be covered by a grant, or the query fails with CADB0517.
Prepared statements
EF Core queries and SaveChanges statements are prepared automatically once the same SQL has run twice — no provider configuration, and no code change. EF generates deterministic, parameterized SQL per query shape, which is exactly the pattern that repays a registration. See Prepared Statements for the mechanism, the MaxAutoPrepare / AutoPrepareMinUsages knobs, and why an IN (…) expansion is the one shape that does not benefit.
Retry on failure
Call EnableRetryOnFailure on the CamusDBDbContextOptionsBuilder to let EF Core automatically retry SaveChangesAsync (and query execution) when a transient serialization conflict is detected. A retry fires on the three retryable CamusDB error codes (CADB0502, CADB0504, CADB0505) as well as the transient contention markers the server reports in the message (MustRetry, AlreadyLocked, commit returned Aborted) — all other exceptions propagate immediately.
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseCamusDB("Endpoint=http://localhost:8082;Database=mydb", o =>
{
o.EnableRetryOnFailure();
})
.Options;
Default parameters:
| Parameter | Default | Description |
|---|---|---|
maxRetryCount |
15 |
Maximum number of retry attempts |
maxRetryDelay |
1 s |
Upper bound on the delay between retries |
retryDeadline |
5 s |
Wall-clock deadline from first failure; no further retries after this |
medianFirstRetryDelay |
30 ms |
Median delay before the first retry |
Override any parameter explicitly:
o.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromMilliseconds(500),
retryDeadline: TimeSpan.FromSeconds(3),
medianFirstRetryDelay: TimeSpan.FromMilliseconds(20));
Optimistic locking & isolation defaults
Make every transaction and autocommit statement on a context use optimistic locking (no locks; conflicts
detected at commit). Pair it with EnableRetryOnFailure() so EF replays a losing unit of work:
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseCamusDB("Endpoint=http://localhost:8082;Database=mydb", o =>
{
o.UseOptimisticLocking(); // or o.UsePessimisticLocking()
o.EnableRetryOnFailure();
})
.Options;
For finer control, UseTransactionDefaults(new CamusTransactionOptions { … }) sets the default isolation
level, mode, and locking together. Any knob left unset defers to the connection-string / server default,
and a per-transaction selection (e.g. Database.BeginTransaction(IsolationLevel.ReadCommitted)) overrides
these. IsolationLevel.ReadCommitted, Serializable, and Snapshot all map through to CamusDB.
EF Core's execution strategy retries the entire unit of work — never only the failing statement. If you manage transactions manually with
BeginTransactionAsync/CommitTransactionAsync, useSerializableRetryHelperinCamusDB.Clientinstead.
Sharing an existing connection
Pass an open CamusConnection when you want to share a connection or attach to an externally managed transaction:
CamusConnection connection = await GetOpenConnectionAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseCamusDB(connection)
.Options;
await using var ctx = new AppDbContext(options);
await ctx.Database.BeginTransactionAsync();
// ... SaveChangesAsync, then CommitTransactionAsync
The DbContext does not take ownership of the supplied connection and will not close or dispose it.
Defining a Model
Use standard EF Core data annotations or the fluent API. Map ID columns to the "id" store type and call ValueGeneratedOnAdd() so the provider generates a client-side ObjectId automatically:
public class Robot
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public string Type { get; set; } = "";
public int Year { get; set; }
public double Price { get; set; }
public bool Enabled { get; set; }
}
public class AppDbContext : DbContext
{
public DbSet<Robot> Robots => Set<Robot>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Robot>(b =>
{
b.ToTable("robots");
b.HasKey(e => e.Id);
b.Property(e => e.Id)
.HasColumnType("id")
.ValueGeneratedOnAdd(); // client-side ObjectId generation
b.Property(e => e.Name).HasColumnType("string");
b.Property(e => e.Type).HasColumnType("string");
b.Property(e => e.Year).HasColumnType("int64");
b.Property(e => e.Price).HasColumnType("float64");
b.Property(e => e.Enabled).HasColumnType("bool");
});
}
}
Add a CHECK constraint with ToTable(t => t.HasCheckConstraint(...)). It is enforced server-side on
every insert and update, and rejected rows surface as a DbUpdateException whose inner CamusException
carries code CADB0303:
b.ToTable("robots", t => t.HasCheckConstraint("ck_robots_price", "price >= 0"));
CamusDB Type Mapping
| CLR type | CamusDB store type | DDL type |
|---|---|---|
string (ID / PK) |
id or oid |
OID |
Guid |
uuid (alias guid) |
UUID |
string |
string |
STRING |
string + HasMaxLength(n) |
string |
STRING(n) |
bool |
bool |
BOOL |
short, int, long |
int64 |
INT64 |
float |
float32 |
FLOAT32 |
double |
float64 |
FLOAT64 |
decimal |
numeric (alias decimal) |
NUMERIC |
byte[] |
bytes (alias blob) |
BYTES |
DateOnly |
date |
DATE |
DateTime, DateTimeOffset |
datetime (alias timestamp) |
DATETIME |
long[], string[], double[], bool[] |
array(int64/string/float64/bool) |
ARRAY(T) |
Use HasColumnType("id") (or the alias "oid") on a string property for primary key columns backed by CamusDB ObjectIds. The provider sends the value as an OID on the wire.
A Guid property maps to CamusDB's native UUID column — indexable and ordered by big-endian byte order. No HasColumnType is required. A Guid value always travels as a UUID, because a 16-byte GUID can never be a 12-byte ObjectId, so do not declare a Guid property with HasColumnType("id"): an OID column refuses its values.
A decimal property maps to the NUMERIC column. HasPrecision does not change the column, because the precision and the scale are fixed (38 and 9). A decimal constant in a query becomes the typed literal NUMERIC '9.99', so it compares exactly and an index on the column can serve it. A bare 9.99 is a FLOAT64 on the server.
Before 0.13.3 a plain Guid property defaulted to the id/OID store type. Inserts through that mapping never worked, so such a column holds only NULLs. A migration snapshot made before 0.13.3 records id for such a property, and the next migration then tries to change the column type to uuid, which CamusDB does not support. Drop the column and add it again in that migration, or keep the old type with HasColumnType("id") until then.
Dates and datetimes are stored as UTC ticks; the provider normalizes DateTime values to UTC before sending and reconstructs them as DateTimeKind.Utc. byte[] is exchanged as base64 over the JSON wire (SQL literals use 0x-hex). A string property maps to float32/bytes/date/datetime etc. either by its CLR type or by an explicit HasColumnType(...).
long[], string[], double[], and bool[] properties map to CamusDB native ARRAY(T) columns (not EF's JSON "primitive collection" — the provider registers a direct mapping that stores a real array). No HasColumnType is required; declare the property as usual:
b.Property(e => e.Tags); // long[] → ARRAY(INT64)
Arrays are not indexable and have no inline SQL literal, so an array value can only travel as a bound parameter — it cannot appear in a Where(...) predicate, as a default value, or in migration seed data.
Database and Table Lifecycle
EnsureCreatedAsync() creates the database and all tables defined in the model. Both operations are idempotent — it is safe to call on a database or tables that already exist. It creates the database only when it does not exist, so an account without superuser rights can call it on an existing database (see Migrating without superuser rights). It creates the table of each principal before the tables that reference it, and declares each relationship as a foreign key (see Relationships and Foreign Keys):
await using var ctx = new AppDbContext();
await ctx.Database.EnsureCreatedAsync();
EnsureDeletedAsync() drops the database entirely:
await ctx.Database.EnsureDeletedAsync();
Insert
await using var ctx = new AppDbContext();
ctx.Robots.Add(new Robot
{
Name = "T-800",
Type = "cyborg",
Year = 1984,
Price = 10.0,
Enabled = true
});
await ctx.SaveChangesAsync(); // Id is generated automatically
A property that the server generates is read back after the insert with INSERT … RETURNING. This applies to a property with HasDefaultValue, a property with HasDefaultValueSql and ValueGeneratedOnAdd(), and any other store-generated property that the entity does not set:
modelBuilder.Entity<Robot>(b =>
{
b.Property(e => e.CreatedAt).HasDefaultValueSql("now()");
b.Property(e => e.Serial).HasDefaultValueSql("nextval('robot_serial')").ValueGeneratedOnAdd();
});
ctx.Robots.Add(robot);
await ctx.SaveChangesAsync(); // robot.CreatedAt and robot.Serial now have the stored values
UseSequence and UseHiLo still draw the value on the client, when the entity is added. The entity then has its value before SaveChanges. Use HasDefaultValueSql("nextval('…')") when the server must draw the value at insert time. An update reads back a store-generated property that it does not write, such as a property with ValueGeneratedOnAddOrUpdate(), with UPDATE … RETURNING. The server has no ON UPDATE defaults, so the update returns the value that the row already has.
Query
await using var ctx = new AppDbContext();
// Key lookup
Robot? robot = await ctx.Robots.FindAsync(id);
// LINQ predicate
List<Robot> active = await ctx.Robots
.Where(r => r.Enabled && r.Year > 1980)
.ToListAsync();
The provider translates a broad set of LINQ shapes to server-side SQL: Where, OrderBy/ThenBy,
Skip/Take, Distinct, scalar aggregates (Count, Sum, Average, Min, Max, Any),
GroupBy + aggregate, joins (see Joins), and correlated subqueries
(Where(x => x.Items.Any(...))).
The following string members translate to CamusDB's native scalar/predicate functions and run on
the server (rather than forcing client-side evaluation):
| LINQ | CamusDB function |
|---|---|
s.StartsWith(x) / s.EndsWith(x) / s.Contains(x) |
starts_with / ends_with / contains |
s.StartsWith(x, StringComparison.OrdinalIgnoreCase) (and the …IgnoreCase variants) |
wrapped in lower(...) |
s.ToUpper() / s.ToLower() |
upper / lower |
s.Trim() / s.TrimStart() / s.TrimEnd() |
trim / ltrim / rtrim |
s.Replace(a, b) |
replace |
s.Length |
length |
The starts_with / ends_with / contains predicates take the search term as a plain argument, so
they work with a column or parameter (not just a literal) and need no LIKE wildcard escaping.
String ordering (string.Compare / CompareTo)
The comparison idiom that keyset (cursor) pagination is written with translates to a plain relational comparison:
// emits: WHERE `seq` < @seq OR (`seq` = @seq AND `code` < @code)
query.Where(e => e.Seq < cursorSeq ||
(e.Seq == cursorSeq && string.Compare(e.Code, cursorCode, StringComparison.Ordinal) < 0));
All of string.Compare(a, b), string.Compare(a, b, ignoreCase), string.Compare(a, b, StringComparison)
and a.CompareTo(b) are supported, tested against 0 with any of < <= > >= == !=; the zero may sit on
either side (0 > string.Compare(a, b) flips the operator). The …IgnoreCase forms wrap both operands in
lower(...).
Other providers emit a three-valued CASE WHEN … THEN -1 … scalar here; CamusDB's dialect has no CASE,
so the provider collapses the compare-and-test-against-zero pair into the direct comparison instead.
Ordering is the server's: String and id columns compare with StringComparison.Ordinal, so the
culture-sensitive overloads order ordinally too — the usual approximation when ordering is deferred to
the database.
Regular expressions
System.Text.RegularExpressions.Regex static calls and the EF.Functions.Regexp* helpers map onto
CamusDB's native regex operators and regexp_* scalar functions (PostgreSQL semantics):
| LINQ | CamusDB |
|---|---|
Regex.IsMatch(s, pattern) |
regexp_like(s, pattern) (the ~ operator) |
Regex.IsMatch(s, pattern, RegexOptions.IgnoreCase) |
regexp_like(s, pattern, 'i') (the ~* operator) |
Regex.Replace(s, pattern, repl) |
regexp_replace(s, pattern, repl, 'g') (replaces every match, like .NET) |
EF.Functions.RegexpLike(s, pattern[, flags]) |
regexp_like |
EF.Functions.RegexpReplace(s, pattern, repl[, flags]) |
regexp_replace (first match; pass "g" to replace all) |
EF.Functions.RegexpCount(s, pattern[, flags]) |
regexp_count |
EF.Functions.RegexpSubstr(s, pattern[, flags]) |
regexp_substr |
EF.Functions.RegexpInstr(s, pattern[, flags]) |
regexp_instr |
using System.Text.RegularExpressions;
var valid = await ctx.People
.Where(p => Regex.IsMatch(p.Name, "^[A-Z]")) // name ~ '^[A-Z]'
.Where(p => EF.Functions.RegexpLike(p.Email, "^[^@]+@[^@]+\\.[a-z]+$", "i")) // email ~* '…'
.ToListAsync();
The flags string uses PostgreSQL option characters: i case-insensitive, c case-sensitive,
m/n multiline, s single-line (dot matches newline), x extended, and g replace-all (for
RegexpReplace). A RegexOptions argument to a Regex call is honored only when it is a compile-time
constant made of flags CamusDB can express (IgnoreCase, Multiline, Singleline,
IgnorePatternWhitespace); anything else leaves the call untranslated. Because these are equivalent to
CHECK-constraint predicates, the same patterns work in HasCheckConstraint (e.g.
t.HasCheckConstraint("ck_email", "email ~* '^[^@]+@[^@]+$'")). The set-returning functions
regexp_matches / regexp_split_to_table are not supported by the server.
Joins
The provider translates every LINQ join shape to the server's INNER, LEFT, RIGHT and CROSS JOIN
(the outer and cross forms need a server with outer-join support):
| LINQ | SQL |
|---|---|
join … on … equals …, Join(...), a required navigation |
INNER JOIN |
Include(...) of a collection or an optional reference, an optional navigation (o.Customer!.Name), join … into g from x in g.DefaultIfEmpty(), LeftJoin(...) |
LEFT JOIN |
RightJoin(...) |
RIGHT JOIN |
from a in A from b in B (no correlation) |
CROSS JOIN |
// Customers with their orders; a customer with no order gets an empty collection.
var customers = await ctx.Customers.Include(c => c.Orders).ToListAsync();
// Every customer, with null for the total of a customer that has no order.
var rows = await ctx.Customers
.LeftJoin(ctx.Orders, c => (long?)c.Id, o => o.CustomerId,
(c, o) => new { c.Name, Total = (long?)o!.Total })
.ToListAsync();
A row that has no match on the other side of an outer join comes back with every column of that side
NULL (a padded row). EF Core materializes it as a null entity, a null member of a projection, or
no element of an Included collection.
RightJoin gets one rewrite. The relational base keeps a Where on its left (null-extended) operand in
the statement's WHERE, which runs after the padding and removes the padded rows. The provider moves a
filtered left operand into a derived table, so the filter runs before the join and the padded rows
stay. The same rewrite lets the left operand be a join (A.Join(B, …).RightJoin(C, …)), which the server
refuses as plain SQL.
Two shapes cannot be translated, because the server has no lateral join and no window functions. The
provider throws InvalidOperationException with the cause:
- A
SelectManyor join whose inner sequence refers to the outer row outside an equality key, e.g.from o in ctx.Orders.Where(o => o.Total > c.Limit + 1). EF Core writesCROSS APPLY/OUTER APPLYfor it. Make the correlation an equality key, or issue separate queries. Take/Skip/Firston a per-row sequence: a filteredInclude(c => c.Orders.OrderBy(...).Take(n)), aSelectManyoverc.Orders.Take(n), or a per-groupFirst(). EF Core writesROW_NUMBER() OVER (...)for it. Load that sequence in a separate query and applyTakeon the client.
FULL JOIN has no LINQ operator, and the server does not support it.
Not translatable (server limitations): set operators (
Union/Concat) — CamusDB has noUNION. These raise a translation or SQL error; issue separate queries instead.
Query Result Cache
Opt a LINQ query into CamusDB's query result cache with WithCache(name).
The provider injects the {cache=…} hint into the generated SQL, so an identical later query is
served from the server's in-memory cache. As with the raw client, only single-table, autocommit
reads are cacheable — a query with a join, one reading through a view, or one inside an explicit
transaction reads live storage.
using CamusDB.EntityFrameworkCore;
// Cache with the server's default TTL
List<Order> recent = await ctx.Orders
.Where(o => o.Status == 1)
.OrderByDescending(o => o.Total)
.Take(20)
.WithCache("recent_orders")
.ToListAsync();
// Per-entry TTL override and strict per-hit validation
List<Order> hot = await ctx.Orders
.Where(o => o.Status == 1)
.WithCache("hot_orders", ttl: TimeSpan.FromSeconds(30), strict: true)
.ToListAsync();
Evict entries through the underlying CamusConnection (EvictCacheAsync / EvictAllCacheAsync).
Update
await using var ctx = new AppDbContext();
Robot robot = await ctx.Robots.FindAsync(id)
?? throw new InvalidOperationException("Not found");
robot.Price = 99.0;
await ctx.SaveChangesAsync();
Delete
await using var ctx = new AppDbContext();
Robot robot = await ctx.Robots.FindAsync(id)
?? throw new InvalidOperationException("Not found");
ctx.Robots.Remove(robot);
await ctx.SaveChangesAsync();
Bulk Update and Delete
ExecuteUpdate and ExecuteDelete run a single UPDATE/DELETE on the server without loading the
entities:
int paid = await ctx.Orders
.Where(o => o.Status == "open" && o.Total > 100)
.ExecuteUpdateAsync(s => s
.SetProperty(o => o.Status, "paid")
.SetProperty(o => o.Total, o => o.Total * 2));
int removed = await ctx.Orders
.Where(o => ctx.Customers.Any(c => c.Id == o.CustomerId && c.Name == "bob"))
.ExecuteDeleteAsync();
CamusDB's UPDATE/DELETE take no alias on the target table and always need a WHERE, so the
provider writes the target's columns by their bare names and adds WHERE TRUE when there is no filter.
A filter on the target table alone is sent as the statement's own WHERE. Any other filter — a
navigation or Any(...) subquery, a join, or OrderBy/Take/Skip — is sent as
WHERE <pk> IN (SELECT ...), because the server cannot evaluate a subquery correlated with the target
row inside an UPDATE/DELETE. That form needs a single-column primary key.
- A value in
SetPropertycan use constants, parameters and the row's own columns. A value that reads another table (a navigation, a joined column, or a correlated subquery) throwsInvalidOperationException. - Like all EF Core bulk operations, these bypass the change tracker: tracked entities are not updated,
and a provider-managed
[Timestamp]row version is not advanced — set it inSetPropertyif other writers rely on it.
Migrations
The provider supports EF Core migrations for the following DDL operations:
| Operation | Generated SQL |
|---|---|
| Create table | CREATE TABLE t (col TYPE [NOT NULL], ..., PRIMARY KEY (col1, ...)) |
| Drop table | DROP TABLE t |
| Rename table | ALTER TABLE t RENAME TO new_name |
| Add column | ALTER TABLE t ADD COLUMN col TYPE [NOT NULL] [DEFAULT (value)] |
| Drop column | ALTER TABLE t DROP COLUMN col |
| Rename column | ALTER TABLE t RENAME COLUMN old TO new |
| Alter column nullability | ALTER TABLE t ALTER COLUMN col SET NOT NULL / DROP NOT NULL |
| Add check constraint | ALTER TABLE t ADD CONSTRAINT name CHECK (expr) |
| Drop check constraint | ALTER TABLE t DROP CONSTRAINT name |
| Add foreign key | ALTER TABLE t ADD CONSTRAINT name FOREIGN KEY (col) REFERENCES p (col) [ON DELETE RESTRICT] |
| Drop foreign key | ALTER TABLE t DROP CONSTRAINT name |
| Add unique constraint (alternate key) | CREATE UNIQUE INDEX name ON t (col1, ...) |
| Drop unique constraint | ALTER TABLE t DROP INDEX name |
| Create index | CREATE INDEX name ON t (col1, col2) |
| Create unique index | CREATE UNIQUE INDEX name ON t (col1, col2) |
| Drop index | ALTER TABLE t DROP INDEX name |
| Rename index | ALTER TABLE t RENAME INDEX old TO new |
| Set/change table comment | COMMENT ON TABLE t IS '...' |
| Set/change column comment | COMMENT ON COLUMN t.col IS '...' |
| Set index comment | COMMENT ON INDEX t.name IS '...' |
| Set/change column storage strategy | inline STORAGE X on create / add column; ALTER TABLE t ALTER COLUMN col SET STORAGE X |
Rewrite stored rows (migrationBuilder.RewriteStorage) |
ALTER TABLE t REWRITE STORAGE [INLINE] |
| Raw SQL | passed through as-is |
A CHECK constraint declared with ToTable(t => t.HasCheckConstraint(...)) is emitted as a
table-level CONSTRAINT name CHECK (expr) inside CREATE TABLE (both via migrations and
EnsureCreated), and can be added or dropped later with migrationBuilder.AddCheckConstraint /
DropCheckConstraint. Changing a property's nullability (with the column type unchanged) maps to
CamusDB's ALTER COLUMN … SET/DROP NOT NULL; DropCheckConstraint also drops a named NOT NULL
constraint by name, since CamusDB resolves DROP CONSTRAINT against both.
Comments
Table and column descriptions come from EF Core's built-in HasComment (or the [Comment]
attribute); indexes use the provider's HasComment extension, since EF has no built-in surface for
them:
modelBuilder.Entity<User>(b =>
{
b.ToTable("users", t => t.HasComment("Application users"));
b.Property(u => u.Email).HasComment("Unique login email address");
b.HasIndex(u => u.Email, "email_idx").HasComment("Lookup by login email");
});
Creating the table emits the comments inline:
CREATE TABLE IF NOT EXISTS `users` (
`id` OID NOT NULL COMMENT 'Internal user identifier',
`email` STRING NOT NULL COMMENT 'Unique login email address',
PRIMARY KEY (`id`)
) COMMENT 'Application users';
CREATE UNIQUE INDEX IF NOT EXISTS `email_idx` ON `users` (`email`);
COMMENT ON INDEX `users`.`email_idx` IS 'Lookup by login email';
Changing a comment later emits COMMENT ON …; removing one emits COMMENT ON … IS NULL, which
CamusDB treats as distinct from IS '' (a present-but-empty comment), matching PostgreSQL. A
comment-only change on a column no longer trips the "only nullability can be altered" guard.
Comments are validated when the SQL is generated, so invalid text fails the migration rather than the server round-trip. CamusDB cannot round-trip control characters (newlines, tabs), a backslash immediately before a quote, or a trailing backslash, and caps comments at 65 535 characters. Embedded single quotes are fine — they are doubled on output.
Two limitations have no EF surface: the primary key index cannot carry a comment (comment the table
instead), and database comments must be issued as raw SQL —
migrationBuilder.Sql("COMMENT ON DATABASE app IS '...'").
Large values
HasStorage sets the storage strategy of a string, byte[] or array property (see Large values for what each strategy does). Migrations and EnsureCreated emit it as an inline STORAGE clause:
modelBuilder.Entity<Doc>(b =>
{
b.Property(d => d.Body); // EXTENDED, the default
b.Property(d => d.Thumbnail).HasStorage(CamusColumnStorage.External);
b.Property(d => d.Embedding).HasMaxLength(3072).HasStorage(CamusColumnStorage.Plain);
});
CREATE TABLE IF NOT EXISTS `docs` (
`Id` OID NOT NULL,
`Body` STRING NOT NULL,
`Thumbnail` BYTES STORAGE EXTERNAL,
`Embedding` BYTES(3072) STORAGE PLAIN,
PRIMARY KEY (`Id`)
);
A property without HasStorage gets no clause, so the DDL of an existing model does not change. A later change of strategy produces ALTER COLUMN … SET STORAGE. A strategy removed from the model produces SET STORAGE EXTENDED, because CamusDB has no reset form. The model validator refuses HasStorage on any other column type, as the server does with CADB0414.
SET STORAGE changes future writes only. To convert the stored rows in the same migration, add the rewrite by hand. It suppresses the migration transaction, because the server runs it in its own batches:
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<byte[]>(
name: "Embedding", table: "docs", type: "bytes", maxLength: 3072, nullable: true,
oldClrType: typeof(byte[]), oldType: "bytes", oldMaxLength: 3072, oldNullable: true)
.Annotation("Camus:ColumnStorage", CamusColumnStorage.Plain)
.OldAnnotation("Camus:ColumnStorage", CamusColumnStorage.Extended);
migrationBuilder.RewriteStorage("docs"); // ALTER TABLE `docs` REWRITE STORAGE
}
Outside a migration, context.Database.RewriteStorageAsync("docs") runs the same statement, and inline: true converts the table back to inline form. The time is proportional to the table, so set Database.SetCommandTimeout(...) first for a large table.
The provider ships design-time services so the EF tooling can discover the provider automatically. No extra flags are needed:
dotnet ef migrations add InitialCreate
dotnet ef database update
Example migration using the supported operations:
public partial class AddStockColumn : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Stock",
table: "products",
type: "int64",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "idx_products_name",
table: "products",
column: "Name",
unique: true);
migrationBuilder.AddCheckConstraint(
name: "ck_products_stock",
table: "products",
sql: "Stock >= 0");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropCheckConstraint(name: "ck_products_stock", table: "products");
migrationBuilder.DropIndex(name: "idx_products_name", table: "products");
migrationBuilder.DropColumn(name: "Stock", table: "products");
}
}
Migrating without superuser rights
MigrateAsync() and EnsureCreatedAsync() first ask the server whether the database exists, with
SHOW DATABASE. They send CREATE DATABASE IF NOT EXISTS only when the answer is CADB0010
(database does not exist). CREATE DATABASE requires a superuser, so an account with DDL grants on
an existing database can migrate it without one:
GRANT SELECT, INSERT, CREATE TABLE, ALTER, INDEX ON shop.* TO migrator;
- The account applies the pending migrations and writes
__EFMigrationsHistory. AddDROP,UPDATEorDELETEwhen a migration drops objects or changes rows. - Against a database that does not exist, the account fails with
CADB0517fromCREATE DATABASE. Create the database as a superuser first. - Any other error from the existence check (an unreachable endpoint, an authentication failure) propagates. Before 0.14.4 the provider always reported "does not exist" and tried to create the database, which hid such an error.
The provider takes no migration lock: CamusDB has no advisory lock. Run one migrator at a time, for example behind a distributed lock in your deployment.
Views
CamusDB views are stored queries expanded at every reference, so anything that works on a subquery —
joins, aggregation, ORDER BY, the optimizer — works when reading through one. Map an entity to a
view with EF's ToView; EF excludes view-mapped types from migrations and from EnsureCreated, so
the view's own DDL is yours to write:
modelBuilder.Entity<OpenOrder>().HasNoKey().ToView("open_orders");
var spend = await ctx.OpenOrders
.GroupBy(o => o.Customer)
.Select(g => new { Customer = g.Key, Spent = g.Sum(o => o.Total) })
.ToListAsync();
EF has no migration operation for views, so the provider adds three that emit the DDL for you:
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateView(
"open_orders",
"SELECT id, customer, total FROM orders WHERE status = 'open'");
// Optional: name the view's own columns, and/or replace an existing definition.
migrationBuilder.CreateView(
"order_summary",
"SELECT id, customer, total FROM orders",
columns: ["order_id", "who", "amount"]);
migrationBuilder.RenameView("open_orders", "active_orders");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropView("order_summary");
migrationBuilder.DropView("active_orders", ifExists: true);
}
Things the server enforces, worth knowing before writing the body:
- Every output column needs a name. A bare expression is refused (
CADB0400) rather than named?column?; alias it (total + 1 AS total_plus_one). CREATE OR REPLACEmay only append columns. Existing names, types, and order must be preserved, because dependent views and cached plans are bound to them. Drop and recreate to change the shape.- Dependencies block drops. Dropping a view or table that another view reads fails with
CADB0530.DROP VIEW … CASCADEtakes the dependents with it (cascade: trueabove);DROP TABLEhas noCASCADEform, so a migration dropping such a table must drop the dependent views first. SELECT *is frozen at creation, cannot be mixed with other projections, and the body may carry neitherAS OF SYSTEM TIMEnor index/cache hints.- Views are read-only. Inserts, updates, and deletes through a view are not implemented, so map a
view-backed type as
HasNoKey()(or otherwise keep it out ofSaveChanges). - A hinted read through a view is not cached. A view expands to a derived table, which has no row
keyspace for the result cache to fence, so
WithCache(...)is accepted but inert. It is reported rather than silent: the response carriescacheStatus = bypasswith bypass reasonderived-source(see Query Result Cache —CamusCacheMetadata).
The same statements work from the ADO client through ExecuteNonQueryAsync (or ExecuteDDLAsync):
await using CamusCommand cmd = connection.CreateCamusCommand(
"CREATE VIEW open_orders AS SELECT id, customer, total FROM orders WHERE status = 'open'");
await cmd.ExecuteNonQueryAsync();
Materialized views
A materialized view stores its query's rows and answers reads from that copy until you refresh it, so
it is a physical relation: it can be indexed, analyzed, commented, and cached like a table. Map it
with ToTable rather than ToView — but keep it out of SaveChanges, because writing to one is
refused (the row would be discarded by the next refresh).
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"CREATE MATERIALIZED VIEW customer_totals AS " +
"SELECT customer, SUM(total) AS total_spent FROM orders GROUP BY customer");
}
The stored rows do not move on their own — only a refresh advances them, and it is a write rather than schema, so it reports the number of rows it wrote:
await using CamusCommand refresh = connection.CreateCamusCommand(
"REFRESH MATERIALIZED VIEW customer_totals");
int rows = await refresh.ExecuteNonQueryAsync();
A rebuild reads its source at one pinned snapshot and swaps the result in atomically, so readers never
block and never observe a half-built materialized view. WITH NO DATA creates (or empties) one without
running the query; reading an unpopulated materialized view is an error, not an empty result, so a
forgotten refresh cannot pass for a correct answer. REFRESH … CONCURRENTLY is refused — the ordinary
form already leaves readers unblocked, and incremental refresh is not implemented.
Relationships and Foreign Keys
Each relationship of the model becomes a FOREIGN KEY constraint in the database, both through
migrations and through EnsureCreated. The server checks each constraint at the end of each statement,
inside the statement's transaction:
modelBuilder.Entity<Customer>()
.HasMany(c => c.Orders)
.WithOne()
.HasForeignKey(o => o.CustomerId);
CREATE TABLE IF NOT EXISTS `Orders` (
`Id` INT64 NOT NULL,
`CustomerId` INT64 NOT NULL,
PRIMARY KEY (`Id`),
KEY `IX_Orders_CustomerId` (`CustomerId`),
CONSTRAINT `FK_Orders_Customers_CustomerId` FOREIGN KEY (`CustomerId`) REFERENCES `Customers` (`Id`)
)
- Delete behavior. The server runs
NO ACTIONandRESTRICTonly. EF Core gives a required relationshipDeleteBehavior.Cascadeby default, so the provider rendersCascade,SetNullandSetDefaultasNO ACTIONin the DDL. EF Core still deletes, or sets to null, the dependents that the context tracks, as it does forClientCascadeandClientSetNull. A delete of a principal whose dependents are not tracked fails withCADB0305. Load the dependents first, or delete them first.DeleteBehavior.Restrictrenders asON DELETE RESTRICT. - The index on the foreign key. The server needs an index whose leading columns are the referencing
columns. In a migration, the provider moves the
IX_…index that EF Core declares for a new table into itsCREATE TABLE, and the server uses that index.EnsureCreateddeclares no index of the model, so there the server creates one of its own, named~fk_{constraint}. - Alternate keys. A foreign key can reference only a primary key or a unique index. An alternate key
(
HasAlternateKey, or the principal key ofHasPrincipalKey) becomes aUNIQUE KEYinCREATE TABLE, andAddUniqueConstraint/DropUniqueConstraintcreate and drop it as a unique index. - Add a foreign key to a table with rows.
AddForeignKeyreads every existing row first. If a row has no parent, the statement fails withCADB0304and changes nothing. - Cycles. The server refuses a cycle of foreign keys between two or more tables (
CADB0416). A self-reference is accepted.EnsureCreatedrefuses a cycle before it creates a table.
A violation surfaces as a CamusException with one of these codes, wrapped in DbUpdateException under
SaveChanges:
| Code | Meaning |
|---|---|
CADB0304 |
An inserted or updated child has no parent, or AddForeignKey found a row without a parent. |
CADB0305 |
A delete removes a parent key that a child row still holds. |
CADB0306 |
An update changes a parent key that a child row still holds. |
CADB0416 |
The constraint closes a cycle of two or more tables. |
CADB0530 |
A schema change that the constraint blocks: DROP TABLE or TRUNCATE of a referenced table, DROP COLUMN of a referencing or a referenced column, DROP INDEX of an index that the constraint uses. |
To keep the database free of constraints, as before the provider supported them, turn them off. The DDL
then has no FOREIGN KEY, and AddForeignKey / DropForeignKey produce no SQL. EF Core still orders
inserts and deletes by the relationships:
optionsBuilder.UseCamusDB(connectionString, o => o.UseForeignKeyConstraints(false));
A database created by an earlier version of the provider has no foreign keys, and a migration does not add
them, because the model snapshot already holds the relationships. To add one, write
migrationBuilder.AddForeignKey(...) in a migration. Before you do, delete or fix the rows that have no
parent. To drop a foreign key that the database does not have, remove the DropForeignKey step from the
migration, because DROP CONSTRAINT of an unknown name fails.
Concurrency Tokens
The provider supports EF Core optimistic concurrency. When a tracked entity has a concurrency token,
its previously-loaded value is added to the UPDATE's WHERE clause; a stale write matches zero rows
on the server and the provider raises DbUpdateConcurrencyException.
[Timestamp] / IsRowVersion() — a byte[] row version. CamusDB has no server-side auto-version,
so the provider generates a fresh, strictly-increasing token on every insert and update and sends it
(stored as a BYTES column). No manual bookkeeping is required:
public class Doc
{
public string Id { get; set; } = "";
public string Body { get; set; } = "";
[Timestamp]
public byte[] Version { get; set; } = []; // provider-managed row version
}
doc.Body = "edited";
await ctx.SaveChangesAsync(); // throws DbUpdateConcurrencyException if stale
[ConcurrencyCheck] on a numeric column (short, int, long) — the application increments the
version before SaveChanges():
public class Order
{
public string Id { get; set; } = "";
public string Status { get; set; } = "";
[ConcurrencyCheck]
public long Version { get; set; }
}
order.Status = "shipped";
order.Version++; // manual bump
await ctx.SaveChangesAsync(); // throws DbUpdateConcurrencyException if stale
Note on MVCC vs. optimistic concurrency: the
WHERE-clause check above is evaluated at write time, so a staleSaveChangesAsyncfails immediately withDbUpdateConcurrencyException. This is distinct from CamusDB's MVCC write-write conflict detection between two open explicit transactions, which surfaces at commit time (CommitTransactionAsync). Use a concurrency token for application-level stale-update detection.
Provider Limitations
- No computed columns.
- Foreign keys run
NO ACTIONandRESTRICTonly;Cascade,SetNullandSetDefaultapply to tracked dependents only (see Relationships and Foreign Keys). No foreign-key cycle of two or more tables. - No lateral join (
CROSS APPLY/OUTER APPLY) and no window functions (ROW_NUMBER()): aSelectManycorrelated outside an equality key, andTake/Skipon a per-row sequence (a filteredInclude, a per-groupFirst), throwInvalidOperationException(see Joins). - No
UNION/UNION ALL— LINQUnion/Concatdo not translate; issue separate queries. ALTER COLUMNonly supports toggling nullability (SET/DROP NOT NULL), the storage strategy (SET STORAGE) and the comment; changing a column's stored type requires dropping and recreating the column.CHECKconditions must be deterministic single-row predicates — no subqueries, aggregates, or volatile functions (now(),gen_uuid_v4/v7(), …); a violated check surfaces as aCamusExceptionwith codeCADB0303(wrapped inDbUpdateExceptionunder EF Core), and a NULL operand makes the predicate pass (SQL three-valued logic).array(T)columns map forlong[],string[],double[], andbool[]. They are not indexable and have no SQL literal, so an array can only be written/read as a whole value — it cannot appear in aWherepredicate or as a default/seed value.- A
decimalreads exactly up to 28 or 29 significant digits. ANUMERICvalue with more digits throwsInvalidCastExceptionwhen it is materialized into adecimalproperty. - Key CLR types must be one of:
string,int,long,short, orGuid. [ConcurrencyCheck]is supported onshort/int/long;[Timestamp]/IsRowVersion()is supported onbyte[](provider-managed). A stale write raisesDbUpdateConcurrencyException.- Views are read-only (no writes through a view) and are not scaffolded into a model.
WithCache(...)on a query that reads through a view is accepted but inert — the response reportsbypass/derived-sourceand the rows come from live storage. Materialized views are writable only byREFRESH, and do cache. WithCache(...)only takes effect on single-table, autocommit reads; the hint is inert on queries with a join or run inside an explicit transaction (they read live storage).ExecuteUpdatecannot set a value read from another table, andExecuteUpdate/ExecuteDeletewith a join, a correlated subquery orTake/Skipneed a single-column primary key (see Bulk Update and Delete).
CamusDB.Dapper (Dapper)
Dapper works with a CamusConnection as it is, because the connection is a plain ADO.NET DbConnection. The CamusDB.Dapper package adds the CamusDB types that Dapper does not know.
Installation
dotnet add package CamusDB.Dapper
Usage
using CamusDB.Client;
using CamusDB.Dapper;
using Dapper;
// Once at startup: registers the handler for CamusObjectIdValue.
CamusDapper.Register();
await using CamusConnection connection = new("Endpoint=http://localhost:5095;Database=test");
IEnumerable<Robot> robots = await connection.QueryAsync<Robot>(
"SELECT * FROM robots WHERE year >= @from ORDER BY year",
new { from = 1977 });
int inserted = await connection.ExecuteAsync(
"INSERT INTO robots (id, name, year) VALUES (GEN_ID(), @Name, @Year)",
new[] { new { Name = "R2-D2", Year = 1977 }, new { Name = "T-800", Year = 1984 } });
long count = await connection.ExecuteScalarAsync<long>("SELECT COUNT(*) FROM robots");
Dapper binds each parameter by its bare name (from, not @from). The driver adds the @ for a SQL command, so a bare name and a name with the @ bind the same placeholder.
Dapper types a parameter by its CLR type. The driver maps that type as the Data Types table shows. A Guid binds as a UUID, a DateOnly binds as a DATE, and an enum binds as its integer value. On read, Dapper converts each column into the property type, for example INT64 into int or DATE into DateOnly.
Typed values: CamusValue
CamusValue binds a value as a column type that the CLR type cannot express:
| Value | Binds as |
|---|---|
CamusValue.Id("65f1…") |
An OID from its hex string. Dapper sends a plain string as STRING. |
CamusValue.Uuid(guid) |
A UUID, also when the value is null. |
CamusValue.Numeric("123…") |
A NUMERIC from its decimal text, for a value that a decimal cannot hold. A plain decimal already binds as NUMERIC. |
CamusValue.Array(values) |
One native ARRAY(T). The element type comes from T, so an empty array works too. |
CamusValue.Vector(floats) |
A float32 vector in a BYTES column, in the CamusVector layout. |
new CamusValue(ColumnType.X, value) |
Any column type. |
IEnumerable<string> names = await connection.QueryAsync<string>(
"SELECT name FROM robots WHERE year = ANY(@years)",
new { years = CamusValue.Array(new long[] { 1977, 2001 }) });
A CamusValue also works as a value in DynamicParameters.
Arrays and IN lists
By default, Dapper expands a sequence parameter into a list: WHERE name IN @names becomes WHERE name IN (@names1, @names2, …). This works with CamusDB with no change.
To bind a sequence as one ARRAY(T) value, wrap it in CamusValue.Array. To read an ARRAY(T) column into a T[] property, register an array type handler:
CamusDapper.RegisterArrayTypeHandlers(); // long[], int[], double[], bool[], string[], Guid[]
CamusDapper.RegisterArrayTypeHandler<float>(); // any other element type
A handler for T[] also changes how Dapper binds a T[] parameter. The parameter becomes one ARRAY(T) value, so IN @values no longer expands for that type. Use = ANY(@values) for it. A List<T> parameter still expands. Without a handler, an ARRAY(T) column reads as object[].
Limitations
- Dapper reads through
ExecuteReaderAsync, which buffers the whole result. For a very large result, useCamusCommand.ExecuteStreamReaderAsyncand parse rows withreader.GetRowParser<T>(). - Dapper type handlers are process-wide. Call the
CamusDapperregistration methods once, at startup.
Run Tests
To run the unit tests, a CamusDB instance must be running locally. After starting it, run:
dotnet test -l "console;verbosity=normal" --filter "FullyQualifiedName~CamusDB.Client.Tests"
Contribution
CamusDB.Client is an open-source project, and contributions are heartily welcomed! Whether you are looking to fix bugs, add new features, or improve documentation, your efforts and contributions will be appreciated. Check out the CONTRIBUTING.md file for guidelines on how to get started with contributing to CamusDB.Client.
License
CamusDB.Client is released under the MIT License.
| 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
- CamusDB.Client (>= 0.15.3)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.12)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.15.4 | 39 | 10/17/2026 |
| 0.15.3 | 71 | 10/14/2026 |
| 0.15.2 | 169 | 10/13/2026 |
| 0.15.1 | 70 | 10/11/2026 |
| 0.15.0 | 85 | 10/11/2026 |
| 0.14.4 | 226 | 10/9/2026 |
| 0.14.2 | 83 | 10/5/2026 |
| 0.14.1 | 93 | 10/4/2026 |
| 0.14.0 | 108 | 9/26/2026 |
| 0.13.2 | 102 | 9/23/2026 |
| 0.13.1 | 112 | 9/21/2026 |
| 0.13.0 | 97 | 9/19/2026 |
| 0.12.4 | 112 | 9/18/2026 |
| 0.12.3 | 101 | 9/15/2026 |
| 0.12.2 | 114 | 9/10/2026 |
| 0.12.1 | 109 | 9/9/2026 |
| 0.12.0 | 116 | 9/8/2026 |
| 0.11.0 | 123 | 9/5/2026 |
| 0.10.0 | 124 | 8/27/2026 |
| 0.9.7 | 127 | 8/10/2026 |