Skip to main content

EF Core provider

CamusDB.EntityFrameworkCore lets a .NET application use CamusDB through Entity Framework Core. Entity Framework Core (EF Core) is the .NET object-relational mapper (ORM): you define your data as C# classes and query it with LINQ, and EF Core writes the SQL, tracks changes to your objects, and saves them.

Use EF Core when your application already uses it, or when you want to work with objects instead of SQL text. The provider sits on top of the .NET driver, so transport, authentication, and routing work the same way.

To compare the three .NET packages (driver, Dapper, EF Core) and their target frameworks, see Which .NET package to use.

Use the EF Core provider when:

  • You want LINQ over CamusDB tables.
  • You want EF Core change tracking.
  • You create the schema with EnsureCreated(), or with EF migrations for the supported DDL.
  • You want application-level optimistic concurrency with version columns.

Use the .NET driver instead when you want to write SQL first, or need finer control over each command and transaction.

The provider depends on the EF Core 10 relational APIs.

For an Aspire application, use the .NET Aspire integration to register a pooled context with health checks and telemetry.

Install​

dotnet add package CamusDB.EntityFrameworkCore

Configure the provider​

Register the provider with UseCamusDB(...):

using CamusDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

var options = new DbContextOptionsBuilder<AppDbContext>()
.UseCamusDB("Endpoint=http://localhost:5095;Database=mydb;Timeout=30")
.Options;

The connection string accepts the same keys as the ADO.NET driver. Common examples are Protocol=grpc, User, Password, AccessToken, MaxAutoPrepare, AutoPrepareMinUsages, IsolationLevel, Locking, TransactionMode, ChannelPoolSize, CoalescingThreshold, and CoalescingDelay.

See .NET Driver for the full connection string reference, and .NET Driver Authentication for authenticated connections.

EF Core commands also emit the driver's OpenTelemetry traces and metrics. See .NET Driver OpenTelemetry for registration and how it interacts with EF Core's own instrumentation.

You can also configure it in OnConfiguring:

public sealed class AppDbContext : DbContext
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.UseCamusDB(
"Endpoint=http://localhost:5095;Database=mydb");
}

Reuse an existing connection​

Pass the connection directly when you want to share a CamusConnection or manage a transaction outside the context:

CamusConnection connection = new(
new CamusConnectionStringBuilder(
"Endpoint=http://localhost:5095;Database=mydb"));

var options = new DbContextOptionsBuilder<AppDbContext>()
.UseCamusDB(connection)
.Options;

When you pass an existing connection, the DbContext doesn't own it and disposes nothing.

Define a model​

Map an ObjectId primary key with the "id" or "oid" store type. Mark the property as generated on add when the client should create the ObjectId:

public sealed class Robot
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public string Kind { get; set; } = "";
public int Year { get; set; }
public double Price { get; set; }
public bool Enabled { get; set; }
}

public sealed 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();

b.Property(e => e.Name).HasColumnType("string");
b.Property(e => e.Kind).HasColumnType("string");
b.Property(e => e.Year).HasColumnType("int64");
b.Property(e => e.Price).HasColumnType("float64");
b.Property(e => e.Enabled).HasColumnType("bool");
});
}
}

For a string primary key mapped to "id" or "oid", the provider generates a 24-character Camus ObjectId on the client.

Use ToTable(t => t.HasCheckConstraint(...)) for a server-enforced check constraint:

modelBuilder.Entity<Robot>(b =>
{
b.ToTable("robots", t =>
t.HasCheckConstraint("ck_robots_price", "price >= 0"));
});

When a check constraint rejects a row, you get a DbUpdateException whose inner CamusException carries code CADB0303.

Type mapping​

Supported CLR-to-store mappings:

CLR typeCamus store typeDDL type
string key with HasColumnType("id")id or oidOID
Guiduuid or guidUUID
stringstringSTRING
string with HasMaxLength(n)stringSTRING(n)
boolboolBOOL
short, int, longint64INT64
floatfloat32 or realFLOAT32
doublefloat64FLOAT64
decimalnumeric or decimalNUMERIC
JsonDocument, JsonElement, JsonNode, JsonObject, JsonArrayjsonbJSONB
string with HasColumnType("jsonb")jsonbJSONB
byte[]bytes or blobBYTES
DateOnlydateDATE
DateTime, DateTimeOffsetdatetime or timestampDATETIME
long[], string[], double[], bool[]array(int64), array(string), array(float64), array(bool)ARRAY(T)

For CamusDB ObjectId primary keys, use HasColumnType("id") on a string property. A plain Guid property maps to native UUID, so HasColumnType("uuid") is optional. Do not declare a Guid property with HasColumnType("id"): CamusDB ObjectId values are 12 bytes and should be modeled as strings or CamusObjectIdValue.

A decimal property maps to NUMERIC. CamusDB uses fixed precision and scale for that type, so EF Core HasPrecision(...) does not change the generated column type. A decimal constant in a LINQ query is emitted as a typed NUMERIC literal.

The provider normalizes DateTime and DateTimeOffset values to UTC. DateOnly maps to a calendar date.

long[], string[], double[], and bool[] properties map to native ARRAY(T) columns. Arrays are read and written as whole values. They are not indexable, have no inline SQL literal, and cannot be used in a LINQ predicate.

Vector properties can be float[] or byte[] with IsVector(dimensions). The provider stores them as packed bytes, rather than a SQL array.

Create tables​

EnsureCreated() is supported and idempotent:

await using var ctx = new AppDbContext(options);
await ctx.Database.EnsureCreatedAsync();

EnsureCreated() first checks whether the database exists. It sends CREATE DATABASE IF NOT EXISTS only when the database is missing, then creates the model's tables. It continues safely when the database or the tables already exist, so an account with DDL grants on an existing database can run EnsureCreated() or migrations without superuser rights.

EnsureDeleted() drops the connection string's database:

await ctx.Database.EnsureDeletedAsync();

Basic CRUD​

Insert​

await using var ctx = new AppDbContext(options);

ctx.Robots.Add(new Robot
{
Name = "T-800",
Kind = "cyborg",
Year = 1984,
Price = 10.0,
Enabled = true
});

await ctx.SaveChangesAsync();

Query​

await using var ctx = new AppDbContext(options);

Robot? robot = await ctx.Robots.FindAsync(id);

List<Robot> active = await ctx.Robots
.Where(r => r.Enabled && r.Year > 1980)
.ToListAsync();

Update​

await using var ctx = new AppDbContext(options);

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(options);

Robot robot = await ctx.Robots.FindAsync(id)
?? throw new InvalidOperationException("Not found");

ctx.Robots.Remove(robot);
await ctx.SaveChangesAsync();

Bulk update and delete​

Use EF Core ExecuteUpdateAsync() and ExecuteDeleteAsync() when the change can run as one server-side statement and you do not need to load entities into the change tracker:

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 requires UPDATE and DELETE to have a WHERE clause, so an unfiltered bulk operation is emitted with WHERE TRUE. A filter over the target table is sent directly. A filter that uses a navigation, a join, a correlated subquery, or Take / Skip is rewritten as WHERE <pk> IN (SELECT ...), which requires a single-column primary key.

Values passed to SetProperty can use constants, parameters, and columns of the row being updated. They cannot read another table. Like all EF Core bulk operations, these bypass the change tracker, and provider-managed row-version tokens are not advanced unless you set them explicitly.

LINQ translation​

The provider translates common relational query shapes to CamusDB SQL:

  • Where
  • OrderBy / ThenBy
  • Skip / Take
  • Distinct
  • scalar aggregates: Count, Sum, Average, Min, Max, Any
  • GroupBy with aggregates
  • inner joins, left joins, right joins, and cross joins
  • correlated subqueries, such as Where(o => o.Items.Any(...))

Parameterized collection checks such as ids.Contains(e.Id) expand to IN (@p0, @p1, ...), which works with CamusDB's SQL dialect.

Join support covers the usual LINQ shapes:

LINQ shapeSQL shape
join ... on ... equals ..., Join(...), required navigationINNER JOIN
GroupJoin(...).SelectMany(...DefaultIfEmpty()), LeftJoin(...), optional navigation, collection Include(...)LEFT JOIN
RightJoin(...)RIGHT JOIN
from a in A from b in B with no correlationCROSS JOIN

The provider does not translate lateral joins or per-row window query shapes. The server supports window functions, which you can use through raw SQL. A SelectMany whose inner sequence refers to the outer row outside an equality key, and per-row Take / Skip / First shapes such as filtered includes, are not translated.

String operations run on the server when they map to CamusDB functions:

