From 9c01c7820cde237974cfde2b195305f016afc6f9 Mon Sep 17 00:00:00 2001 From: jmorris Date: Mon, 10 Aug 2026 19:01:09 -0700 Subject: [PATCH] Update docs for 2.0.0-beta.3 Port everything that shipped since beta.2 into the published Antora site: a new concurrency page (CAS, META fields, read-your-own-writes/ConsistentWith), query hints, the supported-functions table, primitive collections, OwnsMany querying (Any/All/Count(predicate), indexer access), AutoCreateIndexes and HasIndex() secondary-index docs, DateTimeFormat and Unix-millis DateTime storage, cross-bucket sequence resolution, and the beta.3 release notes. Also removes the now-false "CAS not supported" limitation and bumps every version reference across the site. --- antora.yml | 2 +- modules/ROOT/nav.adoc | 1 + ...ty-framework-core-compatibility-guide.adoc | 2 +- .../entity-framework-core-concurrency.adoc | 187 ++++++++++++++++ .../entity-framework-core-configuration.adoc | 183 ++++++++++++++- .../entity-framework-core-limitations.adoc | 100 ++++++++- .../pages/entity-framework-core-modeling.adoc | 120 ++++++++++ .../pages/entity-framework-core-queries.adoc | 210 ++++++++++++++++++ .../entity-framework-core-release-notes.adoc | 81 +++++++ .../pages/entity-framework-core-sdk-api.adoc | 1 + .../entity-framework-core-sequences.adoc | 15 +- modules/ROOT/pages/overview.adoc | 1 + .../pages/start-using-efcore-provider.adoc | 2 +- 13 files changed, 891 insertions(+), 14 deletions(-) create mode 100644 modules/ROOT/pages/entity-framework-core-concurrency.adoc diff --git a/antora.yml b/antora.yml index 802e155..f037ee6 100644 --- a/antora.yml +++ b/antora.yml @@ -10,7 +10,7 @@ asciidoc: page-nav-header-levels: 2 server_version: '7.6.6' sdk_current_version: '3.9.3' - provider_current_version: '2.0.0-beta.2' + provider_current_version: '2.0.0-beta.3' sdk_dot_minor: '3.9' provider_dot_minor: '2.0' sdk_dot_major: '3.x' diff --git a/modules/ROOT/nav.adoc b/modules/ROOT/nav.adoc index c522dfb..ffea78a 100644 --- a/modules/ROOT/nav.adoc +++ b/modules/ROOT/nav.adoc @@ -5,6 +5,7 @@ * xref:ROOT:entity-framework-core-modeling.adoc[] * xref:ROOT:entity-framework-core-queries.adoc[] * xref:ROOT:entity-framework-core-crud-data.adoc[] +* xref:ROOT:entity-framework-core-concurrency.adoc[] * xref:ROOT:entity-framework-core-transactions.adoc[] * xref:ROOT:entity-framework-core-sequences.adoc[] * xref:ROOT:entity-framework-core-release-notes.adoc[Release Notes] diff --git a/modules/ROOT/pages/entity-framework-core-compatibility-guide.adoc b/modules/ROOT/pages/entity-framework-core-compatibility-guide.adoc index 35c01d3..f288f5f 100644 --- a/modules/ROOT/pages/entity-framework-core-compatibility-guide.adoc +++ b/modules/ROOT/pages/entity-framework-core-compatibility-guide.adoc @@ -10,7 +10,7 @@ .Compatibility [cols="1,1"] |=== -| Couchbase EF Core Provider | 2.0.0-beta.2 +| Couchbase EF Core Provider | 2.0.0-beta.3 | EF Core | 10.0.8 | .NET Version | .NET 10 ^①^ | Couchbase Server | >= 7.6.0 diff --git a/modules/ROOT/pages/entity-framework-core-concurrency.adoc b/modules/ROOT/pages/entity-framework-core-concurrency.adoc new file mode 100644 index 0000000..ad87bb9 --- /dev/null +++ b/modules/ROOT/pages/entity-framework-core-concurrency.adoc @@ -0,0 +1,187 @@ += Optimistic Concurrency and Document Metadata with the EF Core Couchbase DB Provider +:page-toclevels: 2 +:description: CAS-based optimistic concurrency, reading document metadata, and per-query read-your-own-writes. + + +[abstract] +{description} + + +Couchbase's KV API tracks a CAS (compare-and-swap) value on every document -- an opaque value that +changes on every mutation, the Couchbase equivalent of a SQL rowversion. {sqlpp} exposes this and +other per-document metadata through the +https://docs.couchbase.com/server/current/n1ql/n1ql-language-reference/indexing-meta-info.html[`META()` function]. +The provider maps `META()` fields onto ordinary shadow properties via `[CouchbaseMeta]`/ +`HasCouchbaseMeta`, most importantly CAS as an EF Core optimistic-concurrency token. + +Without a CAS-backed concurrency token, `SaveChangesAsync` performs an unconditional write -- +two concurrent updates to the same document silently overwrite each other, with no error and no +way for either caller to detect it. Opting into CAS-based concurrency closes that gap. + + +[#cas-as-a-concurrency-token] +== CAS as a concurrency token + +Add a `ulong` property, mark it with `[CouchbaseMeta(CouchbaseMetaField.Cas)]` *and* EF Core's own +`.IsConcurrencyToken()` -- both are required together, so calling `.IsConcurrencyToken()` on an +unrelated property never silently starts sending CAS checks: + +[source,csharp] +---- +public class Order +{ + public int Id { get; set; } + public string CustomerName { get; set; } = string.Empty; + + [CouchbaseMeta(CouchbaseMetaField.Cas)] + public ulong Cas { get; set; } +} +---- + +[source,csharp] +---- +modelBuilder.Entity() + .Property(e => e.Cas) + .IsConcurrencyToken(); +---- + +The property is populated automatically -- never set it yourself: + +* After `SaveChangesAsync` inserts or updates the entity, `Cas` is refreshed with the document's + new CAS, so a later `SaveChangesAsync` against the same tracked instance checks against the + correct value. +* Any query that reads the entity also reads its current CAS via `META(alias).cas`. + +When `SaveChangesAsync` sends an update or delete for an entity with a CAS-backed concurrency +token, it includes the CAS value it last read. If the document was modified or deleted by another +process in the meantime, Couchbase's compare-and-swap check fails and the provider throws EF +Core's own `DbUpdateConcurrencyException` -- the same exception type and handling pattern (reload, +merge, retry) EF Core applications already use for any other provider: + +[source,csharp] +---- +try +{ + await context.SaveChangesAsync(); +} +catch (DbUpdateConcurrencyException) +{ + // Reload the entity (and its Cas) and retry, or surface the conflict to the caller. +} +---- + + +[#reading-other-meta-fields] +== Reading other META() fields + +`[CouchbaseMeta(CouchbaseMetaField.Id)]` (a `string` property), +`[CouchbaseMeta(CouchbaseMetaField.Expiration)]` (a `long` property, Unix epoch seconds -- `0` +means no expiration), `[CouchbaseMeta(CouchbaseMetaField.Flags)]` (a `uint` property -- an opaque +value the SDK's KV layer uses to record the document's datatype), and +`[CouchbaseMeta(CouchbaseMetaField.Type)]` (a `string` property -- e.g. `"json"`) all work the +same way, but are read-only: the provider has no API for setting a document's key, TTL, flags, or +type on write. + +[source,csharp] +---- +public class Order +{ + public int Id { get; set; } + + [CouchbaseMeta(CouchbaseMetaField.Id)] + public string DocumentId { get; set; } = string.Empty; + + [CouchbaseMeta(CouchbaseMetaField.Expiration)] + public long ExpiresAt { get; set; } +} +---- + +A `[CouchbaseMeta]` property must be the exact CLR type its field requires (`ulong` for `Cas`, +`string` for `Id`/`Type`, `long` for `Expiration`, `uint` for `Flags`) -- applying it to any other +type throws `InvalidOperationException` at model-build time, and the fluent +`HasCouchbaseMeta(...)` form throws the same way. + +[WARNING] +==== +*Known Couchbase Server limitation:* don't put both `[CouchbaseMeta(CouchbaseMetaField.Flags)]` +and `[CouchbaseMeta(CouchbaseMetaField.Expiration)]` on the same queried entity. Projecting +`META(alias).flags` together with `META(alias).expiration` in one `SELECT` makes the Couchbase +Server query engine itself return `0` for `flags`, regardless of the document's real value -- +confirmed by issuing the exact {sqlpp} directly via the SDK and observing the wrong value already +present in the raw response, so this is not something this provider's SQL generation or +materialization causes or can work around. `Flags` reads back correctly alone, or combined with +`Cas`/`Id`/`Type` -- only the combination with `Expiration` is affected. +==== + +Not supported: `META().xattrs` (extended attributes). + + +[#read-your-own-writes-consistentwith] +== Read-your-own-writes (ConsistentWith) + +By default, queries use `NotBounded` scan consistency (see +xref:entity-framework-core-limitations.adoc[Limitations]): a document written a moment ago may not +yet be visible to a subsequent {sqlpp} query, because secondary (GSI) indexes update +asynchronously. Setting `ScanConsistency = RequestPlus` on the options builder fixes this for +*every* query on that context, but that's an all-or-nothing, context-wide switch -- every query +pays the extra latency of waiting for the index to catch up, even ones that don't need +read-your-own-writes at all. + +`ConsistentWith` scopes that guarantee to a specific write instead: it makes one query (or one +`FromSqlRaw`/`FromSql`/ADO.NET command) wait only until the index reflects a *specific* prior +mutation, not the whole collection's latest state. This is EF Core's provider-level surface over +the Couchbase SDK's own +https://docs.couchbase.com/dotnet-sdk/current/concept-docs/durability-replication-failure-considerations.html#at_plus[`MutationState`], +which the SDK internally represents as a set of `MutationToken`s (one per document write) and +resolves against by forcing `AT_PLUS` scan consistency. + +`SaveChangesAsync` automatically accumulates a `MutationState` on the `DbContext` from every +document it writes -- there's nothing to opt into on the write side: + +[source,csharp] +---- +context.Add(new Order { CustomerName = "Ada" }); +await context.SaveChangesAsync(); + +var mutationState = context.Database.GetMutationState(); +---- + +Pass that `MutationState` to `ConsistentWith` on the read side, across any of the three query +execution paths: + +[source,csharp] +---- +// LINQ -- ConsistentWith must be the LAST operator in the chain (see below). +var order = await context.Orders + .Where(o => o.CustomerName == "Ada") + .ConsistentWith(mutationState) + .SingleOrDefaultAsync(); + +// FromSqlRaw / FromSql +var orders = await context.Orders + .FromSqlRaw("SELECT o.* FROM `bucket`.`scope`.`orders` AS o WHERE o.customerName = {0}", "Ada") + .ConsistentWith(mutationState) + .ToListAsync(); + +// Raw ADO.NET +using var command = (CouchbaseCommand)connection.CreateCommand(); +command.ConsistentWith = mutationState; +command.CommandText = "SELECT o.* FROM `bucket`.`scope`.`orders` AS o WHERE o.customerName = $name"; +---- + +`context.Database.ClearMutationState()` resets the accumulated state (e.g. between logically +unrelated units of work sharing one long-lived context). + +[WARNING] +==== +On the LINQ path, `ConsistentWith(...)` must be the *last* operator in the query -- composing any +further LINQ operator after it (`.Where(...)`, `.OrderBy(...)`, another `.Select(...)`, etc.) +throws `InvalidOperationException` at query-translation time (a clear, immediate failure, not a +silently-ignored hint). Apply every other operator first, then call `.ConsistentWith(...)` last: +`context.Orders.Where(...).OrderBy(...).ConsistentWith(mutationState)`, not the reverse. +==== + +`MutationState` only ever grows narrower guarantees than `RequestPlus` -- it says "wait for +*these* writes to be indexed," not "wait for the whole collection to be caught up" -- so prefer +it over a context-wide `RequestPlus` whenever the read-after-write need is scoped to a specific +prior write rather than the collection as a whole. diff --git a/modules/ROOT/pages/entity-framework-core-configuration.adoc b/modules/ROOT/pages/entity-framework-core-configuration.adoc index 81fe6c4..80b86d3 100644 --- a/modules/ROOT/pages/entity-framework-core-configuration.adoc +++ b/modules/ROOT/pages/entity-framework-core-configuration.adoc @@ -96,6 +96,10 @@ set in the `couchbaseDbContextOptions` callback passed to `UseCouchbase`/`AddCou | `false` | Whether `EnsureCreatedAsync` automatically creates non-default scopes referenced by entity mappings. When `false`, collections mapped to a non-default scope are skipped (with a warning) instead of created. +| `AutoCreateIndexes` +| `false` +| Whether `EnsureCreatedAsync` automatically creates a primary index, and a secondary index for every `HasIndex()` declared on the model, on every collection it creates or already owns -- waiting for each index to come online before returning. See <> below. + | `FieldNamingPolicy` | `JsonNamingPolicy.CamelCase` | Controls how CLR navigation names are converted to JSON field names when reading/writing `OwnsMany` embedded collections. Set to `null` to use the CLR name verbatim (PascalCase), or supply a different policy such as `JsonNamingPolicy.SnakeCaseLower`. @@ -104,6 +108,10 @@ set in the `couchbaseDbContextOptions` callback passed to `UseCouchbase`/`AddCou | `null` (uses `JsonSerializerDefaults.Web`) | `JsonSerializerOptions` used when deserializing scalar values inside `OwnsMany` collections. Supply a custom instance to match a non-default serializer configured on the Couchbase SDK (custom converters, different enum handling, etc.). +| `DateTimeFormat` +| `"yyyy-MM-ddTHH:mm:ss.FFFK"` +| The .NET custom `DateTime` format string this provider assumes when generating or comparing against `DateTime` string values in {sqlpp} -- used by the LINQ `DateTime` function translators (`.Date`, `.Now`, `.UtcNow`, `.Today`) and for inline `DateTime` literals. See <> below. + | `ServiceKey` | `null` | Selects which application-registered, keyed Couchbase cluster this context uses -- see <>. @@ -114,10 +122,183 @@ set in the `couchbaseDbContextOptions` callback passed to `UseCouchbase`/`AddCou it has no effect on top-level entity property casing in generated {sqlpp} queries. That's a separate concern handled by <>. -If you change one, make sure it still matches the other (and your actual document casing), +If you change one, make sure it still matches the other (and your actual document casing), or fields will silently come back with default values instead of an error. +[#datetime-string-format] +== DateTime string format + +{sqlpp} has no native date type -- a `DateTime` is stored as a plain JSON string, so nothing stops +different data from using a different string convention (a different precision, a date-only +value, or data written by another system entirely). The `DateTimeFormat` option tells the +provider which convention your data actually uses, so `.Date`/`.Now`/`.UtcNow`/`.Today` +comparisons and generated `DateTime` literals stay correct instead of assuming one hardcoded +format. + +`DateTimeFormat` is a .NET custom `DateTime` format string. The default, +`"yyyy-MM-ddTHH:mm:ss.FFFK"`, matches this provider's own default `DateTime` serialization +(millisecond precision, `Z` for UTC or a real offset otherwise -- e.g. +`2026-03-14T09:26:53.123Z`). Only the following tokens are supported (the ISO-8601-relevant +subset -- internally converted to the equivalent +https://pkg.go.dev/time#pkg-constants[Go reference-time] layout {sqlpp}'s date functions expect): + +.Table DateTime format tokens +[cols="1,2"] +|=== +| Token | Meaning + +| `yyyy` | 4-digit year +| `MM` | 2-digit month +| `dd` | 2-digit day +| `HH` | 2-digit hour (24-hour) +| `mm` | 2-digit minute +| `ss` | 2-digit second +| `f` (repeated 1-7 times) | Fixed-width fractional seconds +| `F` (repeated 1-7 times) | Trimmed fractional seconds (dropped, along with the decimal point, when the value is exactly zero) +| `K` | `Z` for UTC, or a real `+hh:mm`/`-hh:mm` offset +| `T` | Literal `T` -- not a reserved .NET specifier, so it needs no quoting (unlike other letters) +| non-letter characters (`-`, `:`, `.`, space, etc.) | Passed through as literal separators +| `'...'` / `"..."` | A quoted literal string, copied through verbatim (needed to use a letter -- other than `T` -- as a literal, e.g. `'Z'`) +| `\x` | Escapes the next character `x` as a literal, usable inside or outside a quoted section +|=== + +Any other bare letter (`tt`, `ddd`, `zzz`, 12-hour `hh`, an un-quoted `Z`, etc.) throws an +`ArgumentException` at configuration time (when `DateTimeFormat` is set) naming the unsupported +token, rather than failing later with a confusing {sqlpp} error. Configure it alongside your +other provider options: + +[source,csharp] +---- +couchbaseDbContextOptions.DateTimeFormat = "yyyy-MM-dd"; // date-only convention +---- + +This is unrelated to `FieldNamingPolicy` -- `DateTimeFormat` controls how `DateTime` *values* are +compared/generated in {sqlpp}, while `FieldNamingPolicy` controls JSON *field name* casing. + +=== Per-property override + +`DateTimeFormat` applies to every `DateTime` property in the context by default, but a single +property can use a different convention -- via the `[DateTimeFormat]` attribute or the +`HasDateTimeFormat` fluent API -- without affecting any other property: + +[source,csharp] +---- +public class Order +{ + public int Id { get; set; } + public DateTime Placed { get; set; } // uses the context-wide DateTimeFormat + + [DateTimeFormat("yyyy-MM-dd")] + public DateTime ShipDate { get; set; } // date-only, independent of Placed +} +---- + +[source,csharp] +---- +// Equivalent fluent form, e.g. if you'd rather not put attributes on the entity: +modelBuilder.Entity() + .Property(o => o.ShipDate) + .HasDateTimeFormat("yyyy-MM-dd"); +---- + +The override only applies to the `.Date` member translator and inline `DateTime` literals for +that specific property -- the static `DateTime.Now`/`.UtcNow`/`.Today` translators have no +associated property to read an override from, so they always use the context-wide default even +in a query that also touches an overridden property. + + +[#unix-millis-datetime-storage] +== Unix-millis DateTime storage + +Some Couchbase data stores dates as Unix epoch milliseconds (a JSON `NUMBER`) rather than a +string. Mark a property with `[UnixMillisDateTime]` (or the `HasUnixMillisDateTime` fluent API) to +store and query it that way instead of this provider's default ISO-8601 string: + +[source,csharp] +---- +public class Event +{ + public int Id { get; set; } + + [UnixMillisDateTime] + public DateTime OccurredAt { get; set; } +} +---- + +[source,csharp] +---- +// Equivalent fluent form: +modelBuilder.Entity() + .Property(e => e.OccurredAt) + .HasUnixMillisDateTime(); +---- + +Under the hood this attaches a `ValueConverter` -- EF Core's own standard +value-conversion mechanism -- so normal read/write paths need no special handling. Query-side +member access (`.Year`, `.Date`, `Add*`) translates to {sqlpp}'s `_MILLIS` date-function family +(`DATE_PART_MILLIS`/`DATE_TRUNC_MILLIS`/`DATE_ADD_MILLIS`) instead of the `_STR` family used for +the default string representation -- see +xref:entity-framework-core-queries.adoc#supported-functions[Supported functions]. + +*Comparing against `DateTime.UtcNow`/`.Now`/`.Today` directly is not supported.* These static +members have no associated property, so they always translate to the `_STR`-family functions +regardless of what they're compared against -- each side of a comparison is translated +independently, with the static member's {sqlpp} function chosen before any binary-expression-level +context exists to know it's being compared against a millis column. Comparing a +`[UnixMillisDateTime]` property directly against one of them throws `NotSupportedException` at +query-translation time rather than silently comparing a `NUMBER` against a `_STR` function's +string result. Capture the value into a local variable before the query instead: + +[source,csharp] +---- +var now = DateTime.UtcNow; +var recent = await context.Events.Where(e => e.OccurredAt > now).ToListAsync(); +---- + +A captured local becomes a query parameter, which correctly infers the millis conversion from the +property it's compared against -- unlike the static members, which never see that context. + + +[#secondary-indexes-hasindex] +== Secondary indexes (HasIndex()) + +When `AutoCreateIndexes` is `true`, `EnsureCreatedAsync` also creates a {sqlpp} secondary (GSI) +index for every `HasIndex()` declared on the model, in addition to the primary index: + +[source,csharp] +---- +modelBuilder.Entity(b => +{ + b.ToCouchbaseCollection("bucket", "scope", "post"); + b.HasIndex(p => p.Score).HasDatabaseName("ix_post_score"); + b.HasIndex(p => new { p.Category, p.Score }).HasDatabaseName("ix_post_category_score"); + b.HasIndex(p => p.Score).HasDatabaseName("ix_post_active_score").HasFilter("`Status` = 'active'"); +}); +---- + +This generates `CREATE INDEX IF NOT EXISTS ON \`bucket\`.\`scope\`.\`collection\`(field[, +field...]) [WHERE ]` for each index and waits for it to report online, the same way +primary index creation does. A few things to know: + +* *An explicit index name is required.* Unlike `CREATE PRIMARY INDEX` (which is anonymous), {sqlpp} + requires every secondary index to have a name -- always call `.HasDatabaseName(...)`. +* *`HasFilter(...)` takes a raw {sqlpp} predicate string*, spliced verbatim into the generated + `WHERE` clause (the same convention SqlServer/Sqlite treat it under) -- it is not translated from + a LINQ expression. +* *`.IsUnique()` is a no-op, logged as a warning.* {sqlpp} secondary indexes have no concept of a + unique constraint the way a relational index does -- the index is still created (as a plain, + non-unique index), just without the enforcement. Enforce uniqueness in application code if you + need it. +* *Only indexes on the entity's own direct (non-owned) properties are auto-created.* An index + referencing a property declared on an owned type (`OwnsOne`/`OwnsMany`) isn't resolvable to a + single JSON field path on the root document in this pass -- it is skipped with a warning; create + it manually instead. +* Index field names are taken from `GetColumnName()` verbatim -- root-level entity properties are + unaffected by `FieldNamingPolicy` (that option only applies to `OwnsMany` embedded collection + fields). + + [#multiple-buckets-and-clusters] == Multiple buckets and clusters diff --git a/modules/ROOT/pages/entity-framework-core-limitations.adoc b/modules/ROOT/pages/entity-framework-core-limitations.adoc index 9346796..153f4ef 100644 --- a/modules/ROOT/pages/entity-framework-core-limitations.adoc +++ b/modules/ROOT/pages/entity-framework-core-limitations.adoc @@ -15,10 +15,36 @@ Use `context.Database.EnsureCreatedAsync()` instead. * https://learn.microsoft.com/en-us/ef/core/modeling/relational/tables#table-per-type-configuration[Table-per-type (TPT)] and https://learn.microsoft.com/en-us/ef/core/modeling/inheritance#table-per-concrete-type-configuration[table-per-concrete-type (TPC)] inheritance -- table-per-hierarchy (TPH) is supported. +* *`EnsureCreatedAsync` does not create any index by default.* It always creates the bucket's +scopes and collections (and any configured sequences -- see +xref:entity-framework-core-sequences.adoc[Sequences and generated values]). LINQ, `FromSqlRaw`/`FromSql`, +and `ExecuteUpdate`/`ExecuteDelete` all run as {sqlpp} queries under the hood, and Couchbase's +query service refuses to query a collection that has no primary or secondary index at all -- set +`AutoCreateIndexes = true` on the options builder to have `EnsureCreatedAsync` also create a +primary index on every collection it creates or already owns, plus a secondary index for every +`HasIndex()` declared on the model, waiting for each one to come online before returning (see +xref:entity-framework-core-configuration.adoc#secondary-indexes-hasindex[Secondary indexes (HasIndex())]). +This defaults to `false`, so by default you must still create at least a primary index yourself: ++ +[source,sqlpp] +---- +CREATE PRIMARY INDEX IF NOT EXISTS ON `bucket`.`scope`.`collection` +---- ++ +A primary index is enough to get started but scans the whole collection; for real workloads, use +`HasIndex()` (or create secondary indexes manually) on the fields you filter/sort/join by instead. +`HasIndex()` support has some gaps: *{sqlpp} secondary indexes have no unique-constraint concept*, +so `.IsUnique()` is a no-op (logged as a warning) rather than enforced, and *an index referencing a +property declared on an owned type (`OwnsOne`/`OwnsMany`) is not auto-created* -- it's skipped with +a warning since it isn't resolvable to a single JSON field path on the root document in this pass; +create it manually instead. See +xref:entity-framework-core-configuration.adoc#secondary-indexes-hasindex[Secondary indexes (HasIndex())] +for the full picture, including the required explicit index name. + * Any features not explicitly mentioned in this documentation [NOTE] -Eager loading/fetching (`Include`/`ThenInclude`, filtered includes, `AutoInclude`), +Eager loading/fetching (`Include`/`ThenInclude`, filtered includes, `AutoInclude`), value generation (server-side sequences and generated GUIDs), and owned-type table splitting are all supported as of `{provider_current_version}` -- see xref:entity-framework-core-modeling.adoc[Modeling] and xref:entity-framework-core-sequences.adoc[Sequences and generated values]. @@ -26,15 +52,16 @@ see xref:entity-framework-core-modeling.adoc[Modeling] and xref:entity-framework == Unsupported Couchbase Features * All queries use xref:dotnet-sdk:concept-docs:n1ql-query.adoc#index-consistency[NOT_BOUNDED] by default. -This means that the query will not wait for the index to be updated before returning results. -Set `RequestPlus` on the options builder for read-after-write consistency. +This means that the query will not wait for the index to be updated before returning results. +Set `RequestPlus` on the options builder for read-after-write consistency across every query on +that context, or use `ConsistentWith`/`GetMutationState()` for a cheaper, narrower guarantee +scoped to a specific prior write -- see +xref:entity-framework-core-concurrency.adoc#read-your-own-writes-consistentwith[Read-your-own-writes]. * Only xref:dotnet-sdk:howtos:n1ql-queries-with-sdk.adoc#query-options[default values] are used for all queries generated and executed against Couchbase. * Only xref:dotnet-sdk:ref:client-settings.adoc[default values] are used for K/V CRUD operations. -* xref:dotnet-sdk:howtos:concurrent-document-mutations.adoc[Compare and swap (CAS)] is not currently supported. - * xref:dotnet-sdk:howtos:concurrent-document-mutations.adoc#pessimistic-locking[Pessimistic locking] is not currently supported. * xref:dotnet-sdk:concept-docs:encryption.adoc[Field level encryption] is not currently supported. @@ -47,13 +74,24 @@ Set `RequestPlus` on the options builder for read-after-write consistency. [NOTE] -Multi-document transactions -- +==== +xref:dotnet-sdk:howtos:concurrent-document-mutations.adoc[Compare and swap (CAS)]-based optimistic +concurrency is supported as of `{provider_current_version}`, via `[CouchbaseMeta(CouchbaseMetaField.Cas)]` +combined with EF Core's own `.IsConcurrencyToken()`. `META().id`/`.expiration`/`.flags`/`.type` are +also readable via the same `[CouchbaseMeta]` mechanism, but read-only. `META().xattrs` (extended +attributes) is still not supported. See xref:entity-framework-core-concurrency.adoc[Optimistic +concurrency and document metadata]. +==== + +[NOTE] +Multi-document transactions -- including transactions spanning more than one bucket on the same cluster, with a configurable xref:dotnet-sdk:concept-docs:durability-replication-failure-considerations.adoc#durability[durability level] -- are supported as of `{provider_current_version}`. See xref:entity-framework-core-transactions.adoc[Transactions]. +[#only-async-queries-supported] == Only Async Queries Supported Limitations on the Couchbase SDK mean that only async queries are supported. @@ -127,10 +165,56 @@ including field-backed access and value converters on owned properties. [NOTE] Because Couchbase has no concept of foreign keys or cascading deletes, related *entities* (not -owned types) -- -for example a one-to-many relationship modeled as a separate `DbSet` rather than an owned type -- +owned types) -- +for example a one-to-many relationship modeled as a separate `DbSet` rather than an owned type -- are not automatically deleted when the parent is deleted unless `DeleteBehavior.Cascade` is configured, same as any EF Core provider. +=== Querying nested owned data + +`.Any(predicate)`/`.All(predicate)`/`.Count(predicate)` over an `OwnsMany` navigation are +supported at any nesting depth -- both a collection declared directly on the entity being queried +and the *nested* case, reached through another owned navigation (e.g. +`c.ContactMethods.Any(m => m.Tags.Any(t => t.Key == "priority"))`, or deeper). An indexer access +can also appear as the innermost predicate (e.g. `c.ContactMethods.Any(m => m.Tags[0].Key == +"priority")`). See xref:entity-framework-core-modeling.adoc#ownsmany[Modeling -- OwnsMany]. + +*`.Contains()` directly on an `OwnsMany` navigation is not supported* -- this is a crash inside EF +Core's own core query-translation code, not a gap in this provider's SQL generation, and would +reproduce for any relational provider once the owned collection's key is composite (the default +for an owned type). Use `.Any(predicate)` comparing individual properties instead. See +xref:entity-framework-core-modeling.adoc#ownsmany[Modeling -- OwnsMany]. + +*Indexer/`.ElementAt()` over a scalar primitive collection (`List`/`T[]`, not `OwnsMany`) has no +ordering guarantee beyond direct array position*, and always behaves like +`.ElementAtOrDefault()` (an out-of-range or negative index returns the default value rather than +throwing) -- this Couchbase Server version has no {sqlpp} syntax for a deterministic positional +binding over an unnested array. `.OrderBy(...).ElementAt(...)`/`.Where(...).ElementAt(...)` +compositions and reverse-`.Contains()` over a local in-memory collection are not supported for a +primitive collection source. See +xref:entity-framework-core-modeling.adoc#primitive-collections[Modeling -- Primitive collections]. + +Indexer/`.ElementAt()` over a depth-1 `OwnsMany` navigation (e.g. `customer.ContactMethods[0].Type`) +has the same `.ElementAtOrDefault()`-like out-of-range behavior and the same +`.Where(...).ElementAt(...)`-composition limitation as a primitive collection's indexer. + +*A direct chained indexer through two levels of `OwnsMany` is not supported* (e.g. +`customer.ContactMethods[0].Tags[0].Key`) -- this is a crash inside EF Core's own core +query-translation code (`InvalidOperationException`, "could not be translated"), not a gap in this +provider's SQL generation, the same class of limitation as `.Contains()` over an `OwnsMany` +navigation above. Use `.Any(predicate)` with an inner indexer instead (e.g. +`customer.ContactMethods.Any(m => m.Tags[0].Key == "priority")`), which is fully supported. See +xref:entity-framework-core-modeling.adoc#ownsmany[Modeling -- OwnsMany]. + +*A `[UnixMillisDateTime]`-mapped property cannot be compared directly against +`DateTime.UtcNow`/`.Now`/`.Today`* -- these static members have no associated property, so they +always translate to the string-based date-function family regardless of what they're compared +against, and the comparison throws `NotSupportedException` at query-translation time rather than +silently comparing a `NUMBER` against a string. Capture the value into a local variable before the +query instead. `HasIndex()` on a `[UnixMillisDateTime]` property auto-creates a secondary index +normally (it's just a `NUMBER` field like any other), but whether the index's field order matches +your queries' access patterns is, as with any index, your own responsibility. See +xref:entity-framework-core-configuration.adoc#unix-millis-datetime-storage[Unix-millis DateTime storage]. + == Avoid Dynamic or Object Type Properties diff --git a/modules/ROOT/pages/entity-framework-core-modeling.adoc b/modules/ROOT/pages/entity-framework-core-modeling.adoc index 94a2cd0..35d4751 100644 --- a/modules/ROOT/pages/entity-framework-core-modeling.adoc +++ b/modules/ROOT/pages/entity-framework-core-modeling.adoc @@ -340,6 +340,7 @@ if the nested object is absent (e.g. a document this provider wrote, which uses the flat-column values are used as normal; nothing needs to be configured differently for either case, and both can coexist across different documents in the same collection. +[#ownsmany] === OwnsMany [source,csharp] @@ -365,6 +366,81 @@ modelBuilder.Entity().OwnsMany(c => c.ContactMethods, cm => The collection property can be typed as `List` or `HashSet` -- both are fully supported. +`.Any(predicate)` and predicate-less `.Any()` are supported for a depth-1 `OwnsMany` navigation +(a collection declared directly on the entity being queried), translating to {sqlpp}'s +`ANY x IN parentAlias.field SATISFIES ... END` rather than a real `EXISTS` subquery, since the +collection is a JSON array already embedded in the current document rather than a separate +keyspace to correlate against: + +[source,csharp] +---- +var withPhone = await context.Customers + .Where(c => c.ContactMethods.Any(m => m.Type == "phone")) + .ToListAsync(); +---- + +`.All(predicate)` and `.Count(predicate)` (with or without a predicate) are also supported for a +depth-1 `OwnsMany` navigation: + +[source,csharp] +---- +var allEmail = await context.Customers + .Where(c => c.ContactMethods.All(m => m.Type == "email")) + .ToListAsync(); + +var twoOrMorePhones = await context.Customers + .Where(c => c.ContactMethods.Count(m => m.Type == "phone") >= 2) + .ToListAsync(); +---- + +`.All(predicate)` translates via the same `ANY ... SATISFIES ... END` mechanism as `.Any()` (EF +Core itself expresses `.All(predicate)` as a negated `.Any(x => !predicate(x))`, so it needs no +separate translation). `.Count(predicate)`/`.Count()` translate to a correlated +`(SELECT RAW COUNT(*) FROM parentAlias.field AS alias [WHERE predicate])[0]` subquery. + +*`.Contains()` directly on an `OwnsMany` navigation is not supported* -- not a limitation of this +provider's SQL generation, but a crash inside EF Core's own core query-translation code +(`RelationalSqlTranslatingExpressionVisitor.ParameterValueExtractor`) that reproduces for any +relational provider once the owned collection's key is composite (owner key + declared key, EF +Core's default for an owned type): it can't read the shadow foreign-key property's value off an +arbitrary (tracked or untracked) `ContactMethod` instance to build the comparison, since a shadow +property has no CLR getter to read from. `.Any(predicate)` (comparing individual properties, not +the whole entity) is the supported alternative. + +`.Any(predicate)`/`.All(predicate)`/`.Count(predicate)` also work when the target collection is +reached through *another* owned navigation (nested, depth > 1), e.g.: + +[source,csharp] +---- +var withPriorityTag = await context.Customers + .Where(c => c.ContactMethods.Any(m => m.Tags.Any(t => t.Key == "priority"))) + .ToListAsync(); +---- + +This needs no special-cased translation: each level's `ANY ... SATISFIES ... END` (or correlated +`COUNT` subquery) is rendered relative to the enclosing level's own array alias, so the same +mechanism just recurses naturally to any depth. An indexer access can also appear as the +innermost predicate, e.g. `c.ContactMethods.Any(m => m.Tags[0].Key == "priority")`. + +Indexer/`.ElementAt()` access is also supported for a depth-1 `OwnsMany` navigation, translating +to {sqlpp}'s native `parentAlias.field[index].propertyName` array subscript: + +[source,csharp] +---- +var customersWithEmailFirst = await context.Customers + .Where(c => c.ContactMethods[0].Type == "email") + .ToListAsync(); +---- + +As with a scalar <>'s indexer, this always behaves +like `.ElementAtOrDefault()` -- an out-of-range index is excluded/returns default rather than +throwing. `.Where(...).ElementAt(...)` composed before the index is not supported. *A direct +chained indexer through two levels of `OwnsMany`* (e.g. `customer.ContactMethods[0].Tags[0].Key`, +as opposed to an indexer appearing inside a `.Any(predicate)` as shown above) *is not +supported* -- this fails inside EF Core's own core query-translation code before any +Couchbase-specific code runs, the same class of limitation as `.Contains()` above. Use +`.Any(predicate)` with an inner indexer instead. + === Field-backed access If an owned type's properties are get-only (backed by a private field, with no public setter), @@ -410,3 +486,47 @@ modelBuilder.Entity().OwnsMany(c => c.Contacts, cm => cm.Property(c => c.Note).HasConversion(new NullToSentinelConverter()); }); ---- + + +[#primitive-collections] +== Primitive collections + +A `List`/`T[]` property of a scalar element type (`string`, numeric types, `bool`, `Guid`, +`DateTime`) mapped directly on an entity -- not via `OwnsMany` -- is stored as a native JSON array +field, no configuration required: + +[source,csharp] +---- +public class Hotel +{ + public int Id { get; set; } + public List PublicLikes { get; set; } = []; +} +---- + +The following LINQ operators are supported over a primitive collection property: + +[source,csharp] +---- +// Indexer / .ElementAt() +context.Hotels.Where(h => h.PublicLikes[0] == "Alice"); + +// .Contains() +context.Hotels.Where(h => h.PublicLikes.Contains("Bob")); + +// .Count +context.Hotels.Where(h => h.PublicLikes.Count == 3); + +// .Any(predicate) +context.Hotels.Where(h => h.PublicLikes.Any(n => n.StartsWith("Car"))); +---- + +Indexer/`.ElementAt()` access always behaves like `.ElementAtOrDefault()` -- an out-of-range or +negative index returns the element type's default value rather than throwing, since {sqlpp}'s +array subscript returns `MISSING` (falsy) for an out-of-range position instead of erroring. + +*Limitations*: there is no supported way to observe or rely on array position beyond direct +indexing -- `.OrderBy(...).ElementAt(...)` and `.Where(...).ElementAt(...)` compositions, and +reverse-`.Contains()` over a local in-memory collection (`someList.Contains(h.SomeProperty)`), are +not supported for a primitive collection source. See +xref:entity-framework-core-limitations.adoc[Limitations]. diff --git a/modules/ROOT/pages/entity-framework-core-queries.adoc b/modules/ROOT/pages/entity-framework-core-queries.adoc index eee0d63..8ae720f 100644 --- a/modules/ROOT/pages/entity-framework-core-queries.adoc +++ b/modules/ROOT/pages/entity-framework-core-queries.adoc @@ -58,6 +58,81 @@ INNER JOIN `Blogging`.`MyBlog`.`Person` AS `p0` ON `p`.`PersonPhotoId` = `p0`.`P ---- +[#query-hints-use-index--use-hash] +== Query hints (USE INDEX / USE HASH) + +{sqlpp} lets a query nudge the optimizer per keyspace reference -- force a specific secondary +index instead of letting the planner choose (`USE INDEX`), or force a hash-join strategy for a +specific join and pick which side builds the hash table vs. probes it (`USE HASH`), instead of the +default nested-loop join +(https://docs.couchbase.com/server/current/n1ql/n1ql-language-reference/hints.html[reference]). +Both are exposed as `IQueryable` extension methods, mirroring {sqlpp}'s own per-FROM-term +placement: + +[source,csharp] +---- +using Couchbase.EntityFrameworkCore.Extensions; + +// USE INDEX -- only valid on the root (primary) keyspace of a query, not after a join. +var highScores = await context.Posts + .UseIndex("post_score_idx") + .Where(p => p.Score >= 20) + .ToListAsync(); + +// A null name broadens the hint to "any index of the given type" (USE INDEX(USING GSI) with no name). +var anyGsi = await context.Posts.UseIndex(null).ToListAsync(); + +// USE HASH -- apply to the inner (right-hand) sequence of a join, before the join itself. +var query = context.Posts.Join( + context.Authors.UseHash(CouchbaseHashHintType.Build), + p => p.AuthorId, + a => a.Id, + (p, a) => new { p.Title, a.Name }); +---- + +This generates: + +[source,sqlpp] +---- +SELECT `p`.`Title`, `a`.`Name` +FROM `Blogging`.`MyBlog`.`Post` AS `p` +INNER JOIN `Blogging`.`MyBlog`.`Author` AS `a` USE HASH(BUILD) ON `p`.`AuthorId` = `a`.`Id` +---- + +`UseIndex`'s second parameter is a `CouchbaseIndexType` (`Gsi` -- the default -- or `Fts`), and +`UseHash`'s parameter is a `CouchbaseHashHintType` (`Build` or `Probe`). + +Both are optimizer nudges, not correctness requirements -- a query returns identical results +whether or not the hint is honored. Calling either method on a non-Couchbase (e.g. in-memory) +queryable is a silent, benign no-op. + + +== Read-your-own-writes (ConsistentWith) + +By default, queries use `NotBounded` scan consistency, so a document written a moment ago may not +yet be visible to a subsequent query (see xref:entity-framework-core-limitations.adoc[Limitations]). +`.ConsistentWith(mutationState)` scopes a read-after-write guarantee to one query, for a specific +prior write, instead of switching the whole context to `RequestPlus`: + +[source,csharp] +---- +using Couchbase.EntityFrameworkCore.Extensions; + +await context.SaveChangesAsync(); +var mutationState = context.Database.GetMutationState(); + +// Must be the LAST operator in the chain -- composing anything after it throws. +var order = await context.Orders + .Where(o => o.CustomerName == "Ada") + .ConsistentWith(mutationState) + .SingleOrDefaultAsync(); +---- + +Also supported on `FromSqlRaw`/`FromSql` and raw ADO.NET commands. See +xref:entity-framework-core-concurrency.adoc#read-your-own-writes-consistentwith[Read-your-own-writes] +for the full explanation, including why the operator must come last. + + == FirstAsync [source,csharp] @@ -163,6 +238,141 @@ The following aggregate operators are supported by the 1.0 release: Other operators may or may not be supported in the 1.0 release. + +[#supported-functions] +== Supported functions + +Beyond the string methods available since 1.0 (`ToLower`/`ToUpper`, `Substring`, `Replace`, +`Trim`/`TrimStart`/`TrimEnd`, `Contains`), the provider translates the following .NET members to +{sqlpp} so they run server-side instead of throwing or falling back to client evaluation: + +.Table Supported functions +[cols="1,1"] +|=== +| .NET | {sqlpp} + +| `string.IndexOf(s)` | `POSITION(x, s)` +| `string.StartsWith(s)` | `LIKE` (pattern-escaped) +| `string.EndsWith(s)` | `LIKE` (pattern-escaped) +| `string.IsNullOrEmpty(x)` | `x IS NULL OR x = ''` +| `string.PadLeft(n)` / `PadRight(n)` | `LPAD(x, n)` / `RPAD(x, n)` +| `string.Length` | `LENGTH(x)` +| `Math.Abs/Ceiling/Floor/Sqrt/Sign(x)` | `ABS/CEIL/FLOOR/SQRT/SIGN(x)` +| `Math.Round(x[, d])` | `ROUND(x[, d])` +| `Math.Truncate(x)` | `TRUNC(x)` +| `Math.Pow(x, y)` | `POWER(x, y)` +| `Math.Log(x)` / `Log10(x)` / `Exp(x)` | `LN(x)` / `LOG(x)` / `EXP(x)` +| `Math.Log(x, newBase)` | `LN(x) / LN(newBase)` +| `Math.Sin/Cos/Tan/Asin/Acos/Atan(x)` | `SIN/COS/TAN/ASIN/ACOS/ATAN(x)` +| `Math.Atan2(y, x)` | `ATAN2(y, x)` +| `Math.Min(a, b)` / `Math.Max(a, b)` | `ARRAY_MIN([a, b])` / `ARRAY_MAX([a, b])` +| `EF.Functions.Least(...)` / `EF.Functions.Greatest(...)` | `ARRAY_MIN([...])` / `ARRAY_MAX([...])` +| `DateTime.Year/Month/Day/Hour/Minute/Second/Millisecond/DayOfWeek/DayOfYear` | `DATE_PART_STR(x, part)` +| `DateTime.Date` | `DATE_TRUNC_STR(x, 'day', fmt)` +| `DateTime.Now` | `NOW_LOCAL(fmt)` +| `DateTime.UtcNow` | `NOW_UTC(fmt)` +| `DateTime.Today` | `DATE_TRUNC_STR(NOW_UTC(fmt), 'day', fmt)` +| `DateTime.AddYears/Months/Days/Hours/Minutes/Seconds(n)` | `DATE_ADD_STR(x, n, part)` +| `Guid.NewGuid()` | `UUID()` +| `a ?? b` | `IFMISSINGORNULL(a, b)` +| `string.Compare(a, b)` / `a.CompareTo(b)` | `CASE WHEN a = b THEN 0 WHEN a > b THEN 1 WHEN a < b THEN -1 END` +| `EF.Functions.IsMissing(x)` / `IsNotMissing(x)` | `(x) IS MISSING` / `(x) IS NOT MISSING` +| `EF.Functions.IsValued(x)` / `IsNotValued(x)` | `(x) IS VALUED` / `(x) IS NOT VALUED` +|=== + +C#'s `??` translates to {sqlpp}'s `IFMISSINGORNULL`, not a generic `COALESCE` (which {sqlpp} +doesn't have) -- this is also the semantically correct choice, not just a renaming: a Couchbase +document field can be genuinely *missing* (absent from the JSON entirely), not just JSON `null`, +and `IFMISSINGORNULL` is the only {sqlpp} null-handling function that treats both the way `??` +does. + +`EF.Functions.IsMissing`/`IsNotMissing`/`IsValued`/`IsNotValued` let a query distinguish those same +two cases explicitly, rather than folding them together the way `??`/`== null` do. `x == null` +matches a field that's present with a JSON `null` *and* (per the same missing-is-falsy {sqlpp} +semantics `??` relies on) a field that's genuinely missing -- if you need to tell those two apart, +or you specifically want "field is present with a real, non-null value," use these instead: + +[source,csharp] +---- +var missing = await context.Posts.Where(p => EF.Functions.IsMissing(p.Title)).ToListAsync(); +var hasRealValue = await context.Posts.Where(p => EF.Functions.IsValued(p.Title)).ToListAsync(); +---- + +These have no client-side (in-memory) implementation -- they can only be used inside a LINQ query +that gets translated to {sqlpp}; calling one directly throws `InvalidOperationException`. + +`string.Compare`/`.CompareTo` only produce the `CASE WHEN` shown above when the raw `int` result +is actually used (e.g. projected in a `Select`). The common `string.Compare(a, b) > 0` / +`a.CompareTo(b) == 0` shape is simplified directly to `a > b` / `a = b` before translation, with no +`CASE` involved at all. + +`StartsWith`/`EndsWith` escape `%`/`_`/the escape character in the search value (constant patterns +are escaped once at translation time; parameter/column patterns are escaped at query time via +nested `REPLACE` calls) so a literal `%` or `_` in the search text is matched literally rather than +treated as a wildcard. + +`fmt` in the table above is the +xref:entity-framework-core-configuration.adoc#datetime-string-format[`DateTimeFormat`] option +(converted to the equivalent Go layout {sqlpp}'s date functions expect) -- it defaults to this +provider's own default `DateTime` serialization (millisecond precision, e.g. +`2026-03-14T09:26:53.123Z`, with the fractional-seconds group and its decimal point entirely +omitted when milliseconds are exactly zero, e.g. `2026-03-14T00:00:00Z`), but is configurable if +your data uses a different string convention -- see +xref:entity-framework-core-configuration.adoc#datetime-string-format[DateTime string format]. + +A `DateTime` property marked +xref:entity-framework-core-configuration.adoc#unix-millis-datetime-storage[`[UnixMillisDateTime]`] +is stored as a JSON `NUMBER` (Unix epoch milliseconds) instead of an ISO-8601 string, and its +`.Year`/`.Month`/etc./`.Date`/`Add*` members instead translate to {sqlpp}'s `_MILLIS` date-function +family (`DATE_PART_MILLIS`/`DATE_TRUNC_MILLIS`/`DATE_ADD_MILLIS`) rather than the `_STR` family +shown above -- see +xref:entity-framework-core-configuration.adoc#unix-millis-datetime-storage[Unix-millis DateTime storage] +for the comparison-against-`DateTime.UtcNow` caveat. + +`Math.Min`/`Math.Max` and `EF.Functions.Least`/`Greatest` translate to {sqlpp}'s +`ARRAY_MIN`/`ARRAY_MAX` functions, which take a single array argument rather than N scalar +arguments -- the provider builds an inline array literal (`[a, b, ...]`) to bridge the two. +`EF.Functions.Least`/`Greatest` accept any number of arguments; a chain of +`Math.Max(Math.Max(a, b), c)`-style calls is automatically flattened by EF Core into a single +N-ary `ARRAY_MAX([a, b, c])` rather than nesting. + +EF Core's `HasIndex()` is supported for auto-creating {sqlpp} secondary indexes via +`EnsureCreatedAsync` -- see +xref:entity-framework-core-configuration.adoc#secondary-indexes-hasindex[Secondary indexes (HasIndex())]. + + +[#primitive-collections] +== Primitive collections + +A scalar `List`/`T[]` property mapped directly on an entity (not via `OwnsMany`) -- see +xref:entity-framework-core-modeling.adoc#primitive-collections[Primitive collections] -- supports +indexer/`.ElementAt()`, `.Contains()`, `.Count`, and `.Any(predicate)`: + +[source,csharp] +---- +context.Hotels.Where(h => h.PublicLikes[0] == "Alice"); +context.Hotels.Where(h => h.PublicLikes.Contains("Bob")); +context.Hotels.Where(h => h.PublicLikes.Count == 3); +context.Hotels.Where(h => h.PublicLikes.Any(n => n.StartsWith("Car"))); +---- + +Indexer/`.ElementAt()` translates directly to {sqlpp}'s native array-subscript syntax +(`field[index]`); `.Contains()`/`.Count`/`.Any(predicate)` translate to a correlated subquery over +the array field. Indexer/`.ElementAt()` always behaves like `.ElementAtOrDefault()` -- an +out-of-range or negative index returns the element type's default rather than throwing, matching +{sqlpp}'s own `MISSING`-for-out-of-range semantics. + +`.OrderBy(...).ElementAt(...)`/`.Where(...).ElementAt(...)` compositions and reverse-`.Contains()` +over a local in-memory collection are not supported for a primitive collection source -- see +xref:entity-framework-core-limitations.adoc[Limitations]. + +Indexer/`.ElementAt()` is also supported over a depth-1 `OwnsMany` navigation (e.g. +`customer.ContactMethods[0].Type`), translating to the same native array-subscript approach, as +are `.Any(predicate)`, `.All(predicate)`, and `.Count(predicate)` -- see +xref:entity-framework-core-modeling.adoc#ownsmany[Modeling -- OwnsMany] (including why +`.Contains()` directly on an `OwnsMany` navigation is not supported). + + == SQL queries [NOTE] diff --git a/modules/ROOT/pages/entity-framework-core-release-notes.adoc b/modules/ROOT/pages/entity-framework-core-release-notes.adoc index ad625b2..b0369e1 100644 --- a/modules/ROOT/pages/entity-framework-core-release-notes.adoc +++ b/modules/ROOT/pages/entity-framework-core-release-notes.adoc @@ -30,6 +30,87 @@ any changes to expected behavior are noted in the release notes that follow. +=== Version 2.0.0-beta.3 (10 August 2026) + +Version https://jira.issues.couchbase.com/issues/?jql=project%20%3D%20%22CBEF%22%20AND%20fixVersion%20%3D%20%222.0.0-beta.3%22[2.0.0-beta.3] adds: + +* *Per-query read-your-own-writes* (`ConsistentWith`/`MutationState`) -- +`SaveChangesAsync` automatically accumulates a `MutationState` on the `DbContext` from its own +writes; `.ConsistentWith(mutationState)` scopes a read-after-write guarantee to one query, across +LINQ, `FromSqlRaw`/`FromSql`, and raw ADO.NET -- cheaper and narrower than a context-wide +`RequestPlus`. +See xref:entity-framework-core-concurrency.adoc#read-your-own-writes-consistentwith[Read-your-own-writes]. + +* *`META()` support: document metadata and CAS-based optimistic concurrency* -- +`[CouchbaseMeta(CouchbaseMetaField)]`/`HasCouchbaseMeta(...)` sources a property from +`META(alias).id`/`.cas`/`.expiration`/`.flags`/`.type`. Combined with EF Core's own +`.IsConcurrencyToken()`, a `ulong` CAS property becomes a real optimistic-concurrency token -- +`SaveChangesAsync` throws `DbUpdateConcurrencyException` on a CAS mismatch instead of silently +overwriting a concurrent change. +See xref:entity-framework-core-concurrency.adoc[Optimistic concurrency and document metadata]. + +* *`UseIndex`/`UseHash` query hints* -- +per-keyspace {sqlpp} optimizer hints exposed as `IQueryable` extension methods: force a +specific secondary index (`UseIndex`), or a hash-join build/probe strategy for a join (`UseHash`). +See xref:entity-framework-core-queries.adoc#query-hints-use-index--use-hash[Query hints]. + +* *`HasIndex()` secondary-index auto-creation* -- +`EnsureCreatedAsync` (with the new `AutoCreateIndexes` option) creates a {sqlpp} secondary index +for every `HasIndex()` declared on the model, alongside the existing primary-index creation, and +waits for each to come online before returning. +See xref:entity-framework-core-configuration.adoc#secondary-indexes-hasindex[Secondary indexes (HasIndex())]. + +* *`OwnsMany` querying, rounded out* -- +`.Any(predicate)`, `.All(predicate)`, and `.Count(predicate)` now work at any nesting depth +(including reached through another owned navigation), and indexer/`.ElementAt()` access +(`customer.ContactMethods[0].Type`) is supported for a depth-1 collection. `.Contains()` directly +on an `OwnsMany` navigation, and a *direct* chained indexer through two owned-collection levels, +remain unsupported -- both are crashes inside EF Core's own core query-translation code, not gaps +in this provider. +See xref:entity-framework-core-modeling.adoc#ownsmany[Modeling -- OwnsMany]. + +* *Scalar primitive collections* (`List`/`T[]`, not `OwnsMany`) -- +now stored as a native JSON array (previously silently double-encoded as a JSON string) and +support indexer/`.ElementAt()`, `.Contains()`, `.Count`, and `.Any(predicate)`. +See xref:entity-framework-core-modeling.adoc#primitive-collections[Modeling -- Primitive collections]. + +* *`[UnixMillisDateTime]`/`HasUnixMillisDateTime`* -- +stores a `DateTime` property as Unix epoch milliseconds (a JSON `NUMBER`) instead of this +provider's default ISO-8601 string, for data that already uses that convention. +See xref:entity-framework-core-configuration.adoc#unix-millis-datetime-storage[Unix-millis DateTime storage]. + +* *`DateTimeFormat` option, with a per-property override* -- +configures the .NET custom `DateTime` format string the provider assumes when generating or +comparing `DateTime` string values in {sqlpp}, since {sqlpp} has no native date type and data can +legitimately use a different convention. +See xref:entity-framework-core-configuration.adoc#datetime-string-format[DateTime string format]. + +* *Broader {sqlpp} function translation* -- +`Math.Sin`/`Cos`/`Tan`/`Asin`/`Acos`/`Atan`/`Atan2`; `Math.Min`/`Max` and +`EF.Functions.Least`/`Greatest` (via a new inline array-literal expression); `EF.Functions.IsMissing`/ +`IsNotMissing`/`IsValued`/`IsNotValued` (N1QL's postfix `IS [NOT] MISSING`/`IS [NOT] VALUED`, +distinguishing a genuinely missing field from an explicit JSON `null`); and `string.StartsWith`/ +`EndsWith`/`IsNullOrEmpty`/`PadLeft`/`PadRight`/`Length`, `Math.Abs`/`Ceiling`/`Floor`/`Round`/ +`Truncate`/`Pow`/`Sqrt`/`Sign`/`Log`/`Log10`/`Exp`, `DateTime` member access and arithmetic, and +`Guid.NewGuid()` from earlier in this cycle. +See xref:entity-framework-core-queries.adoc#supported-functions[Supported functions]. + +Also fixed: sequences always targeted the context's configured bucket rather than the actual +bucket of the entity using them (now resolved per-entity, matching collections/indexes); C#'s `??` +generated N1QL's nonexistent `COALESCE` (now `IFMISSINGORNULL`); `string.IndexOf` translated to +`CONTAINS` (a boolean) instead of `POSITION` (the integer position it must return). + +Known limitations at this release: a `[UnixMillisDateTime]`-mapped property cannot be compared +directly against `DateTime.UtcNow`/`.Now`/`.Today` (capture the value into a local variable +first); `.Contains()` directly on an `OwnsMany` navigation and a direct chained indexer through +two owned-collection levels remain unsupported. See +xref:entity-framework-core-limitations.adoc[Limitations]. + + +xref:ROOT:entity-framework-core-sdk-api.adoc[API Reference] | +https://www.nuget.org/packages/Couchbase.EntityFrameworkCore/2.0.0-beta.3[NUGET] + + === Version 2.0.0-beta.2 (15 July 2026) Version https://jira.issues.couchbase.com/issues/?jql=project%20%3D%20%22CBEF%22%20AND%20fixVersion%20%3D%20%222.0.0-beta.2%22[2.0.0-beta.2] adds: diff --git a/modules/ROOT/pages/entity-framework-core-sdk-api.adoc b/modules/ROOT/pages/entity-framework-core-sdk-api.adoc index 3e44c08..48d4bba 100644 --- a/modules/ROOT/pages/entity-framework-core-sdk-api.adoc +++ b/modules/ROOT/pages/entity-framework-core-sdk-api.adoc @@ -15,6 +15,7 @@ These features are covered in the following sections: * xref:entity-framework-core-modeling.adoc[Couchbase EF Core Modeling] * xref:entity-framework-core-crud-data.adoc[Couchbase EF Core CRUD] * xref:entity-framework-core-queries.adoc[Couchbase EF Core Querying] +* xref:entity-framework-core-concurrency.adoc[Couchbase EF Core Optimistic Concurrency] * xref:entity-framework-core-transactions.adoc[Couchbase EF Core Transactions] * xref:entity-framework-core-sequences.adoc[Couchbase EF Core Sequences and generated values] diff --git a/modules/ROOT/pages/entity-framework-core-sequences.adoc b/modules/ROOT/pages/entity-framework-core-sequences.adoc index dc6a2b9..fa7a5a1 100644 --- a/modules/ROOT/pages/entity-framework-core-sequences.adoc +++ b/modules/ROOT/pages/entity-framework-core-sequences.adoc @@ -13,8 +13,13 @@ configured either via the fluent `UseSequence` API or a `[CouchbaseSequence]` at == UseSequence (fluent API) -The simplest form looks up a sequence of the given name in the bucket or scope the `DbContext` is -already configured for: +The simplest form looks up a sequence of the given name in the bucket/scope the `DbContext` is +already configured for -- unless the property's entity is itself mapped to a different bucket +(via `ToCouchbaseCollection(bucket, scope, collection)` or `[CouchbaseKeyspace]`), in which case +the sequence's *bucket* automatically follows that entity's actual bucket, both when it's +auto-created and when a value is generated at runtime. The sequence's *scope* does not follow the +entity the same way -- it always defaults to the context's configured scope unless you pass one +explicitly (below). [source,csharp] ---- @@ -93,6 +98,12 @@ Sequences targeting a *non-default scope* (via `UseSequence(scope, ...)` or the since that scope might not exist yet -- a warning is logged instead. Create the scope and sequence yourself in that case, or set `AutoCreate = false` if you're managing the sequence's lifecycle entirely outside of `EnsureCreatedAsync`. +If the property's entity is mapped to a different bucket than the context's configured one, the +sequence is created (and, at runtime, queried via `NEXT VALUE FOR`) in that entity's actual +bucket, matching how collections and indexes already resolve per-entity -- not the context's +configured bucket. Two sequences with the same name and scope in different buckets are distinct, +not a naming conflict, since a sequence's true identity is `bucket.scope.name`. + == Generated GUIDs diff --git a/modules/ROOT/pages/overview.adoc b/modules/ROOT/pages/overview.adoc index 6565571..d06a85f 100644 --- a/modules/ROOT/pages/overview.adoc +++ b/modules/ROOT/pages/overview.adoc @@ -14,6 +14,7 @@ This documentation contains the following pages: * xref:ROOT:entity-framework-core-modeling.adoc[] * xref:ROOT:entity-framework-core-queries.adoc[] * xref:ROOT:entity-framework-core-crud-data.adoc[] +* xref:ROOT:entity-framework-core-concurrency.adoc[] * xref:ROOT:entity-framework-core-transactions.adoc[] * xref:ROOT:entity-framework-core-sequences.adoc[] * xref:ROOT:entity-framework-core-release-notes.adoc[Release Notes] diff --git a/modules/ROOT/pages/start-using-efcore-provider.adoc b/modules/ROOT/pages/start-using-efcore-provider.adoc index fdf91f7..13b6e72 100644 --- a/modules/ROOT/pages/start-using-efcore-provider.adoc +++ b/modules/ROOT/pages/start-using-efcore-provider.adoc @@ -68,7 +68,7 @@ dotnet new console + [source,console] ---- -dotnet add package Couchbase.EntityFrameworkCore --version 2.0.0-beta.2 +dotnet add package Couchbase.EntityFrameworkCore --version 2.0.0-beta.3 ---- * Add the dependency on `EFCore.NamingConventions`: