feat(medusa, types): Improve DX of model extensions (#4398)

This commit is contained in:
Riqwan Thamir
2023-06-29 13:45:16 +02:00
committed by GitHub
parent 1e88b4d5d9
commit 9760d4a96c
17 changed files with 405 additions and 39 deletions

View File

@@ -0,0 +1,6 @@
---
"@medusajs/medusa": patch
"@medusajs/utils": patch
---
feat(medusa, utils): improve devx for core entity customizations

View File

@@ -18,6 +18,7 @@ module.exports = {
transform: {
"^.+\\.[jt]s?$": "ts-jest",
},
modulePathIgnorePatterns: ["__fixtures__"],
testEnvironment: `node`,
moduleFileExtensions: [`js`, `jsx`, `ts`, `tsx`, `json`],
setupFilesAfterEnv: ["<rootDir>/setupTests.js"],

View File

@@ -0,0 +1,9 @@
module.exports = {
featureFlags: {},
projectConfig: {
database_url: "postgres://localhost/medusa-store",
database_logging: false
},
plugins: [],
modules: {},
};

View File

@@ -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'
}

View File

@@ -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")
})
})

View File

@@ -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")
})
})

View File

@@ -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<string, any>
}
export function getModelExtensionsMap({
directory,
pathGlob,
config = {},
}: GetModelExtensionMapParams): Map<
string,
ClassConstructor<unknown> | EntitySchema
> {
const modelExtensionsMap = new Map<
string,
ClassConstructor<unknown> | 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<unknown>
| EntitySchema
| undefined
if (extendedModel) {
Object.entries(extendedModel).map(
([_key, val]: [string, ClassConstructor<unknown> | EntitySchema]) => {
if (typeof val === "function" || val instanceof EntitySchema) {
if (config.register) {
const name = formatRegistrationName(modelExtensionPath)
modelExtensionsMap.set(name, val)
}
}
}
)
}
})
return modelExtensionsMap
}

View File

@@ -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 })

View File

@@ -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<unknown> | 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<unknown> | EntitySchema
const modelExtensionsMap = getModelExtensionsMap({
directory: rootDirectory,
pathGlob: extensionPathGlob,
config,
})
coreModels.forEach((modelPath) => {
const loaded = require(modelPath) as
| ClassConstructor<unknown>
| EntitySchema
if (loaded) {
Object.entries(loaded).map(
([, val]: [string, ClassConstructor<unknown> | 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<unknown>),
})

View File

@@ -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<void> {
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<unknown> | 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<unknown>
| EntitySchema
Object.entries(loaded).map(
([, val]: [string, ClassConstructor<unknown> | 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),
})

View File

@@ -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)
})
})

View File

@@ -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

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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"

View File

@@ -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())
}

View File

@@ -0,0 +1,3 @@
export function upperCaseFirst(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1)
}