diff --git a/packages/catalyst_builder/CHANGELOG.md b/packages/catalyst_builder/CHANGELOG.md index 2507079..11bc4ec 100644 --- a/packages/catalyst_builder/CHANGELOG.md +++ b/packages/catalyst_builder/CHANGELOG.md @@ -1,3 +1,9 @@ +## 6.3.0 + +### Features + +- Implemented `ServiceContainer.scope()`: child scope with an isolated singleton cache and parent resolution fallback (also for `has` and `resolveByTag`). + ## 6.2.1 ### Fixes diff --git a/packages/catalyst_builder/lib/src/service_container.dart b/packages/catalyst_builder/lib/src/service_container.dart index 26447bf..b8688f9 100644 --- a/packages/catalyst_builder/lib/src/service_container.dart +++ b/packages/catalyst_builder/lib/src/service_container.dart @@ -20,6 +20,8 @@ class ServiceContainer implements AbstractServiceContainer, ServiceRegistry { var _booted = false; + ServiceContainer? _parent; + @override T? tryResolve([Type? t]) { return _tryResolveInternal(t ?? T); @@ -34,7 +36,7 @@ class ServiceContainer implements AbstractServiceContainer, ServiceRegistry { } var descriptor = _knownServices[exposedType]; if (descriptor == null) { - return null; + return _parent?._tryResolveInternal(t); } var instance = descriptor.produce(); if (descriptor.service.lifetime == ServiceLifetime.singleton) { @@ -56,13 +58,15 @@ class ServiceContainer implements AbstractServiceContainer, ServiceRegistry { @override List resolveByTag(Symbol tag) { var services = []; - if (!_servicesByTag.containsKey(tag)) { - return services; - } - for (var svc in _servicesByTag[tag]!) { - services.add((_tryResolveInternal(svc) as dynamic)); + if (_servicesByTag.containsKey(tag)) { + for (var svc in _servicesByTag[tag]!) { + services.add((_tryResolveInternal(svc) as dynamic)); + } } - return services; + return [ + ...services, + ...?_parent?.resolveByTag(tag), + ]; } @override @@ -114,7 +118,8 @@ class ServiceContainer implements AbstractServiceContainer, ServiceRegistry { @override bool has([Type? type]) { var lookupType = type ?? T; - return _knownServices.containsKey(_exposeMap[lookupType] ?? lookupType); + return _knownServices.containsKey(_exposeMap[lookupType] ?? lookupType) || + (_parent?.has(type) ?? false); } @override @@ -171,6 +176,33 @@ class ServiceContainer implements AbstractServiceContainer, ServiceRegistry { return enhanced; } + @override + AbstractServiceContainer scope({ + List services = const [], + Map parameters = const {}, + }) { + _ensureBoot(); + var child = ServiceContainer(); + child._parent = this; + // child._serviceInstances stays its OWN fresh empty map (the key difference + // from enhance, which shares the parent's map by reference). Scope-local + // services registered below bind their factory to the child, so they + // resolve their own dependencies through the child first, then the parent. + for (var service in services) { + child._registerInternal( + service.returnType, + service.factory, + service.service, + ); + } + // Parameters are snapshot-merged at creation; service resolution still uses + // live parent fallback through _tryResolveInternal. + child.parameters.addAll(this.parameters); + child.parameters.addAll(parameters); + child._booted = true; + return child; + } + @override void applyPlugin(ServiceContainerPlugin plugin) { if (_booted) { diff --git a/packages/catalyst_builder/pubspec.yaml b/packages/catalyst_builder/pubspec.yaml index 11c6b04..0b22ce1 100644 --- a/packages/catalyst_builder/pubspec.yaml +++ b/packages/catalyst_builder/pubspec.yaml @@ -1,6 +1,6 @@ name: catalyst_builder description: A lightweight and easy to use dependency injection container builder for dart. -version: 6.2.1 +version: 6.3.0 homepage: 'https://github.com/mintware-de/catalyst_builder' repository: 'https://github.com/mintware-de/catalyst_builder/tree/main/packages/catalyst_builder' documentation: 'https://github.com/mintware-de/catalyst_builder/wiki' @@ -32,7 +32,7 @@ dependencies: build: ^4.0.0 glob: ^2.1.0 analyzer: ">=8.1.0 <14.0.0" - catalyst_builder_contracts: ^2.1.0 + catalyst_builder_contracts: ^2.2.0 dev_dependencies: test: any diff --git a/packages/catalyst_builder/pubspec_overrides.yaml b/packages/catalyst_builder/pubspec_overrides.yaml new file mode 100644 index 0000000..2ef6a0a --- /dev/null +++ b/packages/catalyst_builder/pubspec_overrides.yaml @@ -0,0 +1,6 @@ +# Local development override: resolve the sibling contracts package from source +# so the builder's tests run against the in-repo (yet-unpublished) version. +# Ignored by the parent workspace resolution (overrides only apply at the root). +dependency_overrides: + catalyst_builder_contracts: + path: ../catalyst_builder_contracts diff --git a/packages/catalyst_builder/test/unit/scope_test.dart b/packages/catalyst_builder/test/unit/scope_test.dart new file mode 100644 index 0000000..b9096f2 --- /dev/null +++ b/packages/catalyst_builder/test/unit/scope_test.dart @@ -0,0 +1,83 @@ +import 'package:catalyst_builder/catalyst_builder.dart'; +import 'package:catalyst_builder_contracts/catalyst_builder_contracts.dart'; +import 'package:test/test.dart'; + +class A {} + +class B {} + +class C { + C(this.a); + + final A a; +} + +void main() { + group('ServiceContainer.scope', () { + test('parent singleton resolves through the scope (cached at parent)', () { + var parent = ServiceContainer() + ..register((_) => A()) + ..boot(); + + var child = parent.scope(); + + expect(child.resolve(), same(parent.resolve())); + }); + + test('scope-local service does not leak to the parent', () { + var parent = ServiceContainer()..boot(); + + var child = parent.scope( + services: [ + LazyServiceDescriptor((_) => B()), + ], + ); + + expect(child.resolve(), isA()); + expect(parent.has(), isFalse); + expect(parent.tryResolve(), isNull); + }); + + test('sibling scopes are isolated but share parent singletons', () { + var parent = ServiceContainer() + ..register((_) => A()) + ..boot(); + + var s1 = parent.scope( + services: [LazyServiceDescriptor((_) => B())], + ); + var s2 = parent.scope( + services: [LazyServiceDescriptor((_) => B())], + ); + + expect(s1.resolve(), isNot(same(s2.resolve()))); + expect(s1.resolve(), same(s2.resolve())); + }); + + test('scope-local service can depend on a parent service', () { + var parent = ServiceContainer() + ..register((_) => A()) + ..boot(); + + var child = parent.scope( + services: [ + LazyServiceDescriptor((c) => C(c.resolve())), + ], + ); + + expect(child.resolve().a, same(parent.resolve())); + }); + + test('has and resolveByTag fall back to the parent', () { + const tag = #thing; + var parent = ServiceContainer() + ..register((_) => A(), const Service(tags: [tag])) + ..boot(); + + var child = parent.scope(); + + expect(child.has(), isTrue); + expect(child.resolveByTag(tag), contains(parent.resolve())); + }); + }); +} diff --git a/packages/catalyst_builder_contracts/CHANGELOG.md b/packages/catalyst_builder_contracts/CHANGELOG.md index d33f679..42288a1 100644 --- a/packages/catalyst_builder_contracts/CHANGELOG.md +++ b/packages/catalyst_builder_contracts/CHANGELOG.md @@ -1,3 +1,9 @@ +## 2.2.0 + +### Features + +- Added `scope()` to `AbstractServiceContainer` for hierarchical child scopes (own instance cache + parent fallback). + ## 2.1.0 ### Features diff --git a/packages/catalyst_builder_contracts/lib/src/abstract_service_container.dart b/packages/catalyst_builder_contracts/lib/src/abstract_service_container.dart index da5730b..46f9c16 100644 --- a/packages/catalyst_builder_contracts/lib/src/abstract_service_container.dart +++ b/packages/catalyst_builder_contracts/lib/src/abstract_service_container.dart @@ -34,6 +34,19 @@ abstract interface class AbstractServiceContainer { Map parameters = const {}, }); + /// Creates a child scope: a container with its OWN singleton-instance cache + /// and this container as parent. Unknown services resolve from the parent + /// (and cache there); services passed in [services] are scope-local and + /// cached only in the child, so they never leak into the parent or siblings. + /// + /// [parameters] are snapshot-merged at creation (parent parameters first, + /// then the provided [parameters]); service resolution still falls back to + /// the live parent on a local miss. + AbstractServiceContainer scope({ + List services = const [], + Map parameters = const {}, + }); + /// Resolves a service or gets a matching parameter. /// If neither a service nor a parameter is found, an exception is thrown. T resolveOrGetParameter( diff --git a/packages/catalyst_builder_contracts/pubspec.yaml b/packages/catalyst_builder_contracts/pubspec.yaml index 2e007ed..99ad3f5 100644 --- a/packages/catalyst_builder_contracts/pubspec.yaml +++ b/packages/catalyst_builder_contracts/pubspec.yaml @@ -1,6 +1,6 @@ name: catalyst_builder_contracts description: This is the contracts package for the catalyst_builder package. It includes annotations and marker interfaces. -version: 2.1.0 +version: 2.2.0 homepage: 'https://github.com/mintware-de/catalyst_builder' repository: 'https://github.com/mintware-de/catalyst_builder/tree/main/packages/catalyst_builder_contracts' documentation: https://github.com/mintware-de/catalyst_builder/wiki