From f3da091bb07ed1b0137dce603e4761ad262252a2 Mon Sep 17 00:00:00 2001 From: Felix Angelov Date: Wed, 7 Jun 2023 08:19:34 -0700 Subject: [PATCH] feat: add `package:scoped` (#600) --- .github/workflows/main.yaml | 4 ++ README.md | 3 +- packages/scoped/.gitignore | 7 ++ packages/scoped/README.md | 24 +++++++ packages/scoped/analysis_options.yaml | 1 + packages/scoped/coverage_badge.svg | 20 ++++++ packages/scoped/example/main.dart | 17 +++++ packages/scoped/lib/scoped.dart | 4 ++ packages/scoped/lib/src/scoped.dart | 80 +++++++++++++++++++++ packages/scoped/pubspec.yaml | 15 ++++ packages/scoped/test/src/scoped_test.dart | 86 +++++++++++++++++++++++ 11 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 packages/scoped/.gitignore create mode 100644 packages/scoped/README.md create mode 100644 packages/scoped/analysis_options.yaml create mode 100644 packages/scoped/coverage_badge.svg create mode 100644 packages/scoped/example/main.dart create mode 100644 packages/scoped/lib/scoped.dart create mode 100644 packages/scoped/lib/src/scoped.dart create mode 100644 packages/scoped/pubspec.yaml create mode 100644 packages/scoped/test/src/scoped_test.dart diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index f7fa413a..33a65535 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -66,6 +66,10 @@ jobs: - ./.github/workflows/main.yaml - ./.github/actions/dart_package - packages/jwt/** + scoped: + - ./.github/workflows/main.yaml + - ./.github/actions/dart_package + - packages/scoped/** - uses: dorny/paths-filter@v2 name: Verify Detection diff --git a/README.md b/README.md index 1c2c53f9..73292542 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Home of the Shorebird Tools ## Status -Shorebird code push is in Open Beta! Instructions for install and usage are at +Shorebird code push is in Open Beta! Instructions for install and usage are at https://docs.shorebird.dev/ ## Packages @@ -25,6 +25,7 @@ This repository is a monorepo containing the following packages: | [shorebird_code_push_protocol](packages/shorebird_code_push_protocol/README.md) | Dart library which contains common interfaces used by Shorebird CodePush | | [discord_gcp_alerts](packages/discord_gcp_alerts/README.md) | Dart server which forwards GCP alerts to Discord | | [jwt](packages/jwt/README.md) | Dart library for verifying Json Web Tokens | +| [scoped](packages/scoped/README.md) | A simple dependency injection library built on Zones | For more information, please refer to the documentation for each package. diff --git a/packages/scoped/.gitignore b/packages/scoped/.gitignore new file mode 100644 index 00000000..526da158 --- /dev/null +++ b/packages/scoped/.gitignore @@ -0,0 +1,7 @@ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +pubspec.lock \ No newline at end of file diff --git a/packages/scoped/README.md b/packages/scoped/README.md new file mode 100644 index 00000000..7332e946 --- /dev/null +++ b/packages/scoped/README.md @@ -0,0 +1,24 @@ +# Scoped + +A simple dependency injection library built on Zones. + +## Quick Start + +```dart +import 'package:scoped/scoped.dart'; + +final value = create(() => 42); + +void main() { + runScoped(scopeA, values: {value}); +} + +void scopeA() { + print(read(value)); // 42 + runScoped(scopeB, values: {value.overrideWith(() => 0)}); +} + +void scopeB() { + print(read(value)); // 0 +} +``` diff --git a/packages/scoped/analysis_options.yaml b/packages/scoped/analysis_options.yaml new file mode 100644 index 00000000..b388541f --- /dev/null +++ b/packages/scoped/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.5.0.0.yaml diff --git a/packages/scoped/coverage_badge.svg b/packages/scoped/coverage_badge.svg new file mode 100644 index 00000000..499e98ce --- /dev/null +++ b/packages/scoped/coverage_badge.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + coverage + coverage + 100% + 100% + + diff --git a/packages/scoped/example/main.dart b/packages/scoped/example/main.dart new file mode 100644 index 00000000..2921daf6 --- /dev/null +++ b/packages/scoped/example/main.dart @@ -0,0 +1,17 @@ +// ignore_for_file: avoid_print +import 'package:scoped/scoped.dart'; + +final value = create(() => 42); + +void main() { + runScoped(scopeA, values: {value}); +} + +void scopeA() { + print(read(value)); // 42 + runScoped(scopeB, values: {value.overrideWith(() => 0)}); +} + +void scopeB() { + print(read(value)); // 0 +} diff --git a/packages/scoped/lib/scoped.dart b/packages/scoped/lib/scoped.dart new file mode 100644 index 00000000..71d37626 --- /dev/null +++ b/packages/scoped/lib/scoped.dart @@ -0,0 +1,4 @@ +/// A simple dependency injection library built on Zones +library scoped; + +export 'src/scoped.dart'; diff --git a/packages/scoped/lib/src/scoped.dart b/packages/scoped/lib/src/scoped.dart new file mode 100644 index 00000000..0cccdb64 --- /dev/null +++ b/packages/scoped/lib/src/scoped.dart @@ -0,0 +1,80 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; + +/// {@template scoped_ref} +/// A reference to a scoped value. +/// {@endtemplate} +@immutable +class ScopedRef { + /// {@macro scoped_ref} + ScopedRef(this._create) : _key = Object(); + + ScopedRef._(T Function() create, Object key) + : _create = create, + _key = key; + + final T Function() _create; + final Object _key; + late final T _value = _create(); + + /// Overrides the value of the current [ScopedRef] + /// with the provided [create]. + ScopedRef overrideWith(T Function() create) { + return ScopedRef._(create, _key); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (runtimeType != other.runtimeType) return false; + if (other is ScopedRef) return _key == other._key; + return false; + } + + @override + int get hashCode => _key.hashCode; +} + +/// Creates a [ScopedRef] which can later be used to access the +/// value, [T] returned by [create]. +ScopedRef create(T Function() create) => ScopedRef(create); + +/// Attempts to retrieve the value for the [ref]. +/// If [read] is called with a [ref] which is not available +/// in the current scope, a [StateError] will be thrown. +T read(ScopedRef ref) { + final value = (Zone.current[ref._key] as ScopedRef?)?._value; + if (value == null) { + throw StateError( + ''' +read(...) was called in a scope which does not contain a corresponding value for the provided ref. +Did you forget to call: runScoped(() {...}, values: {value})?''', + ); + } + return value; +} + +/// Runs [body] within a scope which has access to the set of refs in [values]. +R runScoped( + R Function() body, { + Set> values = const {}, +}) { + return runZoned( + body, + zoneValues: {for (final value in values) value._key: value}, + ); +} + +/// Runs [body] within a scope which has access to the set of refs in [values]. +R? runScopedGuarded( + R Function() body, { + required void Function(Object error, StackTrace stack) onError, + Set> values = const {}, +}) { + return runZonedGuarded( + body, + onError, + zoneValues: {for (final value in values) value._key: value}, + ); +} diff --git a/packages/scoped/pubspec.yaml b/packages/scoped/pubspec.yaml new file mode 100644 index 00000000..3cc67c2a --- /dev/null +++ b/packages/scoped/pubspec.yaml @@ -0,0 +1,15 @@ +name: scoped +description: A simple dependency injection library built on Zones +version: 0.1.0+1 +publish_to: none + +environment: + sdk: ">=3.0.0 <4.0.0" + +dependencies: + meta: ^1.0.0 + +dev_dependencies: + mocktail: ^0.3.0 + test: ^1.0.0 + very_good_analysis: ^5.0.0 diff --git a/packages/scoped/test/src/scoped_test.dart b/packages/scoped/test/src/scoped_test.dart new file mode 100644 index 00000000..322f0a5e --- /dev/null +++ b/packages/scoped/test/src/scoped_test.dart @@ -0,0 +1,86 @@ +// ignore_for_file: prefer_const_constructors +import 'package:scoped/scoped.dart'; +import 'package:test/test.dart'; + +void main() { + group('Scoped', () { + test('read throws StateError when ref is not available', () { + final value = create(() => 42); + expect(() => read(value), throwsStateError); + }); + + test('calls onError when uncaught exception occurs', () { + final value = create(() => 42); + late final Object exception; + runScopedGuarded( + () => read(value), + onError: (error, _) => exception = error, + ); + expect(exception, isNotNull); + }); + + test('read accesses the value when ref is available', () { + final value = create(() => 42); + runScoped( + () => expect(read(value), equals(42)), + values: {value}, + ); + }); + + test('value is computed lazily and cached', () { + var createCallCount = 0; + final value = create(() { + createCallCount++; + return 42; + }); + + expect(createCallCount, equals(0)); + + runScoped( + () { + expect(read(value), equals(42)); + expect(read(value), equals(42)); + expect(read(value), equals(42)); + }, + values: {value}, + ); + + expect(createCallCount, equals(1)); + }); + + test('value can be overridden', () { + final value = create(() => 42); + + runScoped( + () { + expect(read(value), equals(42)); + + runScoped( + () => expect(read(value), equals(0)), + values: {value.overrideWith(() => 0)}, + ); + }, + values: {value}, + ); + }); + + test('overrides are considered equal', () { + final value = create(() => 42); + final override = value.overrideWith(() => 0); + expect(value, equals(override)); + expect(value.hashCode, equals(override.hashCode)); + }); + + test('same instance is equal', () { + final value = create(() => 42); + expect(value, equals(value)); + }); + + test('different instances are not equal', () { + final valueA = create(() => 42); + final valueB = create(() => 42); + expect(valueA, isNot(equals(valueB))); + expect(valueA.hashCode, isNot(equals(valueB.hashCode))); + }); + }); +}