feat(medusa, types): Improve DX of model extensions (#4398)
This commit is contained in:
6
.changeset/proud-ghosts-speak.md
Normal file
6
.changeset/proud-ghosts-speak.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@medusajs/medusa": patch
|
||||
"@medusajs/utils": patch
|
||||
---
|
||||
|
||||
feat(medusa, utils): improve devx for core entity customizations
|
||||
@@ -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"],
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
featureFlags: {},
|
||||
projectConfig: {
|
||||
database_url: "postgres://localhost/medusa-store",
|
||||
database_logging: false
|
||||
},
|
||||
plugins: [],
|
||||
modules: {},
|
||||
};
|
||||
@@ -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'
|
||||
}
|
||||
47
packages/medusa/src/loaders/__tests__/models.spec.ts
Normal file
47
packages/medusa/src/loaders/__tests__/models.spec.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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>),
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
36
packages/utils/src/common/__tests__/to-camel-case.spec.ts
Normal file
36
packages/utils/src/common/__tests__/to-camel-case.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
36
packages/utils/src/common/__tests__/upper-case-first.spec.ts
Normal file
36
packages/utils/src/common/__tests__/upper-case-first.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
7
packages/utils/src/common/to-camel-case.ts
Normal file
7
packages/utils/src/common/to-camel-case.ts
Normal 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())
|
||||
}
|
||||
3
packages/utils/src/common/upper-case-first.ts
Normal file
3
packages/utils/src/common/upper-case-first.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function upperCaseFirst(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
Reference in New Issue
Block a user