diff --git a/docs/4.data-models.md b/docs/4.data-models.md index 044ab82..9b96209 100644 --- a/docs/4.data-models.md +++ b/docs/4.data-models.md @@ -129,7 +129,7 @@ const user = UserModel.fromPlainData({ }); ``` -The object is a `PlainData`. Every field is optional and may be partial, and a field under a container modifier may hold its value per key, as a localized field holds one text per locale. `toPlainData` gives the reverse form, a `PlainRow`. +The object is a `PlainData`. Every field is optional and may be partial, and a field under a container modifier may hold its value per key, as a localized field holds one text per locale. A plain object given for a localized field is read as its texts by locale, so every locale is stored, whatever `localize()` call follows. A single text is stored under the locale that a later `localize()` call selects. A field with another modifier inside its locales, such as `@Localized() @Encrypted()`, does not read a plain object this way. `toPlainData` gives the reverse form, a `PlainRow`. ### `fromDatabase(obj)` diff --git a/skills/database-decorators-interface/SKILL.md b/skills/database-decorators-interface/SKILL.md index 93e7c49..f854be6 100644 --- a/skills/database-decorators-interface/SKILL.md +++ b/skills/database-decorators-interface/SKILL.md @@ -130,6 +130,7 @@ class Chef extends Table.with( - `@Hashed({ algorithm })` stores a fast digest instead of scrypt: use it only for non-secret fingerprints, never for credentials. - Localized fields must be written through a localized instance (`instance.localize(locale).field = ...`); values assigned before `localize()` are held in floating state and silently dropped on insert/update. +- In plain data, a plain object given for a localized field is its texts by locale: `fromPlainData` and `insert` store every locale, whatever `localize()` call follows, and `update` replaces the stored texts with that record. A single text still waits for a locale. A field with another modifier inside its locales, such as `@Localized() @Encrypted()`, does not read a record this way. - `instance.localize("*", ["motto"])` reads and writes every locale at once, typed `Record` for each listed field (`LocalizedRecords`); without a field list the field types stay unchanged. - Under `localize("*")` a localized field takes the record of texts by locale, and `undefined` or `null` clears every locale. A single text throws a `TypeError`, whether it is assigned or held from `fromPlainData` when `localize("*")` runs. - A localized value `""` is stored as no value: reads and queries in that locale get the `fallbackLocale` value. diff --git a/src/modifiers/localization.ts b/src/modifiers/localization.ts index 45740f7..d19a1d1 100644 --- a/src/modifiers/localization.ts +++ b/src/modifiers/localization.ts @@ -1,7 +1,8 @@ import type * as DatabaseDev from "@antelopejs/interface-database"; import { MakePropertyDecorator } from "@antelopejs/interface-core/decorators"; -import { type Constructible, getMetadata } from "../common"; +import { type Constructible, getMetadata, type PlainData } from "../common"; +import { isRecord } from "./internal/kinds"; import { attachModifier, ContainerModifier, @@ -47,6 +48,13 @@ function withoutEmptyTexts(values: Record): Record { ); } +/** Whether `value` is an object literal, not an array, a date or a class instance. */ +function isPlainObject(value: unknown): value is object { + if (!isRecord(value)) return false; + const prototype: unknown = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + /** * Localization modifier. enables the use of {@link Localized} on table fields. * @@ -113,6 +121,28 @@ export class LocalizationModifier extends ContainerModifier< return key === undefined ? (fallback ?? null) : fallback; } + /** + * Reads a field that plain data gives as a plain object as its values by + * locale, as `PlainData` types it, whatever locale the row is read with + * afterwards. A field with a modifier inside its locales, such as + * `@Localized() @Encrypted()`, keeps the value for the next locale selected: + * that modifier transforms the value before the locales are set. + */ + public fromPlainData( + object: T, + field: Extract, + raw: PlainData, + ) { + const modifiers = getMetadata(object.constructor, ModifiersStaticMetadata) + .fields[field]; + if ( + modifiers?.at(-1)?.id === LocalizationModifier.name && + isPlainObject(raw[field]) + ) { + unlock(object, LocalizationModifier, [field], "*"); + } + } + public override unlockrequest( // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- the container modifier leaves the values by locale untyped data: DatabaseDev.ValueProxy>, diff --git a/src/tests/localized_plain_data.test.ts b/src/tests/localized_plain_data.test.ts new file mode 100644 index 0000000..99b4de7 --- /dev/null +++ b/src/tests/localized_plain_data.test.ts @@ -0,0 +1,88 @@ +import { expect } from "chai"; +import { Schema } from "@antelopejs/interface-database"; +import { RegisterTable } from "../schema"; +import { RegisterSchema } from "../database"; +import { BasicDataModel } from "../model"; +import { Table } from "../table"; +import { Encrypted, EncryptionModifier } from "../modifiers/encryption"; +import { LocalizationModifier, Localized } from "../modifiers/localization"; +import { getInternal } from "../modifiers/internal/records"; + +describe("Localized fields - plain data with values by locale", () => { + @RegisterTable("roles", "localized-plain-data-schema") + class Role extends Table.with(LocalizationModifier, EncryptionModifier) { + @Localized() declare displayName?: string; + @Localized() declare tooltip?: string | null; + @Localized() + @Encrypted({ secretKey: "12345678901234567890123456789012" }) + declare note?: unknown; + } + + const RoleModel = BasicDataModel(Role, "roles"); + const wording = { en: "Members", fr: "Membres" }; + + async function model() { + await RegisterSchema("localized-plain-data-schema"); + const instance = Schema.get("localized-plain-data-schema")?.instance(); + if (!instance) throw new Error("Database not found"); + return new RoleModel(instance); + } + + async function stored(row: Role) { + const roles = await model(); + row._id = "role-1"; + await roles.insert(row); + const read = await roles.get("role-1"); + if (!read) throw new Error("Row not stored"); + return read; + } + + afterEach(async () => { + await Schema.get("localized-plain-data-schema") + ?.instance() + .table("roles") + .delete() + .run(); + }); + + it("stores every locale of a record read under one locale", async () => { + const read = await stored( + RoleModel.fromPlainData({ displayName: wording }).localize("en"), + ); + + expect(getInternal(read).data.displayName).to.deep.equal(wording); + expect(read.localize("en").displayName).to.equal("Members"); + }); + + it("stores every locale of a record read with no locale", async () => { + const read = await stored( + RoleModel.fromPlainData({ displayName: wording }), + ); + + expect(getInternal(read).data.displayName).to.deep.equal(wording); + }); + + it("stores a plain value under the locale it is read with", async () => { + const read = await stored( + RoleModel.fromPlainData({ displayName: "Members" }).localize("en"), + ); + + expect(getInternal(read).data.displayName).to.deep.equal({ en: "Members" }); + }); + + it("stores a null value under the locale it is read with", async () => { + const read = await stored( + RoleModel.fromPlainData({ displayName: wording, tooltip: null }).localize( + "en", + ), + ); + + expect(getInternal(read).data.tooltip).to.deep.equal({ en: null }); + }); + + it("stores no record for a field encrypted inside its locales", async () => { + const read = await stored(RoleModel.fromPlainData({ note: wording })); + + expect(getInternal(read).data.note).to.equal(undefined); + }); +});