LINQSQL function or predicate
s.StartsWith(x)starts_with(s, x)
s.EndsWith(x)ends_with(s, x)
s.Contains(x)contains(s, x)
s.ToUpper() / s.ToLower()upper(s) / lower(s)
s.Trim() / s.TrimStart() / s.TrimEnd()trim(s) / ltrim(s) / rtrim(s)
s.Replace(a, b)replace(s, a, b)
s.Lengthlength(s)

string.Compare(...) and a.CompareTo(b) translate when the expression is tested against zero. This is useful for keyset pagination:

List<Item> page = await ctx.Items
.Where(i => i.Seq > cursorSeq ||
(i.Seq == cursorSeq &&
string.Compare(i.Code, cursorCode, StringComparison.Ordinal) > 0))
.OrderBy(i => i.Seq)
.ThenBy(i => i.Code)
.Take(100)
.ToListAsync();

Regular expression helpers map to CamusDB's regex functions:

using System.Text.RegularExpressions;

List<Person> people = await ctx.People
.Where(p => Regex.IsMatch(p.Email, "^[^@]+@[^@]+\\.[a-z]+$",
RegexOptions.IgnoreCase))
.Where(p => EF.Functions.RegexpLike(p.Name, "^[A-Z]"))
.ToListAsync();

Supported regex helpers include Regex.IsMatch, Regex.Replace, EF.Functions.RegexpLike, RegexpReplace, RegexpCount, RegexpSubstr, and RegexpInstr.

String concatenation and interpolation​

From 0.16.8, string + and string.Concat translate to SQL ||, with nullable operands treated as empty strings to match C#. From 0.16.9, simple string interpolation translates too:

var people = await ctx.People
.Where(p => $"{p.First} {p.Last}" == "Ada Lovelace")
.ToListAsync();

Interpolation supports string/integer arguments and plain placeholders. Format specifiers, alignment, and date, decimal, or floating-point arguments are not translated. EF Core can format these on the client in the final projection; using them in a filter or ordering raises a translation error.

Set operations​

Union, Concat, Intersect, and Except translate to UNION, UNION ALL, INTERSECT, and EXCEPT. See set operations for server behavior.

JSON documents​

Document properties map to JSONB automatically; a string property needs HasColumnType("jsonb"). GIN indexes use HasMethod("gin"):

modelBuilder.Entity<Event>(b =>
{
b.Property(e => e.Payload).HasColumnType("jsonb");
b.HasIndex(e => e.Payload).HasMethod("gin");
});

var events = await ctx.Events
.Where(e => EF.Functions.JsonContains(e.Payload, """{"kind": "login"}"""))
.Where(e => EF.Functions.JsonExists(e.Payload, "user"))
.ToListAsync();

JsonContained, JsonExistAny, JsonExistAll, and JsonTypeof are also available. These helpers translate to the corresponding jsonb_* functions. A compatible top-level predicate can use the GIN index.

JsonNode properties use content comparison so in-place edits are saved. Replace a JsonDocument or JsonElement value to change it. JSON member access such as RootElement.GetProperty(...) and EF Core's owned/complex ToJson() mapping are not translated; use raw SQL for document operators. See JSON documents.

Vector queries​

From 0.16.10, declare a float[] or byte[] embedding with IsVector:

public sealed class SearchDocument
{
public string Id { get; set; } = "";
public string Kind { get; set; } = "";
public float[] Embedding { get; set; } = [];
}

Configure the property in OnModelCreating:

modelBuilder.Entity<SearchDocument>(b =>
{
b.Property(d => d.Embedding).IsRequired().IsVector(768);
});

This sets BYTES(3072), STORAGE PLAIN, and a dimension check named CK_{table}_{column}_dims. IsRequired() also rejects NULL; the dimension check alone does not. A later HasStorage can override the storage strategy. For float[], a converter packs the values and an element-wise comparer tracks edits made inside the array.

Rank rows with the vector helpers and a float[] query vector:

float[] query = await EmbedAsync("warehouse safety");
var nearest = await ctx.Set<SearchDocument>()
.Where(d => d.Kind == "manual")
.OrderBy(d => EF.Functions.L2Distance(d.Embedding, query))
.Take(10)
.ToListAsync();

A query vector uses the column's mapping and travels as packed bytes. For a byte[] property, pack it explicitly with CamusVector.ToBytes(...).

Use IsVector(768, loadWithEntity: false) to omit an embedding from ordinary entity loads. The property must be required; its runtime NULL then means "not loaded". Queries that name the vector in a filter, ordering, or explicit projection still read it. SaveChanges does not overwrite a stored vector with that unloaded NULL, including on a detached entity passed to Update. Unrecognized shapes, including collection includes, split queries, and Distinct, retain the vector.

Available helpers:

HelperSQL functionBest order
EF.Functions.L2Distance(a, b)l2_distance(a, b)ascending
EF.Functions.CosineDistance(a, b)cosine_distance(a, b)ascending
EF.Functions.InnerProduct(a, b)inner_product(a, b)descending
EF.Functions.VectorDims(v)vector_dims(v)not a ranking metric
EF.Functions.OctetLength(v)octet_length(v)not a ranking metric

Vector ranking is exact. Pair an OrderBy over a distance with Take(k) so the server can plan it as a bounded top-k query. See Vector Search.

Transactions​

The provider supports EF Core transactions:

await using var ctx = new AppDbContext(options);
await using var tx = await ctx.Database.BeginTransactionAsync();

ctx.Robots.Add(new Robot { Name = "R2-D2", Kind = "mechanical", Year = 1977 });
await ctx.SaveChangesAsync();

await tx.CommitAsync();

With an endpoint pool, commands inside a transaction stay on the same node for the transaction's whole lifetime.

Transaction defaults​

Serializable isolation is the server default. You can set default transaction options for a context with provider options:

var options = new DbContextOptionsBuilder<AppDbContext>()
.UseCamusDB("Endpoint=http://localhost:5095;Database=mydb", camus =>
{
camus.UseTransactionDefaults(new CamusTransactionOptions
{
IsolationLevel = CamusIsolationLevel.Serializable,
Mode = CamusTransactionMode.ReadWrite,
Locking = CamusLocking.Optimistic,
});
})
.Options;

For the common optimistic-locking selection, use the shortcut:

var options = new DbContextOptionsBuilder<AppDbContext>()
.UseCamusDB("Endpoint=http://localhost:5095;Database=mydb", camus =>
{
camus.UseOptimisticLocking();
camus.EnableRetryOnFailure();
})
.Options;

UsePessimisticLocking() selects the pessimistic mode explicitly. Any option left unset falls back to the connection string and then to the server default.

Retry on serializable conflicts​

Serializable is CamusDB's default isolation level, so SaveChangesAsync() and CommitTransactionAsync() can fail when a concurrent transaction wins a serialization conflict.

Enable EF Core's execution strategy with EnableRetryOnFailure():

var options = new DbContextOptionsBuilder<AppDbContext>()
.UseCamusDB("Endpoint=http://localhost:5095;Database=mydb", camus =>
{
camus.EnableRetryOnFailure();
})
.Options;

The strategy retries only CamusDB's retryable transaction errors:

  • CADB0502 TransactionConflict
  • CADB0504 TransactionMustRetry
  • CADB0505 TransactionLifetimeExceeded

CADB0509 TransactionFinalizeUnresolved is not a signal to replay from the start. It can appear while a transaction is finalizing: retry the same commit or rollback for the same transaction id, and don't run the unit of work again.

Default retry settings:

ParameterDefaultDescription
maxRetryCount15Maximum retry attempts.
maxRetryDelay1 sMaximum delay between retries.
retryDeadline5 sWall-clock deadline from first failure.
medianFirstRetryDelay30 msMedian first retry delay.

You can override them:

camus.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromMilliseconds(500),
retryDeadline: TimeSpan.FromSeconds(3),
medianFirstRetryDelay: TimeSpan.FromMilliseconds(20));

The execution strategy retries the whole EF operation. If you manage an explicit transaction by hand, replay the whole transaction from the start; don't retry the failed statement alone.

Prepared statements​

The underlying ADO.NET driver automatically prepares EF Core queries and SaveChangesAsync() statements once the same SQL shape has run enough times. You need no EF-specific configuration and no manual Prepare() calls.

This suits EF well: LINQ queries and change-tracking writes usually produce deterministic, parameterized SQL for each operation.

A prepared execution behaves like an inline one, including transactions, Serializable isolation, retries, and the query result cache.

Tune the policy with the ordinary connection string keys:

var options = new DbContextOptionsBuilder<AppDbContext>()
.UseCamusDB(
"Endpoint=http://localhost:5095;Database=mydb;MaxAutoPrepare=256;AutoPrepareMinUsages=2")
.Options;

