Skip to content

Commit 414c8f9

Browse files
committed
feat: add group feature
1 parent 0ae7403 commit 414c8f9

File tree

2 files changed

+501
-5
lines changed

2 files changed

+501
-5
lines changed

src/index.ts

Lines changed: 242 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export type InjectionToken<T = unknown> = string | symbol | Constructor<T>
3232
* {
3333
* provide: 'MyService',
3434
* useClass: MyServiceImpl,
35+
* deps: [ConfigService], // Optional: for weight calculation
3536
* onInit: async (instance) => await instance.initialize()
3637
* }
3738
*/
@@ -40,6 +41,9 @@ export interface ClassProvider<T = unknown> {
4041
provide: InjectionToken<T>
4142
/** Class constructor to instantiate */
4243
useClass: Constructor<T>
44+
/** Optional dependencies (affects resolution order/weight) */
45+
// biome-ignore lint/suspicious/noExplicitAny: Dependencies can be of any type
46+
deps?: (InjectionToken | Constructor<any>)[]
4347
/** Optional lifecycle hook called after instantiation */
4448
onInit?: (instance: T) => Promise<void> | void
4549
}
@@ -421,6 +425,119 @@ export function getInjectableMetadata(
421425
return Reflect.getMetadata('injectable:options', target)
422426
}
423427

