@open-mercato/shared 0.7.1-develop.7122.1.421cefe668 → 0.7.1-develop.7130.1.fef2396fd8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/lib/db/duplicateEntities.js +52 -0
- package/dist/lib/db/duplicateEntities.js.map +7 -0
- package/dist/lib/db/duplicateEntityClassNames.js +66 -0
- package/dist/lib/db/duplicateEntityClassNames.js.map +7 -0
- package/dist/lib/db/mikro.js +31 -0
- package/dist/lib/db/mikro.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +6 -2
- package/src/lib/db/__tests__/duplicateEntities.test.ts +260 -0
- package/src/lib/db/__tests__/duplicateEntityClassNames.test.ts +172 -0
- package/src/lib/db/__tests__/duplicateEntityClassNamesExport.test.ts +63 -0
- package/src/lib/db/__tests__/fixtures/invoiceBilling.ts +7 -0
- package/src/lib/db/__tests__/fixtures/invoiceReporting.ts +7 -0
- package/src/lib/db/__tests__/fixtures/invoiceSubscriptions.ts +7 -0
- package/src/lib/db/__tests__/fixtures/ledgerBilling.ts +7 -0
- package/src/lib/db/__tests__/fixtures/ledgerReporting.ts +7 -0
- package/src/lib/db/__tests__/fixtures/ledgerSubscriptions.ts +7 -0
- package/src/lib/db/__tests__/registerOrmEntities.duplicates.test.ts +164 -0
- package/src/lib/db/duplicateEntities.ts +89 -0
- package/src/lib/db/duplicateEntityClassNames.ts +150 -0
- package/src/lib/db/mikro.ts +55 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DUPLICATE_ENTITY_CLASS_NAMES_REASON,
|
|
3
|
+
DUPLICATE_ENTITY_CLASS_NAMES_REMEDIATION,
|
|
4
|
+
findDuplicateEntityClassNames,
|
|
5
|
+
formatDuplicateEntityClassNamesWarning,
|
|
6
|
+
toDuplicateEntityClassNameFields,
|
|
7
|
+
} from '../duplicateEntityClassNames'
|
|
8
|
+
|
|
9
|
+
describe('findDuplicateEntityClassNames', () => {
|
|
10
|
+
it('reports nothing for an empty or single-entry list', () => {
|
|
11
|
+
expect(findDuplicateEntityClassNames([])).toEqual([])
|
|
12
|
+
expect(findDuplicateEntityClassNames([{ className: 'Invoice', moduleId: 'billing' }])).toEqual([])
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('reports nothing when class names differ', () => {
|
|
16
|
+
const groups = findDuplicateEntityClassNames([
|
|
17
|
+
{ className: 'Invoice', moduleId: 'billing' },
|
|
18
|
+
{ className: 'Ledger', moduleId: 'subscriptions' },
|
|
19
|
+
])
|
|
20
|
+
|
|
21
|
+
expect(groups).toEqual([])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('reports a name declared by two modules', () => {
|
|
25
|
+
const groups = findDuplicateEntityClassNames([
|
|
26
|
+
{ className: 'Invoice', moduleId: 'billing', sourcePath: 'modules/billing/data/entities.ts' },
|
|
27
|
+
{ className: 'Invoice', moduleId: 'subscriptions', sourcePath: 'modules/subscriptions/data/entities.ts' },
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
expect(groups).toEqual([
|
|
31
|
+
{
|
|
32
|
+
className: 'Invoice',
|
|
33
|
+
sources: [
|
|
34
|
+
{ moduleId: 'billing', sourcePath: 'modules/billing/data/entities.ts' },
|
|
35
|
+
{ moduleId: 'subscriptions', sourcePath: 'modules/subscriptions/data/entities.ts' },
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('treats identical module and path as the same class, not a collision', () => {
|
|
42
|
+
const groups = findDuplicateEntityClassNames([
|
|
43
|
+
{ className: 'Invoice', moduleId: 'billing', sourcePath: 'modules/billing/data/entities.ts' },
|
|
44
|
+
{ className: 'Invoice', moduleId: 'billing', sourcePath: 'modules/billing/data/entities.ts' },
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
expect(groups).toEqual([])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('treats a shared target as the same class reached twice', () => {
|
|
51
|
+
const target = class Invoice {}
|
|
52
|
+
const groups = findDuplicateEntityClassNames([
|
|
53
|
+
{ className: 'Invoice', moduleId: 'billing', target },
|
|
54
|
+
{ className: 'Invoice', moduleId: 'subscriptions', target },
|
|
55
|
+
])
|
|
56
|
+
|
|
57
|
+
expect(groups).toEqual([])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('collects three-way collisions and multiple names in one pass', () => {
|
|
61
|
+
const groups = findDuplicateEntityClassNames([
|
|
62
|
+
{ className: 'Invoice', moduleId: 'billing' },
|
|
63
|
+
{ className: 'Ledger', moduleId: 'billing' },
|
|
64
|
+
{ className: 'Invoice', moduleId: 'subscriptions' },
|
|
65
|
+
{ className: 'Ledger', moduleId: 'subscriptions' },
|
|
66
|
+
{ className: 'Ledger', moduleId: 'reporting' },
|
|
67
|
+
])
|
|
68
|
+
|
|
69
|
+
expect(groups.map((group) => group.className)).toEqual(['Invoice', 'Ledger'])
|
|
70
|
+
expect(groups[1].sources.map((source) => source.moduleId)).toEqual(['billing', 'subscriptions', 'reporting'])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('keeps unidentifiable entries distinct so a collision fails open', () => {
|
|
74
|
+
// Neither entry names a module, a file, or a class, so nothing distinguishes them.
|
|
75
|
+
// Collapsing them into one bucket would hide a genuine collision.
|
|
76
|
+
const groups = findDuplicateEntityClassNames([{ className: 'Invoice' }, { className: 'Invoice' }])
|
|
77
|
+
|
|
78
|
+
expect(groups).toHaveLength(1)
|
|
79
|
+
expect(groups[0].sources).toHaveLength(2)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('skips entries without a class name', () => {
|
|
83
|
+
expect(findDuplicateEntityClassNames([{ className: '' }, { className: '' }])).toEqual([])
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('formatDuplicateEntityClassNamesWarning', () => {
|
|
88
|
+
it('names the class, the modules and the source files', () => {
|
|
89
|
+
const message = formatDuplicateEntityClassNamesWarning([
|
|
90
|
+
{
|
|
91
|
+
className: 'Invoice',
|
|
92
|
+
sources: [
|
|
93
|
+
{ moduleId: 'billing', sourcePath: '/repo/modules/billing/data/entities.ts' },
|
|
94
|
+
{ moduleId: 'subscriptions', sourcePath: '/repo/modules/subscriptions/data/entities.ts' },
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
])
|
|
98
|
+
|
|
99
|
+
expect(message).toContain('Duplicate entity class name(s) defined by more than one enabled module: "Invoice".')
|
|
100
|
+
expect(message).toContain(' Invoice')
|
|
101
|
+
expect(message).toContain(' - billing (/repo/modules/billing/data/entities.ts)')
|
|
102
|
+
expect(message).toContain(' - subscriptions (/repo/modules/subscriptions/data/entities.ts)')
|
|
103
|
+
expect(message).toContain('Rename all but one of the colliding classes')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('lists every colliding name in one message', () => {
|
|
107
|
+
const message = formatDuplicateEntityClassNamesWarning([
|
|
108
|
+
{ className: 'Invoice', sources: [{ moduleId: 'a' }, { moduleId: 'b' }] },
|
|
109
|
+
{ className: 'Ledger', sources: [{ moduleId: 'a' }, { moduleId: 'b' }] },
|
|
110
|
+
])
|
|
111
|
+
|
|
112
|
+
expect(message).toContain('"Invoice", "Ledger"')
|
|
113
|
+
expect(message).toContain(' Invoice')
|
|
114
|
+
expect(message).toContain(' Ledger')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('degrades gracefully when the module id or path is unavailable', () => {
|
|
118
|
+
const message = formatDuplicateEntityClassNamesWarning([
|
|
119
|
+
{
|
|
120
|
+
className: 'Invoice',
|
|
121
|
+
sources: [
|
|
122
|
+
{ moduleId: undefined, sourcePath: '/repo/a/entities.ts' },
|
|
123
|
+
{ moduleId: undefined, sourcePath: undefined },
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
])
|
|
127
|
+
|
|
128
|
+
expect(message).toContain(' - /repo/a/entities.ts')
|
|
129
|
+
expect(message).toContain(' - unknown module')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('omits a source path that degraded to a bare class name', () => {
|
|
133
|
+
// MikroORM derives the decorator path by parsing a stack trace and falls back to
|
|
134
|
+
// the class name when that parse fails.
|
|
135
|
+
const message = formatDuplicateEntityClassNamesWarning([
|
|
136
|
+
{ className: 'Invoice', sources: [{ moduleId: 'billing', sourcePath: 'Invoice' }] },
|
|
137
|
+
])
|
|
138
|
+
|
|
139
|
+
const sourceLine = message.split('\n').find((line) => line.startsWith(' - '))
|
|
140
|
+
expect(sourceLine).toBe(' - billing')
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('toDuplicateEntityClassNameFields', () => {
|
|
145
|
+
// The runtime surface logs through the structured facade, where the message must stay
|
|
146
|
+
// constant and every dynamic value has to be a queryable field.
|
|
147
|
+
it('carries the collisions as fields beside the constant explanation', () => {
|
|
148
|
+
const groups = findDuplicateEntityClassNames([
|
|
149
|
+
{ className: 'Invoice', moduleId: 'billing', sourcePath: '/repo/modules/billing/data/entities.ts' },
|
|
150
|
+
{ className: 'Invoice', moduleId: 'subscriptions', sourcePath: '/repo/modules/subscriptions/data/entities.ts' },
|
|
151
|
+
])
|
|
152
|
+
|
|
153
|
+
expect(toDuplicateEntityClassNameFields(groups)).toEqual({
|
|
154
|
+
classNames: ['Invoice'],
|
|
155
|
+
duplicates: [
|
|
156
|
+
{
|
|
157
|
+
className: 'Invoice',
|
|
158
|
+
sources: [
|
|
159
|
+
{ moduleId: 'billing', sourcePath: '/repo/modules/billing/data/entities.ts' },
|
|
160
|
+
{ moduleId: 'subscriptions', sourcePath: '/repo/modules/subscriptions/data/entities.ts' },
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
reason: DUPLICATE_ENTITY_CLASS_NAMES_REASON,
|
|
165
|
+
remediation: DUPLICATE_ENTITY_CLASS_NAMES_REMEDIATION,
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('stays empty for no collisions', () => {
|
|
170
|
+
expect(toDuplicateEntityClassNameFields([]).classNames).toEqual([])
|
|
171
|
+
})
|
|
172
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const PACKAGE_ROOT = join(__dirname, '..', '..', '..', '..')
|
|
5
|
+
const SUBPATH = './lib/db/duplicateEntityClassNames'
|
|
6
|
+
|
|
7
|
+
type ExportTarget = { types?: string | string[]; default?: string }
|
|
8
|
+
|
|
9
|
+
function readExports(): Record<string, ExportTarget | string> {
|
|
10
|
+
const packageJson = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')) as {
|
|
11
|
+
exports?: Record<string, ExportTarget | string>
|
|
12
|
+
}
|
|
13
|
+
return packageJson.exports ?? {}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Node's subpath resolution: an exact key wins outright, and only when none matches do
|
|
18
|
+
* the `*` patterns compete on the longest static prefix. `@open-mercato/cli` imports this
|
|
19
|
+
* subpath across a workspace boundary, and resolvers do not agree on wildcard handling,
|
|
20
|
+
* so it must not depend on one.
|
|
21
|
+
*/
|
|
22
|
+
function resolveSubpath(exports: Record<string, ExportTarget | string>, subpath: string): string | null {
|
|
23
|
+
if (Object.prototype.hasOwnProperty.call(exports, subpath)) return subpath
|
|
24
|
+
let best: string | null = null
|
|
25
|
+
for (const key of Object.keys(exports)) {
|
|
26
|
+
const star = key.indexOf('*')
|
|
27
|
+
if (star < 0) continue
|
|
28
|
+
const prefix = key.slice(0, star)
|
|
29
|
+
const suffix = key.slice(star + 1)
|
|
30
|
+
if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue
|
|
31
|
+
if (subpath.length < prefix.length + suffix.length) continue
|
|
32
|
+
if (!best || prefix.length > best.slice(0, best.indexOf('*')).length) best = key
|
|
33
|
+
}
|
|
34
|
+
return best
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('@open-mercato/shared duplicateEntityClassNames export map', () => {
|
|
38
|
+
const exports = readExports()
|
|
39
|
+
|
|
40
|
+
it('resolves through an explicit entry rather than a wildcard pattern', () => {
|
|
41
|
+
expect(resolveSubpath(exports, SUBPATH)).toBe(SUBPATH)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('maps types to the source module and default to the built module', () => {
|
|
45
|
+
expect(exports[SUBPATH]).toEqual({
|
|
46
|
+
types: './src/lib/db/duplicateEntityClassNames.ts',
|
|
47
|
+
default: './dist/lib/db/duplicateEntityClassNames.js',
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// Both halves of the mapping must exist in a packed package. `default` is produced by
|
|
52
|
+
// `yarn build:packages`, which the validation gate runs before `yarn test`.
|
|
53
|
+
it('ships both mapped files', () => {
|
|
54
|
+
const target = exports[SUBPATH] as ExportTarget
|
|
55
|
+
expect(existsSync(join(PACKAGE_ROOT, target.types as string))).toBe(true)
|
|
56
|
+
expect(existsSync(join(PACKAGE_ROOT, target.default as string))).toBe(true)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('keeps the module importable under the mapped default condition', async () => {
|
|
60
|
+
const target = exports[SUBPATH] as ExportTarget
|
|
61
|
+
await expect(import(join(PACKAGE_ROOT, target.default as string))).resolves.toBeDefined()
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import 'reflect-metadata'
|
|
2
|
+
import { MetadataStorage } from '@mikro-orm/core'
|
|
3
|
+
import { Invoice as InvoiceBilling } from './fixtures/invoiceBilling'
|
|
4
|
+
import { Invoice as InvoiceSubscriptions } from './fixtures/invoiceSubscriptions'
|
|
5
|
+
import { Invoice as InvoiceReporting } from './fixtures/invoiceReporting'
|
|
6
|
+
import { Ledger as LedgerBilling } from './fixtures/ledgerBilling'
|
|
7
|
+
import { Ledger as LedgerSubscriptions } from './fixtures/ledgerSubscriptions'
|
|
8
|
+
|
|
9
|
+
const warn = jest.fn()
|
|
10
|
+
|
|
11
|
+
jest.mock('../../logger', () => ({
|
|
12
|
+
createLogger: () => ({
|
|
13
|
+
child: () => ({ debug: jest.fn(), info: jest.fn(), warn, error: jest.fn() }),
|
|
14
|
+
}),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
const GLOBAL_ENTITIES_KEY = '__openMercatoOrmEntities__'
|
|
18
|
+
const GLOBAL_REPORTED_KEY = '__openMercatoReportedDuplicateEntityClassNames__'
|
|
19
|
+
|
|
20
|
+
function readSourcePath(entity: unknown): string {
|
|
21
|
+
return (entity as Record<symbol, string>)[MetadataStorage.PATH_SYMBOL]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function stampModuleId(entity: unknown, stamp: string): void {
|
|
25
|
+
Object.defineProperty(entity, 'entityName', {
|
|
26
|
+
value: stamp,
|
|
27
|
+
configurable: true,
|
|
28
|
+
enumerable: false,
|
|
29
|
+
writable: false,
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('registerOrmEntities duplicate class name reporting', () => {
|
|
34
|
+
const originalEntities = (globalThis as Record<string, unknown>)[GLOBAL_ENTITIES_KEY]
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
warn.mockClear()
|
|
38
|
+
// Collisions are reported once per process; start each case from a clean slate.
|
|
39
|
+
delete (globalThis as Record<string, unknown>)[GLOBAL_REPORTED_KEY]
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
for (const entity of [InvoiceBilling, InvoiceSubscriptions]) {
|
|
44
|
+
delete (entity as Record<string, unknown>).entityName
|
|
45
|
+
}
|
|
46
|
+
if (typeof originalEntities === 'undefined') {
|
|
47
|
+
delete (globalThis as Record<string, unknown>)[GLOBAL_ENTITIES_KEY]
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
;(globalThis as Record<string, unknown>)[GLOBAL_ENTITIES_KEY] = originalEntities
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('warns and still registers when two modules contribute the same class name', async () => {
|
|
54
|
+
stampModuleId(InvoiceBilling, 'billing.Invoice')
|
|
55
|
+
stampModuleId(InvoiceSubscriptions, 'subscriptions.Invoice')
|
|
56
|
+
const entities = [InvoiceBilling, InvoiceSubscriptions]
|
|
57
|
+
const { registerOrmEntities, getOrmEntities } = await import('../mikro')
|
|
58
|
+
|
|
59
|
+
registerOrmEntities(entities)
|
|
60
|
+
|
|
61
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
62
|
+
// The structured-logging contract keeps the message constant and the dynamic values
|
|
63
|
+
// in queryable fields.
|
|
64
|
+
const [message, fields] = warn.mock.calls[0] as [string, Record<string, unknown>]
|
|
65
|
+
expect(message).toBe('Duplicate entity class names across enabled modules')
|
|
66
|
+
expect(fields.classNames).toEqual(['Invoice'])
|
|
67
|
+
expect(fields.duplicates).toEqual([
|
|
68
|
+
{
|
|
69
|
+
className: 'Invoice',
|
|
70
|
+
sources: [
|
|
71
|
+
{ moduleId: 'billing', sourcePath: readSourcePath(InvoiceBilling) },
|
|
72
|
+
{ moduleId: 'subscriptions', sourcePath: readSourcePath(InvoiceSubscriptions) },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
])
|
|
76
|
+
expect(typeof fields.remediation).toBe('string')
|
|
77
|
+
// Warning-only by design: registration must still complete.
|
|
78
|
+
expect(getOrmEntities()).toBe(entities)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('stays silent for a registration with unique class names', async () => {
|
|
82
|
+
const { registerOrmEntities } = await import('../mikro')
|
|
83
|
+
|
|
84
|
+
registerOrmEntities([InvoiceBilling])
|
|
85
|
+
|
|
86
|
+
expect(warn).not.toHaveBeenCalled()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('stays silent when the same class is registered twice', async () => {
|
|
90
|
+
const { registerOrmEntities } = await import('../mikro')
|
|
91
|
+
|
|
92
|
+
registerOrmEntities([InvoiceBilling, InvoiceBilling])
|
|
93
|
+
|
|
94
|
+
expect(warn).not.toHaveBeenCalled()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('stays silent for non-entity exports that share a name', async () => {
|
|
98
|
+
const { registerOrmEntities } = await import('../mikro')
|
|
99
|
+
|
|
100
|
+
registerOrmEntities([
|
|
101
|
+
function helper(): void {},
|
|
102
|
+
function helper(): void {},
|
|
103
|
+
{ name: 'TestEntity' },
|
|
104
|
+
{ name: 'TestEntity' },
|
|
105
|
+
])
|
|
106
|
+
|
|
107
|
+
expect(warn).not.toHaveBeenCalled()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('reports a standing collision once, not on every HMR re-registration', async () => {
|
|
111
|
+
stampModuleId(InvoiceBilling, 'billing.Invoice')
|
|
112
|
+
stampModuleId(InvoiceSubscriptions, 'subscriptions.Invoice')
|
|
113
|
+
const entities = [InvoiceBilling, InvoiceSubscriptions]
|
|
114
|
+
const { registerOrmEntities } = await import('../mikro')
|
|
115
|
+
|
|
116
|
+
registerOrmEntities(entities)
|
|
117
|
+
registerOrmEntities(entities)
|
|
118
|
+
registerOrmEntities(entities)
|
|
119
|
+
|
|
120
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('reports a newly introduced collision even after an earlier one was reported', async () => {
|
|
124
|
+
const { registerOrmEntities } = await import('../mikro')
|
|
125
|
+
|
|
126
|
+
registerOrmEntities([InvoiceBilling, InvoiceSubscriptions])
|
|
127
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
128
|
+
|
|
129
|
+
registerOrmEntities([InvoiceBilling, InvoiceSubscriptions, LedgerBilling, LedgerSubscriptions])
|
|
130
|
+
|
|
131
|
+
expect(warn).toHaveBeenCalledTimes(2)
|
|
132
|
+
// The already-reported name is not repeated.
|
|
133
|
+
expect(warn.mock.calls[1][1].classNames).toEqual(['Ledger'])
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('reports a collision again after it was fixed and reintroduced', async () => {
|
|
137
|
+
const { registerOrmEntities } = await import('../mikro')
|
|
138
|
+
|
|
139
|
+
registerOrmEntities([InvoiceBilling, InvoiceSubscriptions])
|
|
140
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
141
|
+
|
|
142
|
+
// The developer renames one of them; the next reload is clean.
|
|
143
|
+
registerOrmEntities([InvoiceBilling])
|
|
144
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
145
|
+
|
|
146
|
+
// Reverting the rename must warn again rather than stay silent forever.
|
|
147
|
+
registerOrmEntities([InvoiceBilling, InvoiceSubscriptions])
|
|
148
|
+
|
|
149
|
+
expect(warn).toHaveBeenCalledTimes(2)
|
|
150
|
+
expect(warn.mock.calls[1][1].classNames).toEqual(['Invoice'])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('reports again when the same class name starts colliding with a different module', async () => {
|
|
154
|
+
const { registerOrmEntities } = await import('../mikro')
|
|
155
|
+
|
|
156
|
+
registerOrmEntities([InvoiceBilling, InvoiceSubscriptions])
|
|
157
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
158
|
+
|
|
159
|
+
registerOrmEntities([InvoiceBilling, InvoiceReporting])
|
|
160
|
+
|
|
161
|
+
expect(warn).toHaveBeenCalledTimes(2)
|
|
162
|
+
expect(warn.mock.calls[1][1].classNames).toEqual(['Invoice'])
|
|
163
|
+
})
|
|
164
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { EntitySchema, MetadataStorage } from '@mikro-orm/core'
|
|
2
|
+
import {
|
|
3
|
+
findDuplicateEntityClassNames,
|
|
4
|
+
type DuplicateEntityClassNameGroup,
|
|
5
|
+
type EntityClassNameEntry,
|
|
6
|
+
} from './duplicateEntityClassNames'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Adapts a registered ORM entity array to the dependency-free collision detector in
|
|
10
|
+
* `./duplicateEntityClassNames`, which explains the underlying MikroORM behaviour.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
type DecoratedEntityClass = {
|
|
14
|
+
readonly name?: unknown
|
|
15
|
+
readonly entityName?: unknown
|
|
16
|
+
readonly [MetadataStorage.PATH_SYMBOL]?: unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readString(value: unknown): string | undefined {
|
|
20
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `enhanceEntities()` in the generated entity registry stamps `<moduleId>.<ExportName>`
|
|
25
|
+
* onto every entity export. MikroORM ignores that stamp, but it is the only place the
|
|
26
|
+
* contributing module id survives to runtime. Module ids may contain dots; the export
|
|
27
|
+
* name never does, so split on the last one. A class that declares its own `entityName`
|
|
28
|
+
* keeps it — `enhanceEntities()` never overwrites one — so a declared value containing a
|
|
29
|
+
* dot yields a bogus module id here; cosmetic in the warning text, and a dot-free value
|
|
30
|
+
* degrades to `undefined`.
|
|
31
|
+
*/
|
|
32
|
+
function readModuleIdFromStamp(stamp: unknown): string | undefined {
|
|
33
|
+
const value = readString(stamp)
|
|
34
|
+
if (!value) return undefined
|
|
35
|
+
const separator = value.lastIndexOf('.')
|
|
36
|
+
return separator > 0 ? value.slice(0, separator) : undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The registered array holds every function export of each module's entity file, so
|
|
41
|
+
* plain helper functions travel alongside real entities. Only classes touched by a
|
|
42
|
+
* MikroORM decorator carry `MetadataStorage.PATH_SYMBOL` as an own property; everything
|
|
43
|
+
* else is skipped so helpers that happen to share a name are never reported.
|
|
44
|
+
*/
|
|
45
|
+
function toEntityClassNameEntry(value: unknown): EntityClassNameEntry | null {
|
|
46
|
+
// `enhanceEntities()` in the generated registry keeps only `typeof value === 'function'`
|
|
47
|
+
// exports, so an EntitySchema a module exports never arrives through it. This branch is
|
|
48
|
+
// live only for direct callers such as the testing bootstrap — and since the module id
|
|
49
|
+
// stamp is applied to those same function exports, an EntitySchema carries no module id
|
|
50
|
+
// and reports by path alone.
|
|
51
|
+
if (EntitySchema.is(value)) {
|
|
52
|
+
const className = readString(value.meta?.className)
|
|
53
|
+
if (!className) return null
|
|
54
|
+
return { className, sourcePath: readString(value.meta?.path), target: value }
|
|
55
|
+
}
|
|
56
|
+
if (typeof value !== 'function') return null
|
|
57
|
+
if (!Object.prototype.hasOwnProperty.call(value, MetadataStorage.PATH_SYMBOL)) return null
|
|
58
|
+
const entity = value as DecoratedEntityClass
|
|
59
|
+
const className = readString(entity.name)
|
|
60
|
+
if (!className) return null
|
|
61
|
+
return {
|
|
62
|
+
className,
|
|
63
|
+
moduleId: readModuleIdFromStamp(entity.entityName),
|
|
64
|
+
sourcePath: readString(entity[MetadataStorage.PATH_SYMBOL]),
|
|
65
|
+
target: value,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function collectEntityClassNameEntries(entities: readonly unknown[]): EntityClassNameEntry[] {
|
|
70
|
+
const entries: EntityClassNameEntry[] = []
|
|
71
|
+
for (const value of entities) {
|
|
72
|
+
let entry: EntityClassNameEntry | null = null
|
|
73
|
+
try {
|
|
74
|
+
entry = toEntityClassNameEntry(value)
|
|
75
|
+
} catch {
|
|
76
|
+
// Reading a name off an exotic export (a throwing getter, a proxy) must not turn
|
|
77
|
+
// a diagnostic into a boot failure. Skip the value and keep checking the rest.
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
if (entry) entries.push(entry)
|
|
81
|
+
}
|
|
82
|
+
return entries
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function findDuplicateRegisteredEntityClassNames(
|
|
86
|
+
entities: readonly unknown[],
|
|
87
|
+
): DuplicateEntityClassNameGroup[] {
|
|
88
|
+
return findDuplicateEntityClassNames(collectEntityClassNameEntries(entities))
|
|
89
|
+
}
|