diff --git a/docs/3.table-modifiers.md b/docs/3.table-modifiers.md index efe0f3f..c46df6f 100644 --- a/docs/3.table-modifiers.md +++ b/docs/3.table-modifiers.md @@ -426,7 +426,7 @@ Base64 is an encoding, not encryption. The example declares its `LockedType` as ### `ContainerModifier` -A modifier that stores several values under keys, as `LocalizationModifier` does, extends `ContainerModifier`. Its `lock`, `unlock`, and `unlockrequest` take the key as their argument, a `string` or `undefined`. `lock` and `unlock` treat the wildcard key `"*"` as the whole record, and `lock` throws a `TypeError` for a value under `"*"` that is not a record. `undefined` is no key: `lock` stores nothing under it, and `unlock` and `unlockrequest` find no value, so a `LocalizationModifier` answers its `fallbackLocale`: +A modifier that stores several values under keys, as `LocalizationModifier` does, extends `ContainerModifier`. Its `lock`, `unlock`, and `unlockrequest` take the key as their argument, a `string` or `undefined`. `lock` and `unlock` treat the wildcard key `"*"` as the whole record, and `lock` throws a `TypeError` for a value under `"*"` that is not a record. `undefined` is no key: `lock` stores nothing under it, and `unlock` and `unlockrequest` find no value, so a `LocalizationModifier` answers its `fallbackLocale`, or `null` when that locale has no value either: ```typescript import { ContainerModifier } from "@antelopejs/interface-database-decorators"; diff --git a/skills/database-decorators-interface/SKILL.md b/skills/database-decorators-interface/SKILL.md index 6eaaeca..93e7c49 100644 --- a/skills/database-decorators-interface/SKILL.md +++ b/skills/database-decorators-interface/SKILL.md @@ -133,7 +133,7 @@ class Chef extends Table.with( - `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. -- A `ContainerModifier` key of `undefined` is no key: writes store nothing under it, and reads and queries get no value, or the `fallbackLocale` value for a localized field. +- A `ContainerModifier` key of `undefined` is no key: writes store nothing under it, and reads and queries get no value. A localized field reads the `fallbackLocale` value, or `null` when there is none. - `@Relation({ to: () => Target })` is declarative metadata only: no foreign-key enforcement, consumed by introspection tooling via `RelationStaticMetadata`. - `Model.validate` (and `{ validate: true }` on insert/update) only checks fields whose `@Field` type is an io-ts-style codec with `.decode`; plain string field tokens are skipped. Update validates with diff --git a/src/modifiers/encryption.ts b/src/modifiers/encryption.ts index b6dd516..b4f9725 100644 --- a/src/modifiers/encryption.ts +++ b/src/modifiers/encryption.ts @@ -152,7 +152,15 @@ export class EncryptionModifier extends TwoWayModifier< readonly autounlock = true; - public override unlock([locked_value, iv_str, authTag]: LockedType) { + /** + * Decrypts a stored value. `null` is no value and stays `null`, as a + * localized field read with no locale gives it. + */ + public override unlock(locked: LockedType | null) { + if (locked === null) { + return null; + } + const [locked_value, iv_str, authTag] = locked; const decipher = createDecipheriv( this.options.algorithm || "aes-256-gcm", this.options.secretKey, diff --git a/src/modifiers/localization.ts b/src/modifiers/localization.ts index 7db7a3b..45740f7 100644 --- a/src/modifiers/localization.ts +++ b/src/modifiers/localization.ts @@ -94,6 +94,10 @@ export class LocalizationModifier extends ContainerModifier< return withoutEmptyTexts(super.lock(locked_value, value, key)); } + /** + * The value under `key`, else under the fallback locale. With no `key`, a + * field without a fallback value reads `null`, as an unset field does. + */ public override unlock( // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- the container modifier leaves the values by locale untyped locked_value: Record, @@ -103,9 +107,10 @@ export class LocalizationModifier extends ContainerModifier< if (unlocked !== undefined && unlocked !== EMPTY_TEXT) { return unlocked; } - if (this.options.fallbackLocale) { - return super.unlock(locked_value, this.options.fallbackLocale); - } + const fallback = this.options.fallbackLocale + ? super.unlock(locked_value, this.options.fallbackLocale) + : undefined; + return key === undefined ? (fallback ?? null) : fallback; } public override unlockrequest( diff --git a/src/tests/localized_no_key.test.ts b/src/tests/localized_no_key.test.ts new file mode 100644 index 0000000..471755e --- /dev/null +++ b/src/tests/localized_no_key.test.ts @@ -0,0 +1,61 @@ +import { expect } from "chai"; +import { Schema } from "@antelopejs/interface-database"; +import { RegisterTable } from "../schema"; +import { RegisterSchema } from "../database"; +import { Field, Table } from "../table"; +import { fromDatabase, lock, unlock } from "../modifiers/common"; +import { EncryptionModifier, Encrypted } from "../modifiers/encryption"; +import { LocalizationModifier, Localized } from "../modifiers/localization"; + +describe("Localized fields - a request without a locale", () => { + @RegisterTable("no-key-pages", "localized-no-key-schema") + class Page extends Table.with(LocalizationModifier, EncryptionModifier) { + @Localized() @Field("string") declare title?: string; + @Localized({ fallbackLocale: "en" }) + @Field("string") + declare summary?: string; + @Localized() + @Encrypted({ secretKey: "12345678901234567890123456789012" }) + @Field("string") + declare secret?: string; + } + + async function createdWithoutLocale() { + await RegisterSchema("localized-no-key-schema"); + const instance = Schema.get("localized-no-key-schema")?.instance(); + if (!instance) throw new Error("Database not found"); + const row = new Page(); + row._id = "page-1"; + row.title = undefined; + row.summary = undefined; + row.secret = "Sent without a locale"; + lock(row, LocalizationModifier, undefined, undefined); + await Page.table(instance).insert(row).run(); + const read = fromDatabase( + await Page.table(instance).get("page-1").run(), + Page, + ); + unlock(read, LocalizationModifier, undefined, undefined); + return read; + } + + afterEach(async () => { + await Schema.get("localized-no-key-schema") + ?.instance() + .table("no-key-pages") + .delete() + .run(); + }); + + it("answers null for a field the request did not send", async () => { + const answer: unknown = JSON.parse( + JSON.stringify(await createdWithoutLocale()), + ); + + expect(answer).to.deep.include({ + title: null, + summary: null, + secret: null, + }); + }); +});