Set MaxAutoPrepare=0 to disable automatic preparation.

See .NET Driver and Prepared Statements for the full behavior.

Query result cache​

Use WithCache(...) to opt a LINQ query into the CamusDB query result cache. The provider adds the {cache=...} hint to the generated SQL.

using CamusDB.EntityFrameworkCore;

List<Order> recent = await ctx.Orders
.Where(o => o.Status == 1)
.OrderByDescending(o => o.Total)
.Take(20)
.WithCache("recent_orders", ttl: TimeSpan.FromSeconds(30), strict: true)
.ToListAsync();

The cache serves only single-table autocommit reads. Queries with joins, and queries inside an explicit transaction, read live storage.

Evict cache entries through the underlying CamusConnection with EvictCacheAsync(...) or EvictAllCacheAsync().

Migrations​

The provider includes design-time services, so the standard EF tools can find it:

dotnet ef migrations add InitialCreate
dotnet ef database update

Supported migration operations:

OperationSQL shape
Create tableCREATE TABLE IF NOT EXISTS ...
Drop tableDROP TABLE ...
Rename tableALTER TABLE ... RENAME TO ...
Add columnALTER TABLE ... ADD COLUMN ...
Drop columnALTER TABLE ... DROP COLUMN ...
Rename columnALTER TABLE ... RENAME COLUMN ... TO ...
Alter column nullabilityALTER TABLE ... ALTER COLUMN ... SET NOT NULL or DROP NOT NULL
Add check constraintALTER TABLE ... ADD CONSTRAINT ... CHECK (...)
Drop check constraintALTER TABLE ... DROP CONSTRAINT ...
Add foreign keyALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY ... REFERENCES ...
Drop foreign keyALTER TABLE ... DROP CONSTRAINT ...
Add unique constraint / alternate keyCREATE UNIQUE INDEX ...
Drop unique constraint / alternate keyALTER TABLE ... DROP INDEX ...
Create indexCREATE INDEX IF NOT EXISTS ...
Create unique indexCREATE UNIQUE INDEX IF NOT EXISTS ...
Drop indexALTER TABLE ... DROP INDEX ...
Rename indexALTER TABLE ... RENAME INDEX ... TO ...
Set table commentCOMMENT ON TABLE ... IS ...
Set column commentCOMMENT ON COLUMN ... IS ...
Set index commentCOMMENT ON INDEX ... IS ...
Create viewCREATE VIEW ... AS SELECT ...
Drop viewDROP VIEW ...
Rename viewALTER VIEW ... RENAME TO ...
Truncate tableTRUNCATE TABLE ...
Seed dataINSERT INTO ... VALUES (...)
Raw SQLpassed through as-is

Example:

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");
}
}

You can change a column's nullability as long as the stored type stays the same. Changing a column's type in place is not supported.

DropCheckConstraint emits DROP CONSTRAINT, which CamusDB resolves against named CHECK and named NOT NULL constraints.

Comments​

Table and column comments use EF Core's built-in HasComment API. Index comments use the provider's HasComment extension:

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");
});

Migrations emit COMMENT ON ... when a comment changes. Removing a comment emits COMMENT ON ... IS NULL. Database comments have no EF Core model API, so use raw SQL:

migrationBuilder.Sql("COMMENT ON DATABASE app IS 'Application database'");

Comments are useful metadata for people and for AI agents that inspect the database through the CamusDB MCP server.

Views​

Map a read-only entity to a CamusDB view with ToView(...):

modelBuilder.Entity<OpenOrder>().HasNoKey().ToView("open_orders");

EF Core does not create view migrations automatically, so the provider exposes migration helpers:

protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateView(
"open_orders",
"SELECT id, customer, total FROM orders WHERE status = 'open'");

migrationBuilder.CreateView(
"order_summary",
"SELECT customer, SUM(total) AS total FROM orders GROUP BY customer",
columns: ["customer", "total"],
orReplace: true);

migrationBuilder.RenameView("open_orders", "active_orders");
}

protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropView("order_summary", ifExists: true);
migrationBuilder.DropView("active_orders", ifExists: true);
}

Views are read-only. Use HasNoKey() for a projection type and keep it out of SaveChanges(). A read through a view can use joins, aggregation, and the query optimizer, but a result-cache hint on a view-backed query is bypassed because a view expands to a derived source.

Materialized views are physical relations. Map them as tables for querying, but do not write through them. Refresh them with SQL:

await ctx.Database.ExecuteSqlRawAsync(
"REFRESH MATERIALIZED VIEW customer_totals");

Truncate tables​

Use TruncateTable(...) when you need to empty a whole base table without loading rows into the change tracker:

await ctx.Database.TruncateTableAsync("staging_imports");
ctx.ChangeTracker.Clear();

TRUNCATE TABLE runs outside an explicit transaction and CamusDB rejects it inside one. The table keeps its schema, indexes, grants, and name. Its previous contents remain recoverable during the server retention window through the orphan-table recovery workflow.

Relationships and foreign keys​

Relationships in the EF model become CamusDB FOREIGN KEY constraints through migrations and EnsureCreated():

modelBuilder.Entity<Customer>()
.HasMany(c => c.Orders)
.WithOne()
.HasForeignKey(o => o.CustomerId);

The server enforces the constraint at the end of each statement, inside the statement's transaction. A child row without a parent fails with CADB0304; a delete or update of a referenced parent key fails with CADB0305 or CADB0306.

CamusDB supports NO ACTION and RESTRICT server-side. EF Core defaults a required relationship to cascade, but the provider renders unsupported server actions as NO ACTION; EF still applies cascade or set-null behavior to dependents that are tracked in the context. If the dependents are not tracked, delete them first or use DeleteBehavior.Restrict.

Foreign keys need an index whose leading columns are the referencing columns. Migrations reuse the index EF creates for the relationship. EnsureCreated() lets the server create the needed index when the model has no explicit one.

Use UseForeignKeyConstraints(false) when you want EF relationships for ordering and navigation, but no database foreign-key constraints:

optionsBuilder.UseCamusDB(connectionString, o =>
o.UseForeignKeyConstraints(false));

Concurrency​

The provider supports two EF Core optimistic-concurrency styles.

[Timestamp] or IsRowVersion() on a byte[] property creates a provider-managed row-version token. The provider writes a fresh token on every insert and update, and EF includes the original token in the update predicate:

public sealed class Document
{
public string Id { get; set; } = "";
public string Body { get; set; } = "";

[Timestamp]
public byte[] Version { get; set; } = [];
}

A stale write raises DbUpdateConcurrencyException.

[ConcurrencyCheck] is also supported on numeric properties:

  • short
  • int
  • long

Example:

public sealed class Order
{
public string Id { get; set; } = "";
public string Status { get; set; } = "";

[ConcurrencyCheck]
public long Version { get; set; }
}

Increment the version column in your application code before saving:

order.Status = "shipped";
order.Version++;
await ctx.SaveChangesAsync();

Important behavior:

  • CamusDB detects write conflicts at transaction commit time.
  • SaveChangesAsync() can succeed and the conflict can still surface later at CommitTransactionAsync().
  • For optimistic concurrency, use an explicit numeric version column with [ConcurrencyCheck], or a provider-managed byte[] row version.

Current limitations​

The provider is useful today, but it doesn't emulate database features that CamusDB doesn't support.

Unsupported or restricted operations include:

  • foreign keys support NO ACTION and RESTRICT server-side. Cascade, SetNull, and SetDefault apply only to dependents tracked by EF.
  • foreign-key cycles of two or more tables are not supported. Self-references are supported.
  • no computed columns
  • ALTER COLUMN only supports toggling nullability; changing the stored type requires a manual migration strategy
  • no add/drop primary key through migrations
  • no LINQ predicate over an ARRAY(T) property; arrays can be read and written as whole values
  • no lateral join or automatic translation of per-row window query shapes; use raw SQL for window functions
  • ExecuteUpdate cannot set a value read from another table. Bulk update/delete shapes that use joins, correlated subqueries, or pagination need a single-column primary key.

Model restrictions:

  • key CLR types must be string, Guid, short, int, or long
  • [ConcurrencyCheck] is limited to numeric columns; use [Timestamp] or IsRowVersion() for provider-managed byte[] row versions
  • plain Guid maps to native UUID
  • vector embeddings are stored in bytes columns; there is no dedicated vector CLR/store type
  • a NUMERIC value with more significant digits than .NET decimal can hold throws when materialized into a decimal property. Read it as string when exact wide values are possible.
  • WithCache(...) is effective only on single-table autocommit reads