diff --git a/cherrypick/lib/src/scope.dart b/cherrypick/lib/src/scope.dart index cba58c8..fd6f708 100644 --- a/cherrypick/lib/src/scope.dart +++ b/cherrypick/lib/src/scope.dart @@ -19,7 +19,7 @@ import 'package:cherrypick/src/global_cycle_detector.dart'; import 'package:cherrypick/src/binding_resolver.dart'; import 'package:cherrypick/src/module.dart'; import 'package:cherrypick/src/observer.dart'; -// import 'package:cherrypick/src/log_format.dart'; + /// Represents a DI scope (container) for modules, subscopes, /// and dependency resolution (sync/async) in CherryPick. @@ -60,6 +60,13 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin { @override CherryPickObserver get observer => _observer; + bool get _isSilentObserver => _observer is SilentCherryPickObserver; + + bool get _canUseDirectResolvePath => + _isSilentObserver && + !isCycleDetectionEnabled && + !isGlobalCycleDetectionEnabled; + /// COLLECTS all resolved instances that implement [Disposable]. final Set _disposables = HashSet(); @@ -71,21 +78,23 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin { Scope(this._parentScope, {required CherryPickObserver observer}) : _observer = observer { setScopeId(_generateScopeId()); - observer.onScopeOpened(scopeId ?? 'NO_ID'); - observer.onDiagnostic( - 'Scope created: ${scopeId ?? 'NO_ID'}', - details: { - 'type': 'Scope', - 'name': scopeId ?? 'NO_ID', - if (_parentScope?.scopeId != null) 'parent': _parentScope!.scopeId, - 'description': 'scope created', - }, - ); + if (!_isSilentObserver) { + observer.onScopeOpened(scopeId ?? 'NO_ID'); + observer.onDiagnostic( + 'Scope created: ${scopeId ?? 'NO_ID'}', + details: { + 'type': 'Scope', + 'name': scopeId ?? 'NO_ID', + if (_parentScope?.scopeId != null) 'parent': _parentScope!.scopeId, + 'description': 'scope created', + }, + ); + } } final Set _modulesList = HashSet(); - // индекс для мгновенного поиска binding’ов + // index for fast binding lookup final Map> _bindingResolvers = {}; /// Generates a unique identifier string for this scope instance. @@ -119,16 +128,18 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin { childScope.enableGlobalCycleDetection(); } _scopeMap[name] = childScope; - observer.onDiagnostic( - 'SubScope created: $name', - details: { - 'type': 'SubScope', - 'name': name, - 'id': childScope.scopeId, - if (scopeId != null) 'parent': scopeId, - 'description': 'subscope created', - }, - ); + if (!_isSilentObserver) { + observer.onDiagnostic( + 'SubScope created: $name', + details: { + 'type': 'SubScope', + 'name': name, + 'id': childScope.scopeId, + if (scopeId != null) 'parent': scopeId, + 'description': 'subscope created', + }, + ); + } } return _scopeMap[name]!; } @@ -149,17 +160,19 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin { if (childScope.scopeId != null) { GlobalCycleDetector.instance.removeScopeDetector(childScope.scopeId!); } - observer.onScopeClosed(childScope.scopeId ?? name); - observer.onDiagnostic( - 'SubScope closed: $name', - details: { - 'type': 'SubScope', - 'name': name, - 'id': childScope.scopeId, - if (scopeId != null) 'parent': scopeId, - 'description': 'subscope closed', - }, - ); + if (!_isSilentObserver) { + observer.onScopeClosed(childScope.scopeId ?? name); + observer.onDiagnostic( + 'SubScope closed: $name', + details: { + 'type': 'SubScope', + 'name': name, + 'id': childScope.scopeId, + if (scopeId != null) 'parent': scopeId, + 'description': 'subscope closed', + }, + ); + } } _scopeMap.remove(name); } @@ -175,30 +188,34 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin { /// ``` Scope installModules(List modules) { _modulesList.addAll(modules); - if (modules.isNotEmpty) { + if (!_isSilentObserver && modules.isNotEmpty) { observer.onModulesInstalled( modules.map((m) => m.runtimeType.toString()).toList(), scopeName: scopeId, ); } for (var module in modules) { - observer.onDiagnostic( - 'Module installed: ${module.runtimeType}', - details: { - 'type': 'Module', - 'name': module.runtimeType.toString(), - 'scope': scopeId, - 'description': 'module installed', - }, - ); + if (!_isSilentObserver) { + observer.onDiagnostic( + 'Module installed: ${module.runtimeType}', + details: { + 'type': 'Module', + 'name': module.runtimeType.toString(), + 'scope': scopeId, + 'description': 'module installed', + }, + ); + } module.builder(this); // Associate bindings with this scope's observer for (final binding in module.bindingSet) { binding.observer = observer; - binding.logAllDeferred(); + if (!_isSilentObserver) { + binding.logAllDeferred(); + } } + _addModuleToIndex(module); } - _rebuildResolversIndex(); return this; } @@ -212,20 +229,22 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin { /// testScope.dropModules(); /// ``` Scope dropModules() { - if (_modulesList.isNotEmpty) { + if (!_isSilentObserver && _modulesList.isNotEmpty) { observer.onModulesRemoved( _modulesList.map((m) => m.runtimeType.toString()).toList(), scopeName: scopeId, ); } - observer.onDiagnostic( - 'Modules dropped for scope: $scopeId', - details: { - 'type': 'Scope', - 'name': scopeId, - 'description': 'modules dropped', - }, - ); + if (!_isSilentObserver) { + observer.onDiagnostic( + 'Modules dropped for scope: $scopeId', + details: { + 'type': 'Scope', + 'name': scopeId, + 'description': 'modules dropped', + }, + ); + } _modulesList.clear(); _rebuildResolversIndex(); return this; @@ -242,6 +261,16 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin { /// final special = scope.resolve(named: 'special'); /// ``` T resolve({String? named, dynamic params}) { + if (_canUseDirectResolvePath) { + final result = _tryResolveInternal(named: named, params: params); + if (result == null) { + throw StateError( + 'Can\'t resolve dependency `$T`. Maybe you forget register it?'); + } + if (result is Disposable) _disposables.add(result); + return result; + } + observer.onInstanceRequested(T.toString(), T, scopeName: scopeId); T result; if (isGlobalCycleDetectionEnabled) { @@ -315,6 +344,12 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin { /// final maybeDb = scope.tryResolve(); /// ``` T? tryResolve({String? named, dynamic params}) { + if (_canUseDirectResolvePath) { + final result = _tryResolveInternal(named: named, params: params); + if (result != null && result is Disposable) _disposables.add(result); + return result; + } + T? result; if (isGlobalCycleDetectionEnabled) { result = withGlobalCycleDetection(T, named, () { @@ -358,6 +393,21 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin { /// final special = await scope.resolveAsync(named: "special"); /// ``` Future resolveAsync({String? named, dynamic params}) async { + if (_canUseDirectResolvePath) { + final result = + await _tryResolveAsyncInternal(named: named, params: params); + if (result == null) { + throw StateError( + "Can't resolve async dependency `$T`. Maybe you forget register it?"); + } + if (result is Disposable) _disposables.add(result); + return result; + } + return _resolveAsyncWithObserverPath(named: named, params: params); + } + + Future _resolveAsyncWithObserverPath( + {String? named, dynamic params}) async { T result; if (isGlobalCycleDetectionEnabled) { result = await withGlobalCycleDetection>(T, named, () async { @@ -413,6 +463,17 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin { /// final user = await scope.tryResolveAsync(); /// ``` Future tryResolveAsync({String? named, dynamic params}) async { + if (_canUseDirectResolvePath) { + final result = + await _tryResolveAsyncInternal(named: named, params: params); + if (result != null && result is Disposable) _disposables.add(result); + return result; + } + return _tryResolveAsyncWithObserverPath(named: named, params: params); + } + + Future _tryResolveAsyncWithObserverPath( + {String? named, dynamic params}) async { T? result; if (isGlobalCycleDetectionEnabled) { result = await withGlobalCycleDetection>(T, named, () async { @@ -441,12 +502,12 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin { } /// Direct async resolution for [T] without cycle check. Returns null if missing. Internal use only. - Future _tryResolveAsyncInternal( - {String? named, dynamic params}) async { + Future _tryResolveAsyncInternal({String? named, dynamic params}) { final resolver = _findBindingResolver(named); // 1 - Try from own modules; 2 - Fallback to parent return resolver?.resolveAsync(params) ?? - _parentScope?.tryResolveAsync(named: named, params: params); + _parentScope?.tryResolveAsync(named: named, params: params) ?? + Future.value(null); } /// Looks up the [BindingResolver] for [T] and [named] within this scope. @@ -454,23 +515,27 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin { BindingResolver? _findBindingResolver(String? named) => _bindingResolvers[T]?[named] as BindingResolver?; + void _addModuleToIndex(Module module) { + for (var binding in module.bindingSet) { + _bindingResolvers.putIfAbsent(binding.key, () => {}); + final nameKey = binding.isNamed ? binding.name : null; + _bindingResolvers[binding.key]![nameKey] = binding.resolver!; + } + } + /// Rebuilds the internal index of all [BindingResolver]s from installed modules. - /// Called after [installModules] and [dropModules]. Internal use only. + /// Called after [dropModules]. Internal use only. void _rebuildResolversIndex() { _bindingResolvers.clear(); for (var module in _modulesList) { - for (var binding in module.bindingSet) { - _bindingResolvers.putIfAbsent(binding.key, () => {}); - final nameKey = binding.isNamed ? binding.name : null; - _bindingResolvers[binding.key]![nameKey] = binding.resolver!; - } + _addModuleToIndex(module); } } /// Tracks resolved [Disposable] instances, to ensure dispose is called automatically. /// Internal use only. void _trackDisposable(Object? obj) { - if (obj is Disposable && !_disposables.contains(obj)) { + if (obj is Disposable) { _disposables.add(obj); } } @@ -487,14 +552,14 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin { /// ``` Future dispose() async { // Create copies to avoid concurrent modification - final scopesCopy = Map.from(_scopeMap); - for (final subScope in scopesCopy.values) { + final scopes = _scopeMap.values.toList(); + for (final subScope in scopes) { await subScope.dispose(); } _scopeMap.clear(); - final disposablesCopy = Set.from(_disposables); - for (final d in disposablesCopy) { + final disposables = _disposables.toList(); + for (final d in disposables) { await d.dispose(); } _disposables.clear(); diff --git a/cherrypick/test/src/scope_fast_path_test.dart b/cherrypick/test/src/scope_fast_path_test.dart new file mode 100644 index 0000000..c5d092d --- /dev/null +++ b/cherrypick/test/src/scope_fast_path_test.dart @@ -0,0 +1,148 @@ +import 'package:cherrypick/cherrypick.dart'; +import 'package:test/test.dart'; + +class _IntModule extends Module { + final int value; + final void Function()? onBuild; + + _IntModule(this.value, {this.onBuild}); + + @override + void builder(Scope currentScope) { + onBuild?.call(); + bind().toInstance(value); + } +} + +class _StringModule extends Module { + final String value; + + _StringModule(this.value); + + @override + void builder(Scope currentScope) { + bind().toInstance(value); + } +} + +class _NamedIntModule extends Module { + final String name; + final int value; + + _NamedIntModule(this.name, this.value); + + @override + void builder(Scope currentScope) { + bind().withName(name).toInstance(value); + } +} + +class _DisposableModule extends Module { + @override + void builder(Scope currentScope) { + bind<_DisposableService>().toProvide(() => _DisposableService()); + } +} + +class _DisposableService implements Disposable { + bool disposed = false; + + @override + Future dispose() async { + disposed = true; + } +} + +void main() { + tearDown(() => CherryPick.closeRootScope()); + + group('SilentCherryPickObserver fast-path', () { + test('resolve with default silent observer uses fast-path', () { + final scope = CherryPick.openSafeRootScope(); + scope.installModules([_IntModule(42)]); + + final result = scope.resolve(); + expect(result, 42); + }); + + test('resolveAsync with default silent observer uses fast-path', () async { + final scope = CherryPick.openSafeRootScope(); + scope.installModules([_IntModule(42)]); + + final result = await scope.resolveAsync(); + expect(result, 42); + }); + + test('tryResolve with default silent observer uses fast-path', () { + final scope = CherryPick.openSafeRootScope(); + scope.installModules([_IntModule(42)]); + + final result = scope.tryResolve(); + expect(result, 42); + }); + + test('tryResolveAsync with default silent observer uses fast-path', + () async { + final scope = CherryPick.openSafeRootScope(); + scope.installModules([_IntModule(42)]); + + final result = await scope.tryResolveAsync(); + expect(result, 42); + }); + + test('installModules with silent observer skips diagnostics', () { + final scope = CherryPick.openSafeRootScope(); + var buildCalls = 0; + scope.installModules([_IntModule(42, onBuild: () => buildCalls++)]); + expect(buildCalls, 1); + expect(scope.resolve(), 42); + }); + + test('Disposable is tracked even in silent observer fast-path', () async { + final scope = CherryPick.openSafeRootScope(); + scope.installModules([_DisposableModule()]); + + final service = scope.resolve<_DisposableService>(); + expect(service.disposed, false); + await scope.dispose(); + expect(service.disposed, true); + }); + }); + + group('Incremental module index', () { + test('Multiple installModules calls accumulate bindings', () { + final scope = CherryPick.openSafeRootScope(); + scope.installModules([_IntModule(1)]); + scope.installModules([_StringModule('two')]); + + expect(scope.resolve(), 1); + expect(scope.resolve(), 'two'); + }); + + test('dropModules clears all bindings', () { + final scope = CherryPick.openSafeRootScope(); + scope.installModules([_IntModule(1)]); + scope.dropModules(); + + expect(() => scope.resolve(), throwsStateError); + }); + + test('Re-installing modules after dropModules works', () { + final scope = CherryPick.openSafeRootScope(); + scope.installModules([_IntModule(1)]); + scope.dropModules(); + scope.installModules([_IntModule(2)]); + + expect(scope.resolve(), 2); + }); + + test('Named bindings are indexed incrementally', () { + final scope = CherryPick.openSafeRootScope(); + scope.installModules([_NamedIntModule('a', 1)]); + scope.installModules([_NamedIntModule('b', 2)]); + + expect(scope.resolve(named: 'a'), 1); + expect(scope.resolve(named: 'b'), 2); + }); + }); +}