diff --git a/.changeset/proud-ghosts-speak.md b/.changeset/proud-ghosts-speak.md new file mode 100644 index 0000000000..6ee2404152 --- /dev/null +++ b/.changeset/proud-ghosts-speak.md @@ -0,0 +1,6 @@ +--- +"@medusajs/medusa": patch +"@medusajs/utils": patch +--- + +feat(medusa, utils): improve devx for core entity customizations diff --git a/packages/medusa/jest.config.js b/packages/medusa/jest.config.js index adffb81889..c9b04f47a1 100644 --- a/packages/medusa/jest.config.js +++ b/packages/medusa/jest.config.js @@ -18,6 +18,7 @@ module.exports = { transform: { "^.+\\.[jt]s?$": "ts-jest", }, + modulePathIgnorePatterns: ["__fixtures__"], testEnvironment: `node`, moduleFileExtensions: [`js`, `jsx`, `ts`, `tsx`, `json`], setupFilesAfterEnv: ["/setupTests.js"], diff --git a/packages/medusa/src/loaders/__tests__/__fixtures__/customizations/medusa-config.js b/packages/medusa/src/loaders/__tests__/__fixtures__/customizations/medusa-config.js new file mode 100644 index 0000000000..7ec24e6c1e --- /dev/null +++ b/packages/medusa/src/loaders/__tests__/__fixtures__/customizations/medusa-config.js @@ -0,0 +1,9 @@ +module.exports = { + featureFlags: {}, + projectConfig: { + database_url: "postgres://localhost/medusa-store", + database_logging: false + }, + plugins: [], + modules: {}, +}; diff --git a/packages/medusa/src/loaders/__tests__/__fixtures__/customizations/models/product.ts b/packages/medusa/src/loaders/__tests__/__fixtures__/customizations/models/product.ts new file mode 100644 index 0000000000..c08cb0c008 --- /dev/null +++ b/packages/medusa/src/loaders/__tests__/__fixtures__/customizations/models/product.ts @@ -0,0 +1,11 @@ +import { Column, Entity } from "typeorm" +import { + // alias the core entity to not cause a naming conflict + Product as MedusaProduct, +} from "@medusajs/medusa" + +@Entity() +export class Product extends MedusaProduct { + @Column() + custom_attribute: string = 'test' +} diff --git a/packages/medusa/src/loaders/__tests__/models.spec.ts b/packages/medusa/src/loaders/__tests__/models.spec.ts new file mode 100644 index 0000000000..7d38ae836c --- /dev/null +++ b/packages/medusa/src/loaders/__tests__/models.spec.ts @@ -0,0 +1,47 @@ +import { createMedusaContainer } from "medusa-core-utils" +import path from "path" +import { asValue } from "awilix" + +import modelsLoader from "../models" + +describe("models loader", () => { + const container = createMedusaContainer() + container.register("db_entities", asValue([])) + let models + let error + + beforeAll(async () => { + try { + models = await modelsLoader({ + container, + isTest: true, + coreTestPathGlob: "../models/{product,product-variant}.ts", + rootDirectory: path.join(__dirname, "__fixtures__/customizations"), + extensionPathGlob: "models/{product,product-variant}.ts", + }) + } catch (e) { + error = e + } + }) + + it("error should be falsy & register 2 models", () => { + expect(error).toBeFalsy() + expect(models).toHaveLength(2) + }) + + it("ensure that the product model is an extended model", () => { + const productModel = models.find((model) => model.name === "Product") + + expect(new productModel().custom_attribute).toEqual("test") + }) + + it("ensure that the extended product model is registered in db_entities", () => { + const entities = container.resolve("db_entities_STORE") + const productModelResolver = entities.find( + (entity) => entity.resolve().name === "Product" + ) + const productModel = productModelResolver.resolve() + + expect(new productModel().custom_attribute).toEqual("test") + }) +}) diff --git a/packages/medusa/src/loaders/__tests__/register-plugin-models.spec.ts b/packages/medusa/src/loaders/__tests__/register-plugin-models.spec.ts new file mode 100644 index 0000000000..144ad5f1c6 --- /dev/null +++ b/packages/medusa/src/loaders/__tests__/register-plugin-models.spec.ts @@ -0,0 +1,36 @@ +import { createMedusaContainer } from "medusa-core-utils" +import path from "path" +import { asValue } from "awilix" + +import { registerPluginModels } from "../plugins" +import configModule from './__fixtures__/customizations/medusa-config' + +describe("plugin models loader", () => { + const container = createMedusaContainer() + container.register("db_entities", asValue([])) + + let models + let error + + beforeAll(async () => { + try { + await registerPluginModels({ + configModule: configModule, + container, + rootDirectory: path.join(__dirname, '__fixtures__/customizations'), + extensionDirectoryPath: './', + pathGlob: "/models/*.ts", + }) + } catch (e) { + error = e + } + }) + + it("ensure that the product model is registered from the user's respository", () => { + const entities = container.resolve("db_entities_STORE") + const productModelResolver = entities.find(entity => entity.resolve().name === 'Product') + const productModel = productModelResolver.resolve() + + expect((new productModel()).custom_attribute).toEqual("test") + }) +}) diff --git a/packages/medusa/src/loaders/helpers/get-model-extension-map.ts b/packages/medusa/src/loaders/helpers/get-model-extension-map.ts new file mode 100644 index 0000000000..bc0ac0f626 --- /dev/null +++ b/packages/medusa/src/loaders/helpers/get-model-extension-map.ts @@ -0,0 +1,58 @@ +import glob from "glob" +import path from "path" +import { EntitySchema } from "typeorm" + +import { formatRegistrationName } from "../../utils/format-registration-name" +import { ClassConstructor } from "../../types/global" + +type GetModelExtensionMapParams = { + directory?: string + pathGlob?: string + config: Record +} + +export function getModelExtensionsMap({ + directory, + pathGlob, + config = {}, +}: GetModelExtensionMapParams): Map< + string, + ClassConstructor | EntitySchema +> { + const modelExtensionsMap = new Map< + string, + ClassConstructor | EntitySchema + >() + const fullPathGlob = + directory && pathGlob ? path.join(directory, pathGlob) : null + + const modelExtensions = fullPathGlob + ? glob.sync(fullPathGlob, { + cwd: directory, + ignore: ["index.js", "index.js.map"], + }) + : [] + + modelExtensions.forEach((modelExtensionPath) => { + const extendedModel = require(modelExtensionPath) as + | ClassConstructor + | EntitySchema + | undefined + + if (extendedModel) { + Object.entries(extendedModel).map( + ([_key, val]: [string, ClassConstructor | EntitySchema]) => { + if (typeof val === "function" || val instanceof EntitySchema) { + if (config.register) { + const name = formatRegistrationName(modelExtensionPath) + + modelExtensionsMap.set(name, val) + } + } + } + ) + } + }) + + return modelExtensionsMap +} diff --git a/packages/medusa/src/loaders/index.ts b/packages/medusa/src/loaders/index.ts index 9aa98c2e8b..63bf8466a7 100644 --- a/packages/medusa/src/loaders/index.ts +++ b/packages/medusa/src/loaders/index.ts @@ -67,7 +67,7 @@ export default async ({ const modelsActivity = Logger.activity(`Initializing models${EOL}`) track("MODELS_INIT_STARTED") - modelsLoader({ container }) + modelsLoader({ container, rootDirectory }) const mAct = Logger.success(modelsActivity, "Models initialized") || {} track("MODELS_INIT_COMPLETED", { duration: mAct.duration }) diff --git a/packages/medusa/src/loaders/models.ts b/packages/medusa/src/loaders/models.ts index c237e734dc..8d7ac29087 100644 --- a/packages/medusa/src/loaders/models.ts +++ b/packages/medusa/src/loaders/models.ts @@ -1,34 +1,74 @@ -import formatRegistrationName from "../utils/format-registration-name" +import { + formatRegistrationName, + formatRegistrationNameWithoutNamespace, +} from "../utils/format-registration-name" +import { getModelExtensionsMap } from "./helpers/get-model-extension-map" import glob from "glob" import path from "path" import { ClassConstructor, MedusaContainer } from "../types/global" import { EntitySchema } from "typeorm" import { asClass, asValue } from "awilix" +type ModelLoaderParams = { + container: MedusaContainer + isTest?: boolean + rootDirectory?: string + corePathGlob?: string + coreTestPathGlob?: string + extensionPathGlob?: string +} /** * Registers all models in the model directory */ export default ( - { container, isTest }: { container: MedusaContainer; isTest?: boolean }, + { + container, + isTest, + rootDirectory, + corePathGlob = "../models/*.js", + coreTestPathGlob = "../models/*.ts", + extensionPathGlob = "dist/models/*.js", + }: ModelLoaderParams, config = { register: true } ) => { - const corePath = isTest ? "../models/*.ts" : "../models/*.js" - const coreFull = path.join(__dirname, corePath) - + const coreModelsGlob = isTest ? coreTestPathGlob : corePathGlob + const coreModelsFullGlob = path.join(__dirname, coreModelsGlob) const models: (ClassConstructor | EntitySchema)[] = [] - const core = glob.sync(coreFull, { + const coreModels = glob.sync(coreModelsFullGlob, { cwd: __dirname, - ignore: ["index.js", "index.ts"], + ignore: ["index.js", "index.ts", "index.js.map"], }) - core.forEach((fn) => { - const loaded = require(fn) as ClassConstructor | EntitySchema + + const modelExtensionsMap = getModelExtensionsMap({ + directory: rootDirectory, + pathGlob: extensionPathGlob, + config, + }) + + coreModels.forEach((modelPath) => { + const loaded = require(modelPath) as + | ClassConstructor + | EntitySchema + if (loaded) { Object.entries(loaded).map( ([, val]: [string, ClassConstructor | EntitySchema]) => { if (typeof val === "function" || val instanceof EntitySchema) { if (config.register) { - const name = formatRegistrationName(fn) + const name = formatRegistrationName(modelPath) + const mappedExtensionModel = modelExtensionsMap.get(name) + + // If an extension file is found, override it with that instead + if (mappedExtensionModel) { + const coreModel = require(modelPath) + const modelName = + formatRegistrationNameWithoutNamespace(modelPath) + + coreModel[modelName] = mappedExtensionModel + val = mappedExtensionModel + } + container.register({ [name]: asClass(val as ClassConstructor), }) diff --git a/packages/medusa/src/loaders/plugins.ts b/packages/medusa/src/loaders/plugins.ts index 6e4960db72..96301ad671 100644 --- a/packages/medusa/src/loaders/plugins.ts +++ b/packages/medusa/src/loaders/plugins.ts @@ -30,7 +30,11 @@ import { Logger, MedusaContainer, } from "../types/global" -import formatRegistrationName from "../utils/format-registration-name" +import { + formatRegistrationName, + formatRegistrationNameWithoutNamespace, +} from "../utils/format-registration-name" +import { getModelExtensionsMap } from "./helpers/get-model-extension-map" import { registerPaymentProcessorFromClass, registerPaymentServiceFromClass, @@ -95,7 +99,8 @@ export default async ({ function getResolvedPlugins( rootDirectory: string, - configModule: ConfigModule + configModule: ConfigModule, + extensionDirectoryPath: string = 'dist' ): undefined | PluginDetails[] { const { plugins } = configModule @@ -110,9 +115,10 @@ function getResolvedPlugins( return details }) + const extensionDirectory = path.join(rootDirectory, extensionDirectoryPath) // Resolve user's project as a plugin for loading purposes resolved.push({ - resolve: `${rootDirectory}/dist`, + resolve: extensionDirectory, name: MEDUSA_PROJECT_NAME, id: createPluginId(MEDUSA_PROJECT_NAME), options: configModule, @@ -126,15 +132,29 @@ export async function registerPluginModels({ rootDirectory, container, configModule, + extensionDirectoryPath = 'dist', + pathGlob = "/models/*.js" }: { rootDirectory: string container: MedusaContainer configModule: ConfigModule + extensionDirectoryPath?: string + pathGlob?: string }): Promise { - const resolved = getResolvedPlugins(rootDirectory, configModule) || [] + const resolved = getResolvedPlugins( + rootDirectory, + configModule, + extensionDirectoryPath + ) || [] + await Promise.all( resolved.map(async (pluginDetails) => { - registerModels(pluginDetails, container) + registerModels( + pluginDetails, + container, + rootDirectory, + pathGlob, + ) }) ) } @@ -563,16 +583,45 @@ function registerRepositories( */ function registerModels( pluginDetails: PluginDetails, - container: MedusaContainer + container: MedusaContainer, + rootDirectory: string, + pathGlob: string = "/models/*.js" ): void { - const files = glob.sync(`${pluginDetails.resolve}/models/*.js`, {}) - files.forEach((fn) => { - const loaded = require(fn) as ClassConstructor | EntitySchema + const pluginFullPathGlob = path.join(pluginDetails.resolve, pathGlob) + + const modelExtensionsMap = getModelExtensionsMap({ + directory: rootDirectory, + pathGlob: pathGlob, + config: { register: true }, + }) + + const coreOrPluginModelsPath = glob.sync( + pluginFullPathGlob, + { ignore: ["index.js", "index.js.map"] } + ) + + coreOrPluginModelsPath.forEach((coreOrPluginModelPath) => { + const loaded = require(coreOrPluginModelPath) as + | ClassConstructor + | EntitySchema Object.entries(loaded).map( ([, val]: [string, ClassConstructor | EntitySchema]) => { if (typeof val === "function" || val instanceof EntitySchema) { - const name = formatRegistrationName(fn) + const name = formatRegistrationName(coreOrPluginModelPath) + const mappedExtensionModel = modelExtensionsMap.get(name) + + // If an extension file is found, override it with that instead + if (mappedExtensionModel) { + const coreOrPluginModel = require(coreOrPluginModelPath) + const modelName = formatRegistrationNameWithoutNamespace( + coreOrPluginModelPath + ) + + coreOrPluginModel[modelName] = mappedExtensionModel + val = mappedExtensionModel + } + container.register({ [name]: asValue(val), }) diff --git a/packages/medusa/src/utils/__tests__/format-registration-name.js b/packages/medusa/src/utils/__tests__/format-registration-name.js index 0403c36fb0..e9ac9d1aeb 100644 --- a/packages/medusa/src/utils/__tests__/format-registration-name.js +++ b/packages/medusa/src/utils/__tests__/format-registration-name.js @@ -1,5 +1,8 @@ import path from "path" -import formatRegistrationName from "../format-registration-name" +import { + formatRegistrationName, + formatRegistrationNameWithoutNamespace, +} from "../format-registration-name" describe("formatRegistrationName", () => { const tests = [ @@ -32,3 +35,26 @@ describe("formatRegistrationName", () => { expect(res).toEqual(expected) }) }) + +describe("formatRegistrationNameWithoutNamespace", () => { + const tests = [ + [["medusa-test-dir", "dist", "services", "my-test.js"], "myTest"], + [["medusa-test-dir", "dist", "services", "my.js"], "my"], + [["services", "my-quite-long-file.js"], "myQuiteLongFile"], + [["/", "Users", "seb", "com.medusa.js", "services", "dot.js"], "dot"], + [["/", "Users", "seb.rin", "com.medusa.js", "services", "dot.js"], "dot"], + [ + ["/", "Users", "seb.rin", "com.medusa.js", "repositories", "dot.js"], + "dot", + ], + [["/", "Users", "seb.rin", "com.medusa.js", "models", "dot.js"], "dot"], + [["C:", "server", "services", "dot.js"], "dot"], + ] + + test.each( + tests.map(([pathParts, expected]) => [path.join(...pathParts), expected]) + )("Service %s -> %s", (fn, expected) => { + const res = formatRegistrationNameWithoutNamespace(fn) + expect(res).toEqual(expected) + }) +}) diff --git a/packages/medusa/src/utils/format-registration-name.ts b/packages/medusa/src/utils/format-registration-name.ts index 3c65202cfa..0831ed959a 100644 --- a/packages/medusa/src/utils/format-registration-name.ts +++ b/packages/medusa/src/utils/format-registration-name.ts @@ -1,4 +1,5 @@ import { parse } from "path" +import { toCamelCase, upperCaseFirst } from "@medusajs/utils" /** * Formats a filename into the correct container resolution name. @@ -7,41 +8,39 @@ import { parse } from "path" * @param path - the full path of the file * @return the formatted name */ -function formatRegistrationName(path: string): string { +export function formatRegistrationName(path: string): string { const parsed = parse(path) const parsedDir = parse(parsed.dir) - const rawname = parsed.name - let namespace = parsedDir.name - if (namespace.startsWith("__")) { + let directoryNamespace = parsedDir.name + + if (directoryNamespace.startsWith("__")) { const parsedCoreDir = parse(parsedDir.dir) - namespace = parsedCoreDir.name + directoryNamespace = parsedCoreDir.name } - switch (namespace) { + switch (directoryNamespace) { // We strip the last character when adding the type of registration // this is a trick for plural "ies" case "repositories": - namespace = "repositorys" + directoryNamespace = "repositorys" break case "strategies": - namespace = "strategys" + directoryNamespace = "strategys" break default: break } - const upperNamespace = - namespace.charAt(0).toUpperCase() + namespace.slice(1, -1) + const upperNamespace = upperCaseFirst(directoryNamespace.slice(0, -1)) - const parts = rawname.split("-").map((n, index) => { - if (index !== 0) { - return n.charAt(0).toUpperCase() + n.slice(1) - } - return n - }) + return formatRegistrationNameWithoutNamespace(path) + upperNamespace +} - return parts.join("") + upperNamespace +export function formatRegistrationNameWithoutNamespace(path: string): string { + const parsed = parse(path) + + return toCamelCase(parsed.name) } export default formatRegistrationName diff --git a/packages/utils/src/common/__tests__/to-camel-case.spec.ts b/packages/utils/src/common/__tests__/to-camel-case.spec.ts new file mode 100644 index 0000000000..1435eae450 --- /dev/null +++ b/packages/utils/src/common/__tests__/to-camel-case.spec.ts @@ -0,0 +1,36 @@ +import { toCamelCase } from "../to-camel-case" + +describe("toCamelCase", function () { + it("should convert all cases to camel case", function () { + const expectations = [ + { + input: "testing-camelize", + output: "testingCamelize", + }, + { + input: "testing-Camelize", + output: "testingCamelize", + }, + { + input: "TESTING-CAMELIZE", + output: "testingCamelize", + }, + { + input: "this_is-A-test", + output: "thisIsATest", + }, + { + input: "this_is-A-test ANOTHER", + output: "thisIsATestAnother", + }, + { + input: "testingAlreadyCamelized", + output: "testingAlreadyCamelized", + }, + ] + + expectations.forEach((expectation) => { + expect(toCamelCase(expectation.input)).toEqual(expectation.output) + }) + }) +}) diff --git a/packages/utils/src/common/__tests__/upper-case-first.spec.ts b/packages/utils/src/common/__tests__/upper-case-first.spec.ts new file mode 100644 index 0000000000..584ac31124 --- /dev/null +++ b/packages/utils/src/common/__tests__/upper-case-first.spec.ts @@ -0,0 +1,36 @@ +import { upperCaseFirst } from "../upper-case-first" + +describe("upperCaseFirst", function () { + it("should convert first letter of the word to capital letter", function () { + const expectations = [ + { + input: "testing capitalize", + output: "Testing capitalize", + }, + { + input: "testing", + output: "Testing", + }, + { + input: "Testing", + output: "Testing", + }, + { + input: "TESTING", + output: "TESTING", + }, + { + input: "t", + output: "T", + }, + { + input: "", + output: "", + }, + ] + + expectations.forEach((expectation) => { + expect(upperCaseFirst(expectation.input)).toEqual(expectation.output) + }) + }) +}) diff --git a/packages/utils/src/common/index.ts b/packages/utils/src/common/index.ts index beb84ecb81..331c6fa027 100644 --- a/packages/utils/src/common/index.ts +++ b/packages/utils/src/common/index.ts @@ -8,12 +8,14 @@ export * from "./is-email" export * from "./is-object" export * from "./is-string" export * from "./lower-case-first" +export * from "./upper-case-first" export * from "./medusa-container" export * from "./object-to-string-path" export * from "./set-metadata" export * from "./simple-hash" export * from "./wrap-handler" export * from "./to-kebab-case" +export * from "./to-camel-case" export * from "./stringify-circular" export * from "./build-query" export * from "./handle-postgres-database-error" diff --git a/packages/utils/src/common/to-camel-case.ts b/packages/utils/src/common/to-camel-case.ts new file mode 100644 index 0000000000..827d2f07bc --- /dev/null +++ b/packages/utils/src/common/to-camel-case.ts @@ -0,0 +1,7 @@ +export function toCamelCase(str: string): string { + return /^([a-z]+)(([A-Z]([a-z]+))+)$/.test(str) + ? str + : str + .toLowerCase() + .replace(/[^a-zA-Z0-9]+(.)/g, (m, chr) => chr.toUpperCase()) +} diff --git a/packages/utils/src/common/upper-case-first.ts b/packages/utils/src/common/upper-case-first.ts new file mode 100644 index 0000000000..39b6888c05 --- /dev/null +++ b/packages/utils/src/common/upper-case-first.ts @@ -0,0 +1,3 @@ +export function upperCaseFirst(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1) +}