428+
// ============================================================================
429+
// Group Decorator (for grouping providers)
430+
// ============================================================================
431+
432+
/**
433+
* Options for @Group decorator
434+
* Used to group related providers together
435+
*/
436+
export interface GroupOptions {
437+
/**
438+
* Providers that belong to this group
439+
* Can include classes, providers, or other groups
440+
*/
441+
providers?: Provider[]
442+
443+
/**
444+
* Dependencies for weight calculation
445+
* These affect resolution order even if not directly used
446+
*/
447+
// biome-ignore lint/suspicious/noExplicitAny: Dependencies can be of any type
448+
deps?: (InjectionToken | Constructor<any>)[]
449+
450+
/**
451+
* Additional options (for future extensibility)
452+
*/
453+
[key: string]: unknown
454+
}
455+
456+
/**
457+
* Group class decorator
458+
*
459+
* Marks a class as a provider group. Groups allow you to organize related
460+
* providers together and can be used in deps arrays or bootstrap.
461+
* Groups are flattened during resolution.
462+
*
463+
* @param options - Configuration for the group
464+
* @returns A class decorator
465+
*
466+
* @example
467+
* // Create a group of auth-related services
468+
* &#64;Group({
469+
* providers: [AuthService, TokenService, UserService]
470+
* })
471+
* class AuthModule {}
472+
*
473+
* @example
474+
* // Use group in deps
475+
* container.register({
476+
* provide: AppService,
477+
* useFactory: () => new AppService(),
478+
* deps: [AuthModule, ConfigService] // AuthModule gets flattened
479+
* })
480+
*
481+
* @example
482+
* // Bootstrap with groups
483+
* await container.bootstrap([
484+
* ConfigModule,
485+
* AuthModule,
486+
* AppService
487+
* ])
488+
*/
489+
export function Group(options: GroupOptions = {}): ClassDecorator {
490+
return (target: object) => {
491+
// Store group metadata
492+
const metadata = {
493+
providers: [],
494+
deps: [],
495+
...options,
496+
}
497+
Reflect.defineMetadata('group:options', metadata, target)
498+
}
499+
}
500+
501+
/**
502+
* Get group metadata from a class
503+
*
504+
* Retrieves the metadata stored by the @Group() decorator.
505+
*
506+
* @param target - The class to get metadata from
507+
* @returns The group options or undefined if not decorated with @Group
508+
*
509+
* @example
510+
* &#64;Group({ providers: [ServiceA, ServiceB] })
511+
* class MyModule {}
512+
*
513+
* const metadata = getGroupMetadata(MyModule)
514+
* console.log(metadata?.providers) // [ServiceA, ServiceB]
515+
*/
516+
export function getGroupMetadata(
517+
// biome-ignore lint/suspicious/noExplicitAny: Constructor can be of any type
518+
target: Constructor<any>,
519+
): GroupOptions | undefined {
520+
return Reflect.getMetadata('group:options', target)
521+
}
522+
523+
/**
524+
* Check if a constructor is decorated with @Group
525+
*
526+
* @param target - The constructor to check
527+
* @returns True if the constructor has @Group decorator
528+
*
529+
* @example
530+
* if (isGroup(MyModule)) {
531+
* console.log('MyModule is a group')
532+
* }
533+
*/
534+
// biome-ignore lint/suspicious/noExplicitAny: Constructor can be of any type
535+
export function isGroup(target: any): target is Constructor<any> {
536+
return (
537+
typeof target === 'function' && Reflect.hasMetadata('group:options', target)
538+
)
539+
}
540+
424541
// ============================================================================
425542
// Container with Injection Tokens
426543
// ============================================================================
@@ -934,11 +1051,15 @@ export class Container {
9341051

9351052
// Get dependencies based on provider type
9361053
if (this.isFactoryProvider(provider)) {
937-
// Factory provider: use explicit deps
938-
deps = provider.deps || []
1054+
// Factory provider: use explicit deps (flatten groups)
1055+
deps = provider.deps ? this.flattenDeps(provider.deps) : []
9391056
} else if (this.isClassProvider(provider)) {
940-
// Class provider: get constructor dependencies
941-
deps = this.getClassDependencies(provider.useClass)
1057+
// Class provider: merge explicit deps with constructor dependencies
1058+
const explicitDeps = provider.deps ? this.flattenDeps(provider.deps) : []
1059+
const constructorDeps = this.getClassDependencies(provider.useClass)
1060+
// Combine both, using Set to avoid duplicates
1061+
const allDeps = [...new Set([...explicitDeps, ...constructorDeps])]
1062+
deps = allDeps
9421063
} else {
9431064
// Plain constructor
9441065
// biome-ignore lint/suspicious/noExplicitAny: Provider is a constructor of any type
@@ -1128,6 +1249,7 @@ export class Container {
11281249
*
11291250
* This is a convenient way to register and resolve multiple providers at once.
11301251
* All providers are registered first, then resolved in optimal order.
1252+
* Groups are automatically flattened during registration.
11311253
*
11321254
* @param providersOrConfig - Array of providers or config object with providers
11331255
* @returns The container instance (for chaining)
@@ -1145,6 +1267,14 @@ export class Container {
11451267
* await container.bootstrap({
11461268
* providers: [UserService, DatabaseService, AuthService]
11471269
* })
1270+
*
1271+
* @example
1272+
* // With groups
1273+
* await container.bootstrap([
1274+
* ConfigModule, // Group gets flattened
1275+
* AuthModule, // Group gets flattened
1276+
* AppService
1277+
* ])
11481278
*/
11491279
public async bootstrap(
11501280
providersOrConfig: Provider[] | { providers: Provider[] },
@@ -1156,8 +1286,15 @@ export class Container {
11561286
? providersOrConfig
11571287
: providersOrConfig.providers
11581288

1289+
// Flatten groups in the providers array
1290+
console.log('Flattening groups...')
1291+
const flattenedProviders = this.flattenProviders(providers)
1292+
console.log(
1293+
`Flattened ${providers.length} items into ${flattenedProviders.length} providers\n`,
1294+
)
1295+
11591296
// Register all providers
1160-
for (const provider of providers) {
1297+
for (const provider of flattenedProviders) {
11611298
this.register(provider)
11621299
}
11631300

@@ -1218,6 +1355,106 @@ export class Container {
12181355
// Type Guards and Helper Methods
12191356
// ============================================================================
12201357

1358+
/**
1359+
* Flatten groups in a providers array
1360+
*
1361+
* Recursively expands any groups found in the providers array.
1362+
* Groups are expanded to their constituent providers.
1363+
*
1364+
* @private
1365+
* @param providers - Array of providers that may contain groups
1366+
* @returns Flattened array of providers with groups expanded
1367+
*/
1368+
private flattenProviders(providers: Provider[]): Provider[] {
1369+
const result: Provider[] = []
1370+
const visited = new Set<Constructor<unknown>>()
1371+
1372+
const flatten = (items: Provider[]) => {
1373+
for (const item of items) {
1374+
// Check if it's a group (plain constructor with @Group decorator)
1375+
if (typeof item === 'function' && isGroup(item)) {
1376+
// Prevent infinite recursion
1377+
if (visited.has(item)) {
1378+
continue
1379+
}
1380+
visited.add(item)
1381+
1382+
const groupMeta = getGroupMetadata(item)
1383+
if (groupMeta?.providers && groupMeta.providers.length > 0) {
1384+
// Recursively flatten nested groups
1385+
flatten(groupMeta.providers)
1386+
}
1387+
} else {
1388+
// Regular provider, add it
1389+
result.push(item)
1390+
}
1391+
}
1392+
}
1393+
1394+
flatten(providers)
1395+
return result
1396+
}
1397+
1398+
/**
1399+
* Flatten groups in a deps array
1400+
*
1401+
* Expands any groups found in the deps array to their constituent providers.
1402+
* Also includes the group's own deps for weight calculation.
1403+
*
1404+
* @private
1405+
* @param deps - Array of dependencies that may contain groups
1406+
* @returns Flattened array of dependencies with groups expanded
1407+
*/
1408+
private flattenDeps(
1409+
// biome-ignore lint/suspicious/noExplicitAny: Dependencies can be of any type
1410+
deps: (InjectionToken | Constructor<any>)[],
1411+
// biome-ignore lint/suspicious/noExplicitAny: Dependencies can be of any type
1412+
): (InjectionToken | Constructor<any>)[] {
1413+
// biome-ignore lint/suspicious/noExplicitAny: Dependencies can be of any type
1414+
const result: (InjectionToken | Constructor<any>)[] = []
1415+
const visited = new Set<Constructor<unknown>>()
1416+
1417+
// biome-ignore lint/suspicious/noExplicitAny: Dependencies can be of any type
1418+
const flatten = (items: (InjectionToken | Constructor<any>)[]) => {
1419+
for (const item of items) {
1420+
// Check if it's a group
1421+
if (typeof item === 'function' && isGroup(item)) {
1422+
// Prevent infinite recursion
1423+
if (visited.has(item)) {
1424+
continue
1425+
}
1426+
visited.add(item)
1427+
1428+
const groupMeta = getGroupMetadata(item)
1429+
if (groupMeta) {
1430+
// Add the group's deps first (for weight calculation)
1431+
if (groupMeta.deps && groupMeta.deps.length > 0) {
1432+
flatten(groupMeta.deps)
1433+
}
1434+
1435+
// Then flatten the group's providers
1436+
if (groupMeta.providers && groupMeta.providers.length > 0) {
1437+
const flattenedProviders = this.flattenProviders(
1438+
groupMeta.providers,
1439+
)
1440+
// Extract tokens from providers
1441+
for (const provider of flattenedProviders) {
1442+
const token = this.getProviderKey(provider)
1443+
result.push(token)
1444+
}
1445+
}
1446+
}
1447+
} else {
1448+
// Regular dependency, add it
1449+
result.push(item)
1450+
}
1451+
}
1452+
}
1453+
1454+
flatten(deps)
1455+
return result
1456+
}
1457+
12211458
/**
12221459
* Check if a provider is a class provider
12231460
*

0 commit comments

Comments
 (0)