Skip to content

Let an editor read and write every language at once, and name preview.3 - #4

Merged
sadeqabuhattem merged 2 commits into
mainfrom
feat/all-languages
Nov 4, 2026
Merged

sadeqabuhattem merged 2 commits into
mainfrom
feat/all-languages

Conversation

@sadeqabuhattem

Copy link
Copy Markdown
Member

Closes gap 3 of COMPOSITION-GAPS.md, and names the release the composition work ships in: 1.0.0-preview.3.

Why

In base-language mode the base language is the entity's own columns. So For(entity).Set(baseCode, …) throws, and GetAllAsync() leaves the base language out. An editor that shows and sends every language at once had to:

  1. switch the whole request to the base language around the save;
  2. switch back in a finally, because localized messages read the current language;
  3. add the base values to the read result by hand.

That is dotnetboost-sample's TranslatedText.cs. It is shared per-request state, and the most delicate code in the application.

What changes

var boxes = await multiLingual.For(product).GetAllAsync(includeBase: true); // every language
multiLingual.For(product).SetAll(request.Languages);                         // write them all
await db.SaveChangesAsync();
  • SetAll(byLanguage):
    • base-language values are assigned to the properties and written to the columns, under any request language, and never as that language's translation;
    • every other language is queued like Set;
    • an entity may be created this way in any language, as long as every translatable value it holds came from SetAll;
    • null empties the column;
    • everything is checked before anything is queued.
  • GetAllAsync(includeBase: true) adds the base language as stored. Under EF Core it reads the columns with a query by key. Without a change tracker, it answers what each property held before its first localization, or what SetAll assigned.
  • Outside base-language mode, SetAll is Set per language and includeBase adds nothing, so editor code needn't know the mode.

The two traps the gaps notes named:

  1. Base text saved as the current language. Each base value from SetAll is marked. Both places that would take it for the current language read the mark:

    • CaptureValues skips it;
    • ApplyBaseLanguageRules keeps the column in the UPDATE.

    The mark only holds while the property keeps that value, so a later direct edit counts as the current language again.

  2. EF's original values. These aren't used: after a save in another language, EF remembers that language as the original value, while the column keeps the base text.

It also fixes an older bug in RememberPersisted: it marked a property with a staged value the property didn't hold. After SetAll, that made the next save write the base text as the current language's.

The second commit names the release. Everything under Unreleased, including #3's fixes, moves to 1.0.0-preview.3, with a summary and a Known limitations entry for the case still open. The date is the day the release was prepared; adjust it if the tag is cut another day.

API

SetAll and the GetAllAsync(bool, CancellationToken) overload are new members of ITranslationAccessor<T>. Nothing in the repo implements that interface; only an application implementing it itself would notice. Existing calls behave as before.

Testing

  • New tests:

    • ProviderTests/EfCore/AllLanguagesTests (14 tests, SQLite, base-language mode under ar);
    • UnitTests/AllLanguagesTests (5 tests: the no-change-tracker path, as Dapper and MongoDB drive it);
    • AllLanguagesIntegrationTests: an editor round trip on PostgreSQL and SQL Server.
  • Each safeguard is pinned by a test:

    • removing the column write, the "on loan" mark, the RememberPersisted guard, the load-time memory, the create-rule exemption or the column reader each fails the tests for it;
    • removing the column reader first failed nothing, so a test was added for a SetAll not yet saved.
  • Results on net8.0 and net10.0:

    Suite Passed Skipped
    Unit 232 0
    Provider 304 6
    API 55 / 56 0
    Integration 198 6

    The skipped rows are Make the save interceptor safe beside other SaveChanges interceptors #3's known retrying-strategy case.

🤖 Generated with Claude Code

sadeqabuhattem and others added 2 commits November 4, 2026 19:28
In base-language mode the base language is the entity's own columns, so
Set(baseCode, ...) throws and GetAllAsync leaves it out. An editor sending
every language at once had to switch the request's language to the base
one around the save, back again in a finally, and put the base values into
the read result itself: the sample's TranslatedText, global per-request
state and its most delicate code.

ITranslationAccessor<T>.SetAll(byLanguage) queues every language at once,
under any language. Base-language values are assigned to the properties
and marked, so the save writes them to the columns whatever is current,
and never as that language's translation: CaptureValues sees them as on
loan from the base language, and ApplyBaseLanguageRules keeps the column
in the UPDATE. The mark holds only while the property keeps that value.
An entity whose every translatable value came from SetAll may be created
in any language. null empties the column. Everything is checked before
anything is queued.

GetAllAsync(includeBase: true) adds the base language as stored. EF's
original values cannot be trusted for it, since a save in another language
leaves that language there, so under EF Core the columns are read with a
query of their own, by key, around the translation machinery. Without a
change tracker the engine answers what each property held before its
first serve, or what SetAll assigned.

RememberPersisted no longer marks a property with a staged value it does
not hold. After SetAll, that made the next save write the base text as
the current language's.

Outside base-language mode SetAll is Set per language and includeBase
adds nothing. Tests on SQLite, the in-memory engine, PostgreSQL and SQL
Server; breaking each safeguard fails the tests that pin it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The composition fixes merged in #3 and SetAll / GetAllAsync(includeBase:
true) sat under [Unreleased], which says what changed but not what to
install it as. They ship as 1.0.0-preview.3: preview.2 is already
published and a published version is immutable.

The section gains a summary, the Fixed entries an order that follows the
story (the nested save first, then the commit, then the two failure-path
leaks), and a Known limitations entry for the one case still open: a
retrying strategy with another interceptor registered first.

[Unreleased] stays at the top, empty, per Keep a Changelog, and the compare
links are rewired so the new heading resolves and [Unreleased] spans
preview.3...HEAD.

The date is the day the release was prepared, not one the tag can confirm,
since the tag does not exist yet. Adjust it if v1.0.0-preview.3 is cut on
another day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sadeqabuhattem
sadeqabuhattem merged commit 4081d3c into main Nov 4, 2026
10 checks passed
@sadeqabuhattem
sadeqabuhattem deleted the feat/all-languages branch November 5, 2026 16:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant