Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions src/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,11 @@ export function generateWithMaps(
const errors: string[] = [];
const edits = new Map<string, SourceEdit[]>();
const needsRuntime = new Set<string>();
// Delegated contracts the generated code imports itself: file -> exported name -> import.
const needsContract = new Map<string, Map<string, { alias: string; specifier: string }>>();
// Delegated contracts the generated code imports itself: file -> qualifier -> import.
const needsContract = new Map<
string,
Map<string, { alias: string; exported: string; specifier: string }>
>();
// Aliased so an injected import cannot shadow or collide with handwritten names.
const runtime = '__mappergenGetMapper';

Expand Down Expand Up @@ -138,6 +141,7 @@ export function generateWithMaps(
function referenceContract(
from: TypeScript.SourceFile,
contract: TypeScript.ClassDeclaration,
qualifier: string,
exported: string,
): string {
// Module resolution substitutes the source extension, so the output one is emitted.
Expand All @@ -148,11 +152,13 @@ export function generateWithMaps(
.replace(/\.mts$/, '.mjs')
.replace(/\.cts$/, '.cjs');

const alias = `__mappergen${exported}`;
// Keyed by name, so several fields delegating to one contract share the import.
// Keyed by the qualifier: unique within the file, where a class name is not —
// two files may export contracts of the same name, imported under local aliases.
const alias = `__mappergenContract${qualifier}`;
const imports = needsContract.get(from.fileName) ?? new Map();
imports.set(exported, {
imports.set(qualifier, {
alias,
exported,
specifier: JSON.stringify(path.startsWith('.') ? path : `./${path}`),
});
needsContract.set(from.fileName, imports);
Expand Down Expand Up @@ -515,7 +521,7 @@ export function generateWithMaps(
// An erased import leaves nothing to call, so generate one instead.
// The contract is imported under the name it exports, not a local alias.
if (isTypeOnly(alias))
reference = referenceContract(file, declaration, symbol.name);
reference = referenceContract(file, declaration, qualifier, symbol.name);
} else {
const candidates = contractsByName.get(qualifier) ?? [];
if (candidates.length > 1)
Expand All @@ -532,7 +538,8 @@ export function generateWithMaps(
`Delegate '${rule.delegate}': '${qualifier}' must be an @mapper contract in scope or in the mapping project.`,
);
owner = found;
reference = referenceContract(file, found, qualifier);
// Looked up by name, so the qualifier is the exported name.
reference = referenceContract(file, found, qualifier, qualifier);
}
}

Expand Down Expand Up @@ -752,7 +759,7 @@ export function generateWithMaps(
text: `import { getMapper as ${runtime} } from 'mappergen';\n`,
});

for (const [exported, { alias, specifier }] of needsContract.get(file) ?? [])
for (const { alias, exported, specifier } of needsContract.get(file)?.values() ?? [])
changes.push({
start: 0,
end: 0,
Expand Down
43 changes: 43 additions & 0 deletions test/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1805,6 +1805,49 @@ export type Contract = UserMapping;`,
);
});

test('two contracts sharing a class name are delegated to separately', async (t) => {
// Arrange: identical shapes, so only the instance proves which contract ran.
const f = contracts(
t,
{
'a/User.ts': `export class AlphaUser { id!: string }
/** @mapper */
export abstract class UserMapping {
abstract toUser(source: { id: string }): AlphaUser;
}`,
'b/User.ts': `export class BetaUser { id!: string }
/** @mapper */
export abstract class UserMapping {
abstract toUser(source: { id: string }): BetaUser;
}`,
'Sensor.ts': `import { getMapper } from 'mappergen';
import type { UserMapping as Alpha, AlphaUser } from './a/User.js';
import type { UserMapping as Beta, BetaUser } from './b/User.js';
/** @mapper */
export abstract class SensorMapping {
/**
* @delegate owner Alpha.toUser
* @delegate reviewer Beta.toUser
*/
abstract toDto(source: { owner: { id: string }; reviewer: { id: string } }): { owner: AlphaUser; reviewer: BetaUser };
}
export const sensors = getMapper(SensorMapping);`,
},
{},
['**/*.ts'],
);

// Act
const module = await executeFile(f, 'Sensor.js');
const alpha = await import(pathToFileURL(join(f.dir, 'dist/a/User.js')).href);
const beta = await import(pathToFileURL(join(f.dir, 'dist/b/User.js')).href);
const result = module.sensors.toDto({ owner: { id: 'u1' }, reviewer: { id: 'u2' } });

// Assert
assert.ok(result.owner instanceof alpha.AlphaUser);
assert.ok(result.reviewer instanceof beta.BetaUser);
});

test('a renamed type-only import is delegated under the name the contract exports', async (t) => {
// Arrange
const f = contracts(
Expand Down
Loading