Skip to content
Open
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
67 changes: 66 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,71 @@ autowire.makeIdLegacy() // switch back to base64-encoded IDs

---

## 🏷️ Tagged Services

Tags classify services into reusable plural collections. Tag attributes remain application-owned metadata until a consumer explicitly gives an attribute meaning.

NDI supports two tagged injection forms. The existing `!tagged` form remains unchanged for compatibility, while `@tagged(...)` adds Symfony-style collection projection with priority ordering and optional indexing.

```yaml
services:
handler.fast:
class: './handlers/FastHandler'
tags:
- name: app.handler
attributes:
priority: 20
key: fast

handler.fallback:
class: './handlers/FallbackHandler'
tags:
- name: app.handler
attributes:
priority: 0
key: fallback

handler.runner:
class: './HandlerRunner'
arguments:
- '@tagged(app.handler)'
```

| Form | Result | Ordering | Attribute semantics |
|---|---|---|---|
| `!tagged app.handler` | `Array` | Definition order | Attributes are ignored |
| `@tagged(app.handler)` | `Array` | Integer `priority` descending; definition order breaks ties | `priority` controls ordering |
| `@tagged(app.handler, key)` | `Map` | Same priority ordering | `key` supplies each map key; service id is the fallback |

The indexed form can use any tag attribute name, not only `key`:

```yaml
arguments:
- '@tagged(app.handler, name)'
```

If a service declares the same tag more than once, unindexed projection includes that service once and uses the first tag occurrence for priority. Indexed projection can expose separate entries for repeated tag occurrences when they provide different indexes, matching Symfony tagged-iterator behavior.

Programmatic registration uses the same metadata:

```js
import { ContainerBuilder, TaggedReference } from 'node-dependency-injection'

const container = new ContainerBuilder()
container.register('handler.fast', FastHandler)
.addTag('app.handler', new Map([
['priority', 20],
['key', 'fast']
]))

container.register('runner', HandlerRunner)
.addArgument(new TaggedReference('app.handler', 'key'))
```

Use `!tagged` when you need the historical definition-order behavior. Use `@tagged(...)` when the collection itself needs ordering or keyed projection.

---

## 🗝️ Keyed Services

