Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/4.data-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ const user = UserModel.fromPlainData({
});
```

The object is a `PlainData<T>`. 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<T>`.
The object is a `PlainData<T>`. 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<T>`.

### `fromDatabase(obj)`

Expand Down
1 change: 1 addition & 0 deletions skills/database-decorators-interface/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>` for each listed field (`LocalizedRecords<T, K>`); 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.
Expand Down
32 changes: 31 additions & 1 deletion src/modifiers/localization.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -47,6 +48,13 @@ function withoutEmptyTexts<V>(values: Record<string, V>): Record<string, V> {
);
}

/** 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.
*
Expand Down Expand Up @@ -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<T extends { constructor: any }>(
object: T,
field: Extract<keyof T, string>,
raw: PlainData<T>,
) {
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<Record<string, unknown>>,
Expand Down
88 changes: 88 additions & 0 deletions src/tests/localized_plain_data.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading