diff --git a/.changeset/gold-bobcats-decide.md b/.changeset/gold-bobcats-decide.md new file mode 100644 index 0000000000..f41cc508af --- /dev/null +++ b/.changeset/gold-bobcats-decide.md @@ -0,0 +1,6 @@ +--- +"@medusajs/types": patch +"@medusajs/utils": patch +--- + +feat(DML): Add a new translatable property modifier applicable on text diff --git a/packages/core/utils/src/dml/__tests__/base-property.spec.ts b/packages/core/utils/src/dml/__tests__/base-property.spec.ts index e29f34721f..bd97481a07 100644 --- a/packages/core/utils/src/dml/__tests__/base-property.spec.ts +++ b/packages/core/utils/src/dml/__tests__/base-property.spec.ts @@ -38,6 +38,7 @@ describe("Base property", () => { searchable: true, }, }, + defaultValue: undefined, nullable: false, computed: false, indexes: [], diff --git a/packages/core/utils/src/dml/__tests__/text-property.spec.ts b/packages/core/utils/src/dml/__tests__/text-property.spec.ts index 7f0fb52eea..d48d233746 100644 --- a/packages/core/utils/src/dml/__tests__/text-property.spec.ts +++ b/packages/core/utils/src/dml/__tests__/text-property.spec.ts @@ -36,4 +36,73 @@ describe("Text property", () => { primaryKey: true, }) }) + + test("should mark text property as translatable", () => { + const property = new TextProperty().translatable() + + expectTypeOf(property["$dataType"]).toEqualTypeOf() + expect(property.parse("name")).toEqual({ + fieldName: "name", + dataType: { + name: "text", + options: { searchable: false, translatable: true }, + }, + nullable: false, + computed: false, + indexes: [], + relationships: [], + }) + }) + + test("should chain translatable with searchable", () => { + const property = new TextProperty().searchable().translatable() + + expectTypeOf(property["$dataType"]).toEqualTypeOf() + expect(property.parse("name")).toEqual({ + fieldName: "name", + dataType: { + name: "text", + options: { searchable: true, translatable: true }, + }, + nullable: false, + computed: false, + indexes: [], + relationships: [], + }) + }) + + test("should chain translatable with nullable", () => { + const property = new TextProperty().translatable().nullable() + + expectTypeOf(property["$dataType"]).toEqualTypeOf() + expect(property.parse("description")).toEqual({ + fieldName: "description", + dataType: { + name: "text", + options: { searchable: false, translatable: true }, + }, + nullable: true, + computed: false, + indexes: [], + relationships: [], + }) + }) + + test("should chain translatable with default", () => { + const property = new TextProperty().default("Default Value").translatable() + + expectTypeOf(property["$dataType"]).toEqualTypeOf() + expect(property.parse("name")).toEqual({ + fieldName: "name", + dataType: { + name: "text", + options: { searchable: false, translatable: true }, + }, + nullable: false, + computed: false, + defaultValue: "Default Value", + indexes: [], + relationships: [], + }) + }) }) diff --git a/packages/core/utils/src/dml/entity.ts b/packages/core/utils/src/dml/entity.ts index 0db09781da..182478dbc5 100644 --- a/packages/core/utils/src/dml/entity.ts +++ b/packages/core/utils/src/dml/entity.ts @@ -7,6 +7,7 @@ import { IDmlEntity, IDmlEntityConfig, InferDmlEntityNameFromConfig, + PropertyType, QueryCondition, } from "@medusajs/types" import { isObject, isString, toCamelCase, upperCaseFirst } from "../common" @@ -17,6 +18,32 @@ import { BelongsTo } from "./relations/belongs-to" const IsDmlEntity = Symbol.for("isDmlEntity") +/** + * Represents an entity with translatable properties + */ +export type TranslatableEntityEntry = { + /** + * The entity name (PascalCase) + */ + entity: string + /** + * The list of translatable property names + */ + fields: string[] +} + +/** + * Module-level storage for translatable entities using Map for O(1) lookups. + * Keys are entity names (PascalCase), values are Sets of field names. + * + * @example + * import { DmlEntity } from "@medusajs/framework/utils" + * + * const translatables = DmlEntity.getTranslatableEntities() + * // Returns: [{ entity: "Store", fields: ["name"] }] + */ +const TRANSLATABLE_ENTITIES = new Map>() + export type DMLEntitySchemaBuilder = DMLSchemaWithBigNumber & DMLSchemaDefaults & Schema @@ -86,6 +113,32 @@ export class DmlEntity< this.schema = schema this.name = name this.#tableName = tableName + + this.#registerTranslatableFields(name, schema) + } + + /** + * Collects translatable fields from the schema and registers them + */ + #registerTranslatableFields(entityName: string, schema: Schema): void { + for (const [fieldName, property] of Object.entries(schema)) { + if (typeof (property as PropertyType).parse !== "function") { + continue + } + + const parsed = (property as PropertyType).parse(fieldName) + if (!("fieldName" in parsed) || !parsed.dataType?.options?.translatable) { + continue + } + + // Get or create the Set for this entity + const existingFields = TRANSLATABLE_ENTITIES.get(entityName) + if (existingFields) { + existingFields.add(fieldName) + } else { + TRANSLATABLE_ENTITIES.set(entityName, new Set([fieldName])) + } + } } /** @@ -99,6 +152,36 @@ export class DmlEntity< return !!entity?.[IsDmlEntity] } + /** + * Returns all registered translatable entities with their translatable fields. + * Each entry contains the entity name (PascalCase) and an array + * of field names that are marked as translatable. + * + * @example + * import { DmlEntity } from "@medusajs/framework/utils" + * + * const translatables = DmlEntity.getTranslatableEntities() + * // Returns: [{ entity: "Store", fields: ["name"] }] + * + * @customNamespace Model Methods + */ + static getTranslatableEntities(): TranslatableEntityEntry[] { + return Array.from(TRANSLATABLE_ENTITIES.entries()).map( + ([entity, fields]) => ({ + entity, + fields: Array.from(fields), + }) + ) + } + + /** + * Clears all registered translatable entities. + * This is primarily used for testing purposes. + */ + static clearTranslatableEntities(): void { + TRANSLATABLE_ENTITIES.clear() + } + /** * Parse entity to get its underlying information */ diff --git a/packages/core/utils/src/dml/index.ts b/packages/core/utils/src/dml/index.ts index a99e40fb5c..c8930e3684 100644 --- a/packages/core/utils/src/dml/index.ts +++ b/packages/core/utils/src/dml/index.ts @@ -5,4 +5,6 @@ export * from "./helpers/create-mikro-orm-entity" export * from "./relations/index" export * from "./properties/index" +export { TranslatableEntityEntry } from "./entity" + export * from "./helpers/entity-builder/index" diff --git a/packages/core/utils/src/dml/integration-tests/__tests__/translatable.spec.ts b/packages/core/utils/src/dml/integration-tests/__tests__/translatable.spec.ts new file mode 100644 index 0000000000..a8def23707 --- /dev/null +++ b/packages/core/utils/src/dml/integration-tests/__tests__/translatable.spec.ts @@ -0,0 +1,161 @@ +import { MetadataStorage } from "@medusajs/deps/mikro-orm/core" +import { DmlEntity } from "../../entity" +import { model } from "../../entity-builder" +import { mikroORMEntityBuilder } from "../../helpers/create-mikro-orm-entity" + +describe("Translatable", () => { + beforeEach(() => { + MetadataStorage.clear() + mikroORMEntityBuilder.clear() + DmlEntity.clearTranslatableEntities() + }) + + describe("getTranslatableEntities", () => { + it("should collect translatable fields from a single entity", () => { + model.define("ProductVariant", { + id: model.id().primaryKey(), + name: model.text().translatable(), + default_sales_channel_id: model.text().nullable(), + }) + + const translatables = DmlEntity.getTranslatableEntities() + + expect(translatables).toContainEqual({ + entity: "ProductVariant", + fields: ["name"], + }) + }) + + it("should collect multiple translatable fields from a single entity", () => { + model.define("product", { + id: model.id().primaryKey(), + title: model.text().translatable(), + description: model.text().translatable(), + handle: model.text(), + }) + + const translatables = DmlEntity.getTranslatableEntities() + + expect(translatables).toContainEqual({ + entity: "Product", + fields: ["title", "description"], + }) + }) + + it("should collect translatable fields from multiple entities", () => { + model.define("store", { + id: model.id().primaryKey(), + name: model.text().translatable(), + }) + + model.define("product", { + id: model.id().primaryKey(), + title: model.text().translatable(), + }) + + model.define("region", { + id: model.id().primaryKey(), + name: model.text().translatable(), + currency_code: model.text(), + }) + + const translatables = DmlEntity.getTranslatableEntities() + + expect(translatables).toContainEqual({ + entity: "Store", + fields: ["name"], + }) + expect(translatables).toContainEqual({ + entity: "Product", + fields: ["title"], + }) + expect(translatables).toContainEqual({ + entity: "Region", + fields: ["name"], + }) + }) + + it("should not include entities without translatable fields", () => { + model.define("store", { + id: model.id().primaryKey(), + name: model.text().translatable(), + }) + + model.define("currency", { + id: model.id().primaryKey(), + code: model.text(), + symbol: model.text(), + }) + + const translatables = DmlEntity.getTranslatableEntities() + + expect(translatables).toContainEqual({ + entity: "Store", + fields: ["name"], + }) + + const currencyEntry = translatables.find((e) => e.entity === "Currency") + expect(currencyEntry).toBeUndefined() + }) + + it("should work with translatable chained with other modifiers", () => { + model.define("product", { + id: model.id().primaryKey(), + title: model.text().searchable().translatable(), + subtitle: model.text().translatable().nullable(), + description: model.text().default("No description").translatable(), + }) + + const translatables = DmlEntity.getTranslatableEntities() + + expect(translatables).toContainEqual({ + entity: "Product", + fields: ["title", "subtitle", "description"], + }) + }) + + it("should use PascalCase entity name", () => { + model.define("product_category", { + id: model.id().primaryKey(), + name: model.text().translatable(), + }) + + const translatables = DmlEntity.getTranslatableEntities() + + expect(translatables).toContainEqual({ + entity: "ProductCategory", + fields: ["name"], + }) + }) + + it("should use name from config object", () => { + model.define( + { name: "ProductCategory", tableName: "product_category" }, + { + id: model.id().primaryKey(), + name: model.text().translatable(), + } + ) + + const translatables = DmlEntity.getTranslatableEntities() + + expect(translatables).toContainEqual({ + entity: "ProductCategory", + fields: ["name"], + }) + }) + + it("should return a copy of the translatable entities array", () => { + model.define("store", { + id: model.id().primaryKey(), + name: model.text().translatable(), + }) + + const translatables1 = DmlEntity.getTranslatableEntities() + const translatables2 = DmlEntity.getTranslatableEntities() + + expect(translatables1).not.toBe(translatables2) + expect(translatables1).toEqual(translatables2) + }) + }) +}) diff --git a/packages/core/utils/src/dml/properties/text.ts b/packages/core/utils/src/dml/properties/text.ts index a86d0695fc..88f062e0c1 100644 --- a/packages/core/utils/src/dml/properties/text.ts +++ b/packages/core/utils/src/dml/properties/text.ts @@ -10,6 +10,7 @@ export class TextProperty extends BaseProperty { options: { prefix?: string searchable: boolean + translatable?: boolean } } = { name: "text", @@ -57,4 +58,25 @@ export class TextProperty extends BaseProperty { return this } + + /** + * This method indicates that a text property is translatable. + * Translatable properties can have different values per locale. + * + * @example + * import { model } from "@medusajs/framework/utils" + * + * const Store = model.define("store", { + * name: model.text().translatable(), + * // ... + * }) + * + * export default Store + * + * @customNamespace Property Configuration Methods + */ + translatable() { + this.dataType.options.translatable = true + return this + } }