Keyed services let you register multiple implementations of the same interface under a named group, then retrieve a specific one by key or inject the entire group as a `Map`.
Expand Down Expand Up @@ -432,4 +497,4 @@ Inspired by the [Symfony](http://symfony.com) Dependency Injection component —
<p align="center">
<a href="https://github.com/zazoomauro/node-dependency-injection/blob/master/LICENCE">MIT License</a> &nbsp;·&nbsp;
Made with ❤️ by <a href="https://twitter.com/zazoomauro">@zazoomauro</a>
</p>
</p>
195 changes: 191 additions & 4 deletions lib/Autowire.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,18 @@ import Definition from './Definition'
import Reference from './Reference'
import AutowireIdentifier from './AutowireIdentifier'
import ContainerDefaultDirMustBeSet from './Exception/ContainerDefaultDirMustBeSet'
import AmbiguousAutowireException from './Exception/AmbiguousAutowireException'
import PassConfig from './PassConfig'
import AutowireOverridePass from './CompilerPass/AutowireOverridePass'

const AUTOWIRE_ALIAS_RESOLUTIONS = [
'first',
'first-or-unique',
'unique',
'unique-or-fail',
'none'
]

export default class Autowire {
/**
* @param {ContainerBuilder} container
Expand All @@ -22,6 +31,9 @@ export default class Autowire {
this._excludedFolders = []
this._serviceFile = null
this._idStrategy = 'readable'
this._autowireAliasResolution = 'first'
this._interfaceCandidates = new Map()
this._autowireRequests = new Map()
try {
this._tsConfigFullPath = tsConfigFullPath || path.join(process.cwd(), 'tsconfig.json')
this._tsConfigPaths = json5.parse(
Expand Down Expand Up @@ -49,6 +61,25 @@ export default class Autowire {
return this._container
}

/**
* @param {'first'|'first-or-unique'|'unique'|'unique-or-fail'|'none'} value
*/
set autowireAliasResolution (value) {
if (!AUTOWIRE_ALIAS_RESOLUTIONS.includes(value)) {
throw new TypeError(
`Invalid autowireAliasResolution '${value}'. Expected one of: ${AUTOWIRE_ALIAS_RESOLUTIONS.join(', ')}`
)
}
this._autowireAliasResolution = value
}

/**
* @returns {'first'|'first-or-unique'|'unique'|'unique-or-fail'|'none'}
*/
get autowireAliasResolution () {
return this._autowireAliasResolution
}

/**
* @private
* @param {string}
Expand Down Expand Up @@ -138,6 +169,8 @@ export default class Autowire {
* @return {Promise}
*/
async process () {
this._interfaceCandidates.clear()
this._autowireRequests.clear()
this._container.loggerHelper.info(`Autowiring services from: ${this._rootDirectory}`)
const promises = []
for (const filePath of this._walk(this._rootDirectory)) {
Expand Down Expand Up @@ -284,6 +317,7 @@ export default class Autowire {
)
continue
}
this._recordAutowireRequest(argumentId, ServiceClass.name, paramName || typeNameForArgument)
definition.addArgument(new Reference(argumentId), definition.abstract)
}
return definition
Expand Down Expand Up @@ -335,9 +369,13 @@ export default class Autowire {
}

/**
* Record every implementation discovered by autowire. Depending on
* autowireAliasResolution, the historical first-discovered alias may also
* be created immediately while discovery is still in progress.
*
* @private
* @param {any} classDeclaration
* @param {any} body
* @param {Map} importMap
* @param {any} parsedFile
* @param {string} serviceId
*/
Expand All @@ -350,11 +388,160 @@ export default class Autowire {
importMap,
parsedFile
)
if (!aliasId || this.container.hasAlias(aliasId)) {
if (!aliasId) {
continue
}
this._container.loggerHelper.debug(`Autowire aliasing interface: ${interfaceType} -> ${serviceId}`)
this.container.setAlias(aliasId, serviceId)
if (!this._interfaceCandidates.has(aliasId)) {
this._interfaceCandidates.set(aliasId, {
interfaceType,
serviceIds: new Set()
})
}
this._interfaceCandidates.get(aliasId).serviceIds.add(serviceId)

if (
(this._autowireAliasResolution === 'first' ||
this._autowireAliasResolution === 'first-or-unique') &&
!this.container.hasAlias(aliasId)
) {
this._container.loggerHelper.debug(`Autowire aliasing interface: ${interfaceType} -> ${serviceId}`)
this.container.setAlias(aliasId, serviceId)
}
}
}

/**
* @private
* @param {string} dependencyId
* @param {string} service
* @param {string} argument
*/
_recordAutowireRequest (dependencyId, service, argument) {
if (!this._autowireRequests.has(dependencyId)) {
this._autowireRequests.set(dependencyId, [])
}
this._autowireRequests.get(dependencyId).push({ service, argument })
}

/**
* Apply the late alias-resolution portion of the configured policy after
* beforeOptimization compiler passes have had a chance to alter the graph.
*
* @returns {void}
*/
resolveInterfaceAliases () {
if (this._autowireAliasResolution === 'first' || this._autowireAliasResolution === 'none') {
return
}

this._resolveUniqueInterfaceAliases()

if (this._autowireAliasResolution === 'unique-or-fail') {
this._failOnAmbiguousInterfaces()
}
}

/**
* Create or repair aliases only when exactly one autowire-discovered
* implementation is still registered. Any valid existing alias is treated
* as authoritative regardless of how it was created.
*
* @private
* @returns {void}
*/
_resolveUniqueInterfaceAliases () {
for (const [aliasId, candidate] of this._interfaceCandidates) {
if (this._isAliasValid(aliasId)) {
continue
}

const serviceIds = this._survivingInterfaceCandidates(candidate)
if (serviceIds.length !== 1) {
continue
}

this._container.loggerHelper.debug(
`Autowire aliasing unique interface: ${candidate.interfaceType} -> ${serviceIds[0]}`
)
this.container.setAlias(aliasId, serviceIds[0])
}
}

/**
* @private
* @param {{serviceIds: Set<string>}} candidate
* @returns {string[]}
*/
_survivingInterfaceCandidates (candidate) {
return [...candidate.serviceIds]
.filter((id) => this.container.hasDefinition(id))
.sort()
}

/**
* Alias validity intentionally follows runtime lookup semantics: an alias
* must point directly at a registered definition (or service_container when
* enabled). A valid alias is never replaced by autowire.
*
* @private
* @param {string} aliasId
* @returns {boolean}
*/
_isAliasValid (aliasId) {
if (!this.container.hasAlias(aliasId)) {
return false
}

const targetId = this.container._alias.get(aliasId)
return this.container.hasDefinition(targetId) ||
(targetId === 'service_container' && this.container.containerReferenceAsService)
}

/**
* Reject only singular autowire requests whose discovered provider
* population remains ambiguous after compiler passes and unique resolution.
*
* @private
* @returns {void}
*/
_failOnAmbiguousInterfaces () {
for (const [aliasId, candidate] of this._interfaceCandidates) {
if (this._isAliasValid(aliasId)) {
continue
}

const serviceIds = this._survivingInterfaceCandidates(candidate)
if (serviceIds.length < 2) {
continue
}

const recordedRequests = this._autowireRequests.get(aliasId) ?? []
const consumers = []

for (const [serviceId, definition] of this._container.definitions) {
for (let index = 0; index < definition.args.length; index += 1) {
const argument = definition.args[index]
if (!(argument instanceof Reference) || argument.id !== aliasId) {
continue
}

const service = definition.Object?.name || serviceId
const recorded = recordedRequests.find((request) => request.service === service)
consumers.push({
service,
argument: recorded?.argument || `#${index + 1}`
})
}
}

if (consumers.length > 0) {
throw new AmbiguousAutowireException(
candidate.interfaceType,
aliasId,
serviceIds,
consumers
)
}
}
}

Expand Down
6 changes: 6 additions & 0 deletions lib/Compiler.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ export default class Compiler {
async _optimize () {
this._container.loggerHelper.debug('Running compiler phase: beforeOptimization')
await this._container._compilerPass.process(PassConfig.TYPE_BEFORE_OPTIMIZATION)

// Alias resolution that depends on the compiled graph happens only after
// beforeOptimization passes have had a chance to replace or remove services,
// but before OptimizePass begins instantiating definitions.
this._container.autowire?.resolveInterfaceAliases()

this._container.loggerHelper.debug('Running compiler phase: optimize')
await this._container._compilerPass.process(PassConfig.TYPE_OPTIMIZE)
}
Expand Down
12 changes: 11 additions & 1 deletion lib/ContainerBuilder.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import RootDirectoryMustBeAbsolute from './Exception/RootDirectoryMustBeAbsolute
import KeyedGroupNotFoundException from './Exception/KeyedGroupNotFoundException'
import KeyedServiceNotFoundException from './Exception/KeyedServiceNotFoundException'
import KeyedGroupNoDefaultException from './Exception/KeyedGroupNoDefaultException'
import KeyedGroupMultipleDefaultsException from './Exception/KeyedGroupMultipleDefaultsException'
import ServiceFile from './ServiceFile'
import LoggerHelper from './LoggerHelper'
import ContainerValidator from './ContainerValidator'
Expand Down Expand Up @@ -271,13 +272,22 @@ class ContainerBuilder {
return this.get(id)
}

const defaults = []
for (const [, id] of groupMap) {
const definition = this._definitions.get(id)
if (definition && definition.keyedDefault) {
return this.get(id)
defaults.push(id)
}
}

if (defaults.length === 1) {
return this.get(defaults[0])
}

if (defaults.length > 1) {
throw new KeyedGroupMultipleDefaultsException(group, defaults)
}

throw new KeyedGroupNoDefaultException(group)
}

Expand Down
Loading