diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..051dc49 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +/.* export-ignore +/docs export-ignore +/test export-ignore +/CHANGELOG.md export-ignore +/README.md export-ignore diff --git a/.github/workflows/Quickstart Symfony.yaml b/.github/workflows/Quickstart Symfony.yaml new file mode 100644 index 0000000..57c9459 --- /dev/null +++ b/.github/workflows/Quickstart Symfony.yaml @@ -0,0 +1,101 @@ +name: Symfony quickstart + +on: + push: + pull_request: + workflow_dispatch: + schedule: + - cron: 0 6 * * * + +defaults: + run: + working-directory: Symfony quickstart + +jobs: + Symfony-quickstart: + runs-on: ubuntu-latest + + env: + php: 8.2 + + steps: + - uses: actions/checkout@v3 + + - name: Setup PHP ${{ env.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.php }} + + - name: Create working directory + run: mkdir --verbose "$GITHUB_WORKFLOW" + working-directory: + + - name: Create Symfony project + run: composer create-project symfony/skeleton . ^7 + + - name: Require IANA + run: composer require --with-dependencies provider/iana + + - name: Add Porter services + run: | + cat <<'.' >>config/services.yaml + ScriptFUSION\Porter\Porter: + arguments: + - '@providers' + + providers: + class: Symfony\Component\DependencyInjection\ServiceLocator + arguments: + - + - '@ScriptFUSION\Porter\Provider\Iana\Provider\IanaProvider' + + ScriptFUSION\Porter\Provider\Iana\Provider\IanaProvider: + . + + - name: Add AppListAction + run: | + cat <<'.' | >src/Controller/AppListAction.php sed 's/ *//' + import(new Import(new IanaPortNumbers())) as $port) { + if ($port['Port Number'] === '') { + continue; + } + + echo "{$port['Port Number']}:{$port['Transport Protocol']}\n"; + } + }, + headers: ['content-type' => 'text/plain'], + ); + } + } + . + + - name: Start web server + run: sudo php -S localhost:80 public/index.php & + + - name: Download home page + run: curl localhost | tee out + + - name: Test output contains over 13k lines + run: | + echo Lines: ${lines=$(wc --lines 13000)) diff --git a/.github/workflows/Quickstart.yaml b/.github/workflows/Quickstart.yaml new file mode 100644 index 0000000..19dc621 --- /dev/null +++ b/.github/workflows/Quickstart.yaml @@ -0,0 +1,77 @@ +name: Quickstart + +on: + push: + pull_request: + workflow_dispatch: + schedule: + - cron: 0 6 * * * + +defaults: + run: + working-directory: Quickstart + +jobs: + Quickstart: + runs-on: ubuntu-latest + + env: + php: 8.1 + + steps: + - uses: actions/checkout@v3 + + - name: Setup PHP ${{ env.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.php }} + + - name: Create working directory + run: mkdir --verbose "$GITHUB_WORKFLOW" + working-directory: + + - name: Initialize Composer project + run: composer init --name foo/bar + + - name: Configure minimum stability for Amp v3. + run: | + composer config minimum-stability beta + composer config prefer-stable true + + - name: Require ECB + run: composer require provider/european-central-bank + + - name: Require DI library + run: composer require --with-dependencies joomla/di + + - name: Run PHP script + run: | + php <<'.' | sed 's/ *//' | tee out + set(EuropeanCentralBankProvider::class, new EuropeanCentralBankProvider); + + $porter = new Porter($container); + $rates = $porter->import(new Import(new DailyForexRates)); + + foreach ($rates as $rate) { + echo "$rate[currency]: $rate[rate]\n"; + } + . + + - name: Test output contains USD + run: 'grep --perl-regexp ''^USD: [\d.]+$'' out' + + - name: Test output contains between 25-35 lines + run: | + echo Lines: ${lines=$(wc --lines = 25 && lines <= 35)) diff --git a/.github/workflows/Tests.yaml b/.github/workflows/Tests.yaml index 5e13d9b..7871a01 100644 --- a/.github/workflows/Tests.yaml +++ b/.github/workflows/Tests.yaml @@ -15,8 +15,10 @@ jobs: fail-fast: false matrix: php: - - 8.1 - 8.2 + - 8.3 + - 8.4 + - 8.5 dependencies: - hi - lo @@ -28,7 +30,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - coverage: xdebug + coverage: pcov - name: Validate composer.json run: composer validate @@ -57,6 +59,6 @@ jobs: - name: Run mutation tests run: | ln -sfv ../build test - composer mutation -- --min-msi=99 --threads=$(nproc) --show-mutations --coverage=build/coverage + composer mutate -- --min-msi=97 --threads=$(nproc) --show-mutations --coverage=build/coverage env: INFECTION_BADGE_API_KEY: ${{ secrets.INFECTION_BADGE_API_KEY }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 61febce..32e2d8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Porter change log +## 7.0.0 – Unified API + +Unifies the synchronous and asynchronous APIs, so they are one and the same. + +### Breaking changes + +* Removed all async-specific code paths, including `Porter::fetchAsync()` and `Porter::fetchOneAsync()`. +* Renamed `ImportSpecification` -> `Import`. +* Renamed `StaticDataSpecification` -> `StaticImport`. +* Changed `Porter`, `MemoryCache` and all outstanding exceptions' inheritance accessibility to `final`. + +## 6.0.0 – Fibers + +Replaces coroutines with fibers as the new async technology. + +### Breaking changes + +* Removed support for PHP less than 8.1. +* Removed support for Amp v2 in lieu of Amp v3 (beta). +* All methods returning `Amp\Promise` were changed to their underlying type. +* Added union types and `mixed` where appropriate, e.g. `Connector|AsyncConnector`. +* Changed `PorterRecords` semantics to always run generators to the first suspension point. + ## 5.0.0 – Async Porter v5 introduces asynchronous imports and complete strict type safety (excluding union types and generics). diff --git a/README.md b/README.md index 3f506ff..54f2a01 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,21 @@ - Porter ====== -[![Latest version][Version image]][Releases] -[![Total downloads][Downloads image]][Downloads] -[![Build status][Build image]][Build] -[![Mutation score][MSI image]][MSI report] -[![Test coverage][Coverage image]][Coverage] +[![Version image]][Releases] +[![Downloads image]][Downloads] +[![Build image]][Build] +[![Quickstart image]][Quickstart build] +[![Quickstart Symfony image]][Quickstart Symfony build] +[![Coverage image]][Coverage] +[![Mutation score image][MSI image]][MSI report] -### Scalable and durable data imports for publishing and consuming APIs +### Durable and asynchronous data imports for consuming data at scale and publishing testable SDKs -Porter is the all-purpose PHP data importer. She fetches data from anywhere and serves it as a single record or an iterable [record collection](#record-collections), encouraging processing one record at a time instead of loading full data sets into memory at once. Her [durability](#durability) feature provides automatic, transparent recovery from intermittent network connectivity errors by default. +Porter is the all-purpose PHP data importer. She fetches data from APIs, web scraping or anywhere and serves it as an iterable [record collection](#record-collections), encouraging processing one record at a time instead of loading full data sets into memory. [Durability](#durability) features provide automatic, transparent recovery from intermittent network errors by default. -Porter's [interface trichotomy](#overview) of [providers](#providers), [resources](#resources) and [connectors](#connectors) maps well to APIs. For example, a typical API such as GitHub would define the provider as GitHub, a resource as `GetUser` or `ListRepositories` and the connector could be [HttpConnector][]. +Porter's [interface triad](#overview) of [providers](#providers), [resources](#resources) and [connectors](#connectors) allows us to publish testable SDKs and maps well to APIs and HTTP endpoints. For example, a typical API such as GitHub would define the provider as `GitHubProvider`, a resource as `GetUser` or `ListRepositories` and the connector could be [HttpConnector][]. -Porter provides a dual API for synchronous and [asynchronous](#asynchronous) imports, both of which are concurrency safe, so multiple imports can be paused and resumed simultaneously. Asynchronous mode allows large scale imports across multiple connections to work at maximum efficiency without waiting for each network call to complete. +Porter supports [asynchronous](#asynchronous) imports via [fibers][] allowing multiple imports to be started, paused and resumed concurrently. Async allows us to import data as fast as possible, transforming applications from network-bound (slow) to CPU-bound (optimal). [Throttle](#throttling) support ensures we do not exceed peer connection or throughput limits. ###### Porter network quick links @@ -26,32 +27,31 @@ Contents -------- 1. [Benefits](#benefits) - 1. [Quick start](#quick-start) - 1. [About this manual](#about-this-manual) - 1. [Usage](#usage) - 1. [Porter's API](#porters-api) - 1. [Overview](#overview) - 1. [Import specifications](#import-specifications) - 1. [Record collections](#record-collections) - 1. [Asynchronous](#asynchronous) - 1. [Transformers](#transformers) - 1. [Filtering](#filtering) - 1. [Durability](#durability) - 1. [Caching](#caching) - 1. [Architecture](#architecture) - 1. [Providers](#providers) - 1. [Resources](#resources) - 1. [Connectors](#connectors) - 1. [Requirements](#requirements) - 1. [Limitations](#limitations) - 1. [Testing](#testing) - 1. [Contributing](#contributing) - 1. [License](#license) + 2. [Quick start](#quick-start) + 3. [About this manual](#about-this-manual) + 4. [Usage](#usage) + 5. [Porter's API](#porters-api) + 6. [Overview](#overview) + 7. [Import specifications](#import-specifications) + 8. [Record collections](#record-collections) + 9. [Asynchronous](#asynchronous) + 10. [Transformers](#transformers) + 11. [Filtering](#filtering) + 12. [Durability](#durability) + 13. [Caching](#caching) + 14. [Architecture](#architecture) + 15. [Providers](#providers) + 16. [Resources](#resources) + 17. [Connectors](#connectors) + 18. [Limitations](#limitations) + 19. [Testing](#testing) + 20. [Contributing](#contributing) + 21. [License](#license) Benefits -------- - * Defines an **interface trichotomy** for data imports: [providers](#providers) represent one or more [resources](#resources) that fetch data from [connectors](#connectors). These interfaces make it very easy to **test and mock** specific parts of the import lifecycle using industry standard tools, whether we want to mock at the connector level and feed in raw responses or mock at the resource level and feed in hydrated objects. + * Defines an easily testable **interface triad** for data imports: [providers](#providers) represent one or more [resources](#resources) that fetch data from [connectors](#connectors). These interfaces make it very easy to **test and mock** specific parts of the import lifecycle using industry standard tools, whether we want to mock at the connector level and feed in raw responses or mock at the resource level and supply ready-hydrated objects. * Provides **memory-efficient data processing** interfaces that handle large data sets one record at a time, via iterators, which can be implemented using deferred execution with generators. * [Asynchronous](#asynchronous) imports offer highly efficient **CPU-bound data processing** for large scale imports across multiple connections concurrently, eliminating network latency performance bottlenecks. Concurrency can be **rate-limited** using [throttling](#throttling). * Protects against intermittent network failures with [durability](#durability) features that transparently and **automatically retry failed data fetches**. @@ -62,19 +62,22 @@ Benefits Quick start ----------- -To get started quickly consuming an existing Porter provider, try our [quick start guide][Quickstart]. For a more thorough introduction continue reading. +To get started quickly, consuming an existing Porter provider, try one of our quick start guides: + +* [General quickstart][Quickstart] – Get started using Porter with vanilla PHP (no framework) and the [European Central Bank][ECB provider] provider. +* [Symfony quickstart][] – Get started by integrating Porter into a new Symfony project with the [Steam provider][]. + +For a more thorough introduction continue reading. About this manual ----------------- -Those wishing to consume a Porter provider create one instance of `Porter` for their application and an instance of `ImportSpecification` for each data import they wish to perform. Those publishing providers must implement `Provider` and `ProviderResource`. +Those **consuming** a Porter provider create one instance of `Porter` for their application and an instance of `Import` for each data import they wish to perform. Those **publishing** providers must implement `Provider` and `ProviderResource`. -The first half of this manual covers Porter's main API for consuming data services. The second half covers architecture, interface and implementation details for publishing data services. There's an intermission in-between, so you'll know where the separation is! +The first half of this manual covers Porter's main API for *consuming* data services. The second half covers architecture, interface and implementation details for *publishing* data services. There's an intermission in-between, so you'll know where the separation is! Text marked as `inline code` denotes literal code, as it would appear in a PHP file. For example, `Porter` refers specifically to the class of the same name within this library, whereas *Porter* refers to this project as a whole. -Porter has a dual API for the synchronous and asynchronous workflow dichotomy. Almost every Porter feature has an asynchronous equivalent. You will need to choose which you prefer to use. Most examples in this manual are for the synchronous API, for brevity and simplicity, but the asynchronous version will invariably be similar. Working synchronously may be easier when getting started, but you are encouraged to use the async API if you are able, to reap its benefits. - Usage ----- @@ -82,29 +85,29 @@ Usage Create a `new Porter` instance—we'll usually only need one per application. Porter's constructor requires a [PSR-11][] compatible `ContainerInterface` that acts as a repository of [providers](#providers). -When integrating Porter into a typical MVC framework application, we'll usually have a service locator or DI container implementing this interface already. We can simply inject the entire container into Porter, although it's best practice to create a separate container just for Porter's providers. +When integrating Porter into a typical MVC framework application, we'll usually have a service locator or DI container implementing this interface already. We can simply inject the entire container into Porter, although it's best practice to create a separate container just for Porter's providers. For an example of doing this correctly in Symfony, see the [Symfony quickstart][]. -Without a framework, pick any [PSR-11 compatible library][PSR-11 search] and inject an instance of its container class. We could even write our own container since the interface is easy to implement, but using an existing library is beneficial, particularly since most support lazy-loading of services. If you're not sure which to use, [Joomla DI](https://github.com/joomla-framework/di) is fairly lightweight and straightforward. +Without a framework, pick any [PSR-11 compatible library][PSR-11 search] and inject an instance of its container class. We could even write our own container since the interface is easy to implement, but using an existing library is beneficial, particularly since most support lazy-loading of services. If you're not sure which to use, [Joomla DI](https://github.com/joomla-framework/di) seems fairly simple and light. ### Registering providers Configure the container by registering one or more Porter [providers][Provider]. In this example we'll add the [ECB provider][] for foreign exchange rates. Most provider libraries will export just one provider class; in this case it's `EuropeanCentralBankProvider`. We could add the provider to the container by writing something similar to `$container->set(EuropeanCentralBankProvider::class, new EuropeanCentralBankProvider)`, but consult the manual for your particular container implementation for the exact syntax. -It is recommended to use the provider's class name as the container service name, as in the example in the previous paragraph. Porter will retrieve the service matching the provider's class name by default, so this reduces friction when getting started. If we use a different service name, it will need to be configured later in the `ImportSpecification` by calling `setProviderName()`. +It is recommended to use the provider's class name as the container service name, as in the example in the previous paragraph. Porter will retrieve the service matching the provider's class name by default, so this reduces friction when getting started. If we use a different service name, it will need to be configured on the `Import` by calling `setProviderName()`. ### Importing data -Porter's `import` method accepts an `ImportSpecification` that describes which data should be imported and how the data should be transformed. To import `DailyForexRates` without applying any transformations we can write the following. +Porter's `import` method accepts an `Import` that describes which data should be imported and how the data should be transformed. To import `DailyForexRates` without applying any transformations we can write the following. ```php -$records = $porter->import(new ImportSpecification(new DailyForexRates)); +$records = $porter->import(new Import(new DailyForexRates)); ``` Calling `import()` returns an instance of `PorterRecords` or `CountablePorterRecords`, which both implement `Iterator`, allowing each record in the collection to be enumerated using `foreach` as in the following example. ```php foreach ($records as $record) { - // Insert breakpoint or var_dump($record) here to examine each record. + var_dump($record); } ``` @@ -113,15 +116,10 @@ Porter's API `Porter`'s simple API comprises data import methods that must always be used to begin imports, instead of calling methods directly on providers or resources, in order to take advantage of Porter's features correctly. -`Porter` provides just two public methods for synchronous data import. These are the methods to be most familiar with, where the life of a data import operation begins. +`Porter` provides just two public methods for importing data. These are the methods to be most familiar with, where the life of a data import operation begins. -* `import(ImportSpecification): PorterRecords|CountablePorterRecords` – Imports one or more records from the resource contained in the specified import specification. If the total size of the collection is known, the record collection may implement `Countable`. -* `importOne(ImportSpecification): ?array` – Imports one record from the resource contained in the specified import specification. If more than one record is imported, `ImportException` is thrown. Use this when a provider just returns a single record. - -Porter's asynchronous API mirrors the synchronous one with similar method names but different signatures. - -* `importAsync(AsyncImportSpecification): AsyncPorterRecords|CountableAsyncPorterRecords` – Imports one or more records asynchronously from the resource contained in the specified asynchronous import specification. -* `importOneAsync(AsyncImportSpecification): Promise` – Imports one record from the resource contained in the specified asynchronous import specification. +* `import(Import): PorterRecords|CountablePorterRecords` – Imports one or more records from the resource contained in the specified import specification. If the total size of the collection is known, the record collection may implement `Countable`, otherwise `PorterRecords` is returned. +* `importOne(Import): mixed` – Imports one record from the resource contained in the specified import specification. If more than one record is imported, `ImportException` is thrown. Use this when a provider implements `SingleRecordResource`, returning just a single record. Overview -------- @@ -134,64 +132,78 @@ The following data flow diagram gives a high level overview of Porter's main int -Our application calls `Porter::import()` with an `ImportSpecification` and receives `PorterRecords` back. Everything else happens internally, so we don't need to worry about it unless writing custom providers, resources or connectors. +Our application calls `Porter::import()` with an `Import` and receives `PorterRecords` back. Everything else happens internally, so we don't need to worry about it unless writing custom providers, resources or connectors. Import specifications --------------------- -Import specifications specify *what* to import, *how* it should be [transformed](#transformers) and whether to use [caching](#caching). In synchronous code, create a new instance of `ImportSpecification` and pass a `ProviderResource` that specifies the resource we want to import. In Asynchronous code, create `AsyncImportSpecification` instead. +Import specifications specify *what* to import, *how* it should be [transformed](#transformers) and whether to use [caching](#caching). Create a new instance of `Import` and pass a `ProviderResource` that specifies the resource we want to import. Options may be configured using the methods below. - `setProviderName(string)` – Sets the provider service name. - - `addTransformer(Transformer)` – Adds a transformer to the end of the transformation queue. In async code, pass `AsyncTransformer` instead. + - `addTransformer(Transformer)` – Adds a transformer to the end of the transformation queue. - `addTransformers(Transformer[])` – Adds one or more transformers to the end of the transformation queue. - `setContext(mixed)` – Specifies user-defined data to be passed to transformers. - `enableCache()` – Enables caching. Requires a `CachingConnector`. - `setMaxFetchAttempts(int)` – Sets the maximum number of fetch attempts per connection before failure is considered permanent. - `setFetchExceptionHandler(FetchExceptionHandler)` – Sets the exception handler invoked each time a fetch attempt fails. - - `setThrottle(Throttle)` – Sets the asynchronous connection throttle, invoked each time a connector fetches data. Applies to `AsyncImportSpecification` only. + - `setThrottle(Throttle)` – Sets the connection throttle, invoked each time a connector fetches data. Record collections ------------------ -Record collections are `Iterator`s, guaranteeing imported data is enumerable using `foreach`. Each *record* of the collection is the familiar and flexible `array` type, allowing us to present structured or flat data, such as JSON, XML or CSV, as an array. +Record collections are `Iterator`s, guaranteeing imported data is enumerable using `foreach`. Each *record* of the collection is the `mixed` type, offering the flexibility to present data series in whatever format is most useful for the user, such as an array for JSON data or an object that bundles data with functionality that the user can directly invoke. ### Details Record collections may be `Countable`, depending on whether the imported data was countable and whether any destructive operations were performed after import. Filtering is a destructive operation since it may remove records and therefore the count reported by a `ProviderResource` would no longer be accurate. It is the responsibility of the resource to supply the total number of records in its collection by returning an iterator that implements `Countable`, such as `ArrayIterator`, or more commonly, `CountableProviderRecords`. When a countable iterator is used, Porter returns `CountablePorterRecords`, provided no destructive operations were performed. -Record collections are composed by Porter using the decorator pattern. If provider data is not modified, `PorterRecords` will decorate the `ProviderRecords` returned from a `ProviderResource`. That is, `PorterRecords` has a pointer back to the previous collection, which could be written as: `PorterRecords` → `ProviderRecords`. If a [filter](#filtering) was applied, the collection stack would be `PorterRecords` → `FilteredRecords` → `ProviderRecords`. Normally this is an unimportant detail but can sometimes be useful for debugging. +Record collections are composed by Porter using the decorator pattern. If provider data is not modified, `PorterRecords` will decorate the `ProviderRecords` returned from a `ProviderResource`. That is, `PorterRecords` has a pointer back to the previous collection, which could be written as: `PorterRecords` → `ProviderRecords`. If a [filter](#filtering) was applied, the collection stack would be `PorterRecords` → `FilteredRecords` → `ProviderRecords`. Normally, this is an unimportant detail but can sometimes be useful for debugging. -The stack of record collection types informs us of the transformations a collection has undergone and each type holds a pointer to relevant objects that participated in the transformation. For example, `PorterRecords` holds a reference to the `ImportSpecification` that was used to create it and can be accessed using `PorterRecords::getSpecification`. +The stack of record collection types informs us of the transformations a collection has undergone and each type holds a pointer to relevant objects that participated in the transformation. For example, `PorterRecords` holds a reference to the `Import` that was used to create it and can be accessed using `PorterRecords::getImport`. ### Metadata Since record collections are just objects, it is possible to define derived types that implement custom fields to expose additional *metadata* in addition to the iterated data. Collections are very good at representing a repeating series of data but some APIs send additional non-repeating data which we can expose as metadata. However, if the data is not repeating at all, it should be treated as a single record rather than metadata. -The result of a successful `Porter::import` call is always an instance of `PorterRecords` or `CountablePorterRecords`, depending on whether the number of records is known. If we need to access methods of the original collection, returned by the provider, we can call `findFirstCollection()` on the collection. For an example, see [CurrencyRecords][] of the [European Central Bank Provider][ECB] and its associated [test case][ECB test]. +The result of a successful `Porter::import` call is always an instance of `PorterRecords` or `CountablePorterRecords`, depending on whether the number of records is known. If we need to access methods of the original collection, returned by the provider, we can call `findFirstCollection()` on the collection. For an example, see [CurrencyRecords][] of the [European Central Bank Provider][ECB provider] and its associated [test case][ECB test]. Asynchronous ------------ -The asynchronous API, introduced in version 5, is built on top of the fully programmable asynchronous framework, [Amp][]. The synchronous API is not compatible with the asynchronous API so one must decide which to use. In general, the asynchronous API should be preferred for new projects because async can do everything sync can do, including emulating synchronous behaviour, but sync code cannot behave asynchronously without significant refactoring. +Porter has had asynchronous support since version 5 (2019) thanks to [Amp][] integration. In v5, async was implemented with coroutines, but from version 6 onwards, Porter uses the simpler [fibers][] model. Fiber support is included in PHP 8.1 and can be added to PHP 8.0 using [ext-fiber][]. PHP 7 does not support fibers, so if you are stuck with that version of PHP, coroutines are the only option. It is strongly recommended to upgrade to PHP 8.1 to use async, to avoid unnecessary bugs leading to segfaults and to avoid getting trapped in the coroutine architecture that is cumbersome to upgrade, difficult to debug and harder to reason about. -We must be inside the async event loop to begin programming asynchronously. Let's illustrate how to rewrite the [earlier example](#importing-data) asynchronously. +In version 5, Porter offered a dual API to support the asynchronous code path. That is, `Porter::import` had the async analogue: `Porter::importAsync` and `Porter::importOne` had `Porter::importOneAsync`. In version 6 we switched to fibers but kept the dual API to making migrating from coroutines to fibers slightly easier. Since version 7, we unified the dual API because async with fibers can be almost entirely transparent: the synchronous and asynchronous code paths are identical, so we don't even have to think about async unless and until we want to start leveraging its benefits in our application. + +To use async in Porter v7 onwards, simply wrap an `import()` or `importOne()` call in an `async()` call using one of the following two methods. ```php -\Amp\Loop::run(function (): \Generator { - $records = $porter->importAsync(new AsyncImportSpecification(new DailyForexRates)); +use function Amp\async; - while (yield $records->advance()) { - $record = $records->current(); - // Insert breakpoint or var_dump($record) here to examine each record. - } -}); +async( + $this->porter->import(...), + new Import(new MyResource()) +); + +// -OR- + +async(fn () => $this->porter->import(new Import(new MyResource())); ``` -We would not usually code directly inside the event loop in a real application, however we always need to create the event loop somewhere, even if it just calls a service method in our application which delegates to other objects. To pass asynchronous data through layers of abstraction, our application's methods must return `Promise`s that wrap the data they would normally return directly in a synchronous application. For example, a method returning `string` would instead return `Promise`, that is, a promise that returns a string. +In order for this to work, the only requirement is that the underlying [connector][Porter connectors] supports fibers. To know whether a particular connector supports fibers, consult its documentation. The most common connector, [HttpConnector][], already has fiber support. + +Calling `async()` returns a `Future` representing the eventual result of an asynchronous operation. To understand how futures are composed and abstracted, or how to await and iterate collections of futures, is beyond the scope of this document. Full details about async programming can be found in the official [Amp documentation][]. + +>Note: At the time of writing, Amp v3 is still in beta, so you may find it necessary to lower a project's minimum stability to include Amp packages, via `composer.json`: +> ```json +> "minimum-stability": "beta" +> ```` +> To avoid pulling in any betas other than those absolutely necessary for the dependency solver to be satisfied, it is recommended to also set stable packages as the preferred stability when using the above setting. +> ```json +> "prefer-stable": true +> ``` -Programming asynchronously requires an understanding of Amp, the async framework. Further details can be found in the official [Amp documentation][]. ### Throttling @@ -204,7 +216,7 @@ Synchronously, we seldom trip protection measures even for high volume imports, A `DualThrottle` can be assigned by modifying the import specification as follows. ```php -(new AsyncImportSpecification)->setThrottle(new DualThrottle) +(new Import)->setThrottle(new DualThrottle) ``` #### ThrottledConnector @@ -216,7 +228,7 @@ Implementing `ThrottledConnector` is likely to be preferable when we want many r Transformers ------------ -Transformers manipulate imported data. Transforming data is useful because third-party data seldom arrives in a format that looks exactly as we want. Transformers are added to the transformation queue of an `ImportSpecification` by calling its `addTransformer` method and are executed in the order they are added. +Transformers manipulate imported data. Transforming data is useful because third-party data seldom arrives in a format that looks exactly as we want. Transformers are added to the transformation queue of an `Import` by calling its `addTransformer` method and are executed in the order they are added. Porter includes one transformer, `FilterTransformer`, that removes records from the collection based on a predicate. For more information, see [filtering](#filtering). More powerful data transformations can be designed with [MappingTransformer][]. More transformers may be available from [Porter transformers][]. @@ -232,12 +244,12 @@ public function transformAsync(AsyncRecordCollection $records, mixed $context): When `transform()` or `transformAsync()` is called the transformer may iterate each record and change it in any way, including removing or inserting additional records. The record collection must be returned by the method, whether or not changes were made. -Transformers should also implement the `__clone` magic method if they store any object state, in order to facilitate deep copy when Porter clones the owning `ImportSpecification` during import. +Transformers should also implement the `__clone` magic method if they store any object state, in order to facilitate deep copy when Porter clones the owning `Import` during import. Filtering --------- -Filtering provides a way to remove some records. For each record, if the specified predicate function returns `false` (or a falsy value), the record will be removed, otherwise the record will be kept. The predicate receives the current record as an array as its first parameter and context as its second parameter. +Filtering provides a way to remove some records. For each record, if the specified predicate function returns `false` (or a falsy value), the record will be removed, otherwise the record will be kept. The predicate receives the current record as its first parameter and context as its second parameter. In general, we would like to avoid filtering because it is inefficient to import data and then immediately remove some of it, but some immature APIs do not provide a way to reduce the data set on the server, so filtering on the client is the only alternative. Filtering also invalidates the record count reported by some resources, meaning we no longer know how many records are in the collection before iteration. @@ -247,7 +259,7 @@ The following example filters out any records that do not have an *id* field pre ```php $records = $porter->import( - (new ImportSpecification(new MyResource)) + (new Import(new MyResource)) ->addTransformer( new FilterTransformer(static function (array $record) { return array_key_exists('id', $record); @@ -259,7 +271,7 @@ $records = $porter->import( Durability ---------- -Porter automatically retries connections when an exception occurs during `Connector::fetch`. This helps mitigate intermittent network conditions that cause temporary data fetch failures. The number of retry attempts can be configured by calling the `setMaxFetchAttempts` method of an [`ImportSpecification`](#import-specifications). +Porter automatically retries connections when an exception occurs during `Connector::fetch`. This helps mitigate intermittent network conditions that cause temporary data fetch failures. The number of retry attempts can be configured by calling the `setMaxFetchAttempts` method of an [`Import`](#import-specifications). The default exception handler, `ExponentialSleepFetchExceptionHandler`, causes a failed fetch to pause the entire program for a series of increasing delays, doubling each time. Given that the default number of retry attempts is *five*, the exception handler may be called up to *four* times, delaying each retry attempt for ~0.1, ~0.2, ~0.4, and finally, ~0.8 seconds. After the fifth and final failure, `FailingTooHardException` is thrown. @@ -269,16 +281,14 @@ The exception handler can be changed by calling `setFetchExceptionHandler`. For $specification->setFetchExceptionHandler(new ExponentialSleepFetchExceptionHandler(1000000)); ``` -Durability only applies when connectors throw a recoverable exception type derived from `RecoverableConnectorException`. If an unexpected exception occurs the fetch attempt will be aborted. For more information, see [implementing connector durability](#durability-1). Exception handlers receive the thrown exception as their first argument. An exception handler can inspect the recoverable exception and throw its own exception if it decides the exception should be treated as fatal instead of recoverable. +Durability only applies when connectors throw a recoverable exception type derived from `RecoverableConnectorException`. If an unexpected exception occurs, the fetch attempt will be aborted. For more information, see [implementing connector durability](#durability-1). Exception handlers receive the thrown exception as their first argument. An exception handler can inspect the recoverable exception and throw its own exception if it decides the exception should be treated as fatal instead of recoverable. Caching ------- Any connector can be wrapped in a `CachingConnector` to provide [PSR-6][] caching facilities to the base connector. Porter ships with one cache implementation, `MemoryCache`, which caches fetched data in memory, but this can be substituted for any other PSR-6 cache implementation. The `CachingConnector` caches raw responses for each unique request, where uniqueness is determined by `DataSource::computeHash`. -Remember that whilst using a `CachingConnector` enables caching, caching must also be enabled on a per-import basis by calling `ImportSpecification::enableCache()`. - -Note that Caching is not yet supported for asynchronous imports. +Remember that whilst using a `CachingConnector` enables caching, caching must also be enabled on a per-import basis by calling `Import::enableCache()`. ### Example @@ -286,7 +296,7 @@ The follow example enables connector caching. ```php $records = $porter->import( - (new ImportSpecification(new MyResource)) + (new Import(new MyResource)) ->enableCache() ); ``` @@ -352,7 +362,7 @@ final class MyProvider implements Provider Resources --------- -Resources fetch data using the supplied connector and format it as a collection of arrays. A resource implements `ProviderResource` that defines the following three methods. +Resources fetch data using the supplied connector and format it as an iterator. A resource implements `ProviderResource` that defines the following three methods. ```php public function getProviderClassName(): string; @@ -361,7 +371,7 @@ public function fetch(ImportConnector $connector): \Iterator; A resource supplies the class name of the provider it expects a connector from when `getProviderClassName()` is called. -When `fetch()` is called it is passed the connector from which data must be fetched. The resource must ensure data is formatted as an iterator of array values whilst remaining as true to the original format as possible; that is, we must avoid renaming or restructuring data because it is the caller's prerogative to perform data customization if desired. The recommended way to return an iterator is to use `yield` to implicitly return a `Generator`, which has the added benefit of processing one record at a time. +When `fetch()` is called it is passed the connector from which data must be fetched. The resource must ensure data is formatted as an iterator of values whilst remaining as true to the original format as possible; that is, we must avoid renaming or restructuring data because it is the caller's prerogative to perform data customization if desired. The recommended way to return an iterator is to use `yield` to implicitly return a `Generator`, which has the added benefit of processing one record at a time. The fetch method receives an `ImportConnector`, which is a runtime wrapper for the underlying connector supplied by the provider. This wrapper is used to isolate the connector's state from the rest of the application. Since PHP doesn't have native immutability support, working with cloned state is the only way we can guarantee unexpected changes do not occur once an import has started. This means it's safe to import one resource, make changes to the connector's settings and then start another import before the first has completed. Providers can also safely make changes to the underlying connector by calling `getWrappedConnector()`, because the wrapped connector is cloned as soon as `ImportConnector` is constructed. @@ -369,25 +379,14 @@ Providing immutability via cloning is an important concept because resources are ### Writing a resource -Resources must implement the `ProviderResource` interface. `getProviderClassName()` usually returns a hard-coded provider class name and `fetch()` must always return an iterator of array values. - -In this contrived example that uses dummy data and ignores the connector, suppose we want to return the numeric series one to three: the following implementation would be invalid because it returns an iterator of integer values instead of an iterator of array values. - -```php -public function fetch(ImportConnector $connector): \Iterator -{ - return new ArrayIterator(range(1, 3)); // Invalid return type. -} -``` +Resources must implement the `ProviderResource` interface. `getProviderClassName()` usually returns a hard-coded provider class name and `fetch()` must always return an iterator. Either of the following `fetch()` implementations would be valid. ```php public function fetch(ImportConnector $connector): \Iterator { - foreach (range(1, 3) as $number) { - yield [$number]; - } + return new ArrayIterator(range(1, 3)); // Invalid return type. } ``` @@ -396,13 +395,7 @@ Since the total number of records is known, the iterator can be wrapped in `Coun ```php public function fetch(ImportConnector $connector): \Iterator { - $series = function ($limit) { - foreach (range(1, $limit) as $number) { - yield [$number]; - } - }; - - return new CountableProviderRecords($series($count = 3), $count, $this); + return new CountableProviderRecords(new ArrayIterator(range(1, $count = 3)), $count, $this); } ``` @@ -429,7 +422,7 @@ class MyResource implements ProviderResource, SingleRecordResource } ``` -If the data represents a repeating series, yield each record separately instead, as in the following example and remove the `SingleRecordResource` marker interface. +If the data represents a repeating series, `yield` each record separately instead, as in the following example, and remove the `SingleRecordResource` marker interface. ```php public function fetch(ImportConnector $connector): \Iterator @@ -479,21 +472,12 @@ It is important to define a canonical order for hashed inputs such that identica To support Porter's durability features a connector may throw a subclass of `RecoverableConnectorException` to signal that the fetch operation can be retried. Execution will halt as normal if any other exception type is thrown. It is recommended to throw a recoverable exception type when the fetch operation is idempotent. -Requirements ------------- - - - [PHP 7.1](http://php.net/) - - [Composer](https://getcomposer.org/) - Limitations ----------- Current limitations that may affect some users and should be addressed in the near future. - No end-to-end data steaming interface. - - Caching does not support asynchronous imports. - - [Sub-imports][] do not support async. - - No import rate throttle for synchronous imports. Testing ------- @@ -513,6 +497,13 @@ License Porter is published under the open source GNU Lesser General Public License v3.0. However, the original Porter character and artwork is copyright © 2022 [Bilge](https://github.com/Bilge) and may not be reproduced or modified without express written permission. +Support +------- + +Porter is supported by [JetBrains for Open Source][] products. + +[![][JetBrains logo]][JetBrains for Open Source] + ###### Quick links [![][Porter icon]][Provider] @@ -526,14 +517,19 @@ Porter is published under the open source GNU Lesser General Public License v3.0 [Downloads image]: https://poser.pugx.org/scriptfusion/porter/downloads "Total downloads" [Build]: https://github.com/ScriptFUSION/Porter/actions/workflows/Tests.yaml [Build image]: https://github.com/ScriptFUSION/Porter/actions/workflows/Tests.yaml/badge.svg "Build status" - [MSI image]: https://badge.stryker-mutator.io/github.com/ScriptFUSION/Porter/master - [MSI report]: https://dashboard.stryker-mutator.io/github.com/ScriptFUSION/Porter/master + [Quickstart build]: https://github.com/ScriptFUSION/Porter/actions/workflows/Quickstart.yaml + [Quickstart image]: https://github.com/ScriptFUSION/Porter/actions/workflows/Quickstart.yaml/badge.svg "Quick start build status" + [Quickstart Symfony build]: https://github.com/ScriptFUSION/Porter/actions/workflows/Quickstart%20Symfony.yaml + [Quickstart Symfony image]: https://github.com/ScriptFUSION/Porter/actions/workflows/Quickstart%20Symfony.yaml/badge.svg "Symfony quick start build status" + [MSI report]: https://dashboard.stryker-mutator.io/reports/github.com/ScriptFUSION/Porter/master + [MSI image]: https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2FScriptFUSION%2FPorter%2Fmaster "Mutation score" [Coverage]: https://codecov.io/gh/ScriptFUSION/Porter [Coverage image]: https://codecov.io/gh/ScriptFUSION/Porter/branch/master/graphs/badge.svg "Test coverage" [Issues]: https://github.com/ScriptFUSION/Porter/issues [PRs]: https://github.com/ScriptFUSION/Porter/pulls [Quickstart]: https://github.com/ScriptFUSION/Porter/tree/master/docs/Quickstart.md + [Symfony quickstart]: https://github.com/ScriptFUSION/Porter/tree/master/docs/Quickstart%20Symfony.md [Provider]: https://github.com/provider [Porter transformers]: https://github.com/Porter-transformers [Porter connectors]: https://github.com/Porter-connectors @@ -550,11 +546,14 @@ Porter is published under the open source GNU Lesser General Public License v3.0 [Porter icon]: https://avatars3.githubusercontent.com/u/16755913?v=3&s=35 "Porter providers" [Porter transformers icon]: https://avatars2.githubusercontent.com/u/24607042?v=3&s=35 "Porter transformers" [Porter connectors icon]: https://avatars3.githubusercontent.com/u/25672142?v=3&s=35 "Porter connectors" - [Class diagram]: https://github.com/ScriptFUSION/Porter/blob/master/docs/images/diagrams/Porter%20UML%20class%20diagram%205.0.png?raw=true - [Data flow diagram]: https://github.com/ScriptFUSION/Porter/blob/master/docs/images/diagrams/Porter%20data%20flow%20diagram%205.0.png?raw=true - [ECB]: https://github.com/Provider/European-Central-Bank + [Class diagram]: https://github.com/ScriptFUSION/Porter/blob/master/docs/images/diagrams/Porter%20UML%20class%20diagram%207.0.png?raw=true + [Data flow diagram]: https://github.com/ScriptFUSION/Porter/blob/master/docs/images/diagrams/Porter%20data%20flow%20diagram%208.0.webp?raw=true [CurrencyRecords]: https://github.com/Provider/European-Central-Bank/blob/master/src/Records/CurrencyRecords.php [ECB test]: https://github.com/Provider/European-Central-Bank/blob/master/test/DailyForexRatesTest.php [Amp]: https://amphp.org - [Amp documentation]: https://amphp.org/amp/ + [Amp documentation]: https://v3.amphp.org/amp [Async Throttle]: https://github.com/ScriptFUSION/Async-Throttle + [JetBrains for Open Source]: https://jb.gg/OpenSource + [JetBrains logo]: https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.svg "JetBrains logo" + [Fibers]: https://www.php.net/manual/en/language.fibers.php + [ext-fiber]: https://github.com/amphp/ext-fiber diff --git a/composer.json b/composer.json index abacb2e..c0238b8 100644 --- a/composer.json +++ b/composer.json @@ -9,19 +9,17 @@ ], "license": "LGPL-3.0", "require": { - "php": "^8.1", - "amphp/amp": "^3-beta.9", + "php": "^8.2", "async/throttle": "^4", - "psr/cache": "^1", - "psr/container": "^1", - "revolt/event-loop": "^0.2", - "scriptfusion/retry": "^4", + "psr/cache": "^1|^2|^3", + "psr/container": "^1|^2", + "scriptfusion/retry": "^5", "scriptfusion/retry-exception-handlers": "^1.2", "scriptfusion/static-class": "^1" }, "require-dev": { - "infection/infection": ">=0.26,<0.27", - "mockery/mockery": "^1.4.2", + "infection/infection": ">=0.32,<0.33", + "mockery/mockery": "^1.5", "phpunit/phpunit": "^9.5.23" }, "suggest" : { @@ -40,7 +38,7 @@ }, "scripts": { "test": "phpunit -c test", - "mutation": "infection --configuration=test/infection.json" + "mutate": "infection --configuration=test/infection.json" }, "config": { "sort-packages": true, diff --git a/docs/Quickstart Symfony.md b/docs/Quickstart Symfony.md new file mode 100644 index 0000000..ed9c278 --- /dev/null +++ b/docs/Quickstart Symfony.md @@ -0,0 +1,211 @@ +Porter Quick Start Guide for Symfony +==================================== + +This quick start guide will walk through integrating Porter into a new Symfony project from scratch and assumes we already have a PHP 8.1 environment set up with Composer. This guide is based on a real-world use-case, used in production on [Steam 250][]. If you want to integrate Porter into an existing Symfony project, skip all steps except modifying `services.yaml`. + +This steps in this guide are automatically verified by a nightly [CI build][] to ensure their correctness. However, if you encounter any errors or get stuck, don't hesitate to file an issue. + +Let's start by creating a new Symfony 5 project in an empty directory using the following command. Ensure the current working directory is set to the empty project directory. + +```sh +composer create-project symfony/skeleton . ^5 +``` + +Let's start with the [Steam provider][] for Porter by including it in our `composer.json` with the following command. + +>Note: The Steam provider requires [Amp v3][], which is currently in beta, so we need to allow beta dependencies temporarily. This can be enabled with the following commands. +> ```sh +> composer config minimum-stability beta +> composer config prefer-stable true +> ``` + +```sh +composer require --with-dependencies provider/steam +``` + +>Note: We specify the *with dependencies* flag because some shared dependencies between the provider and our current Symfony project may have mismatched versions, so they must be allowed to up/downgrade as necessary to work together. + +Now the provider is installed along with all its dependencies, including Amp and Porter herself. + +In this simple exercise, we will use the [Steam provider][] to display a list of all the app IDs for every app available on [Steam][]. We're going to start coding now, so let's fire up our favourite editor. Start by creating a new `AppListAction` in our existing `src/Controller` directory. + +>Note: We're following the [ADR][] pattern rather than MVC, so we should rename the *Controller* directory to *Action*, too, but to keep things simple we will refrain for now. + +Actions only handle a single route, using the `__invoke` method, so let's add that, too. + +```php + print 'Hello, Porter!', ++ ); + } +``` + +Start a web server to [view the home page](http://localhost). + +```sh +php -S localhost:80 public/index.php +``` + +We should see *Hello, Porter!* displayed in our browser. If you see the default Symfony *welcome* page, ensure `doctrine/annotations` was installed correctly. + +To gain access to the Steam data we need to inject Porter into our action. + +```diff ++use ScriptFUSION\Porter\Porter; + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + +- public function __invoke(): Response ++ public function __invoke(Porter $porter): Response +``` + +Refreshing our browser now will display an error because we haven't told Symfony how to configure Porter as a service yet: + +>`RuntimeException` Cannot autowire argument $porter of "App\Controller\AppListAction()": it references class "ScriptFUSION\Porter\Porter" but no such service exists. + +Let's append the Porter service to `config/services.yaml`. Porter requires a container of providers, so let's inject the *providers* service into Porter. + +```yaml + ScriptFUSION\Porter\Porter: + arguments: + - '@providers' +``` + +Of course, this *providers* service does not exist yet, but we can create it by [defining a service locator][]. We only have one provider at this time, the `SteamProvider`, so let's ensure it's added to the locator so Porter can use it. + +```yaml + providers: + class: Symfony\Component\DependencyInjection\ServiceLocator + arguments: + - + - '@ScriptFUSION\Porter\Provider\Steam\SteamProvider' +``` + +>Note: We do not use the `!service_locator` shortcut to implicitly create the service locator due to a [Symfony bug](https://github.com/symfony/symfony/issues/48454) that misnames services added in this way. + +Finally, since `SteamProvider` is third-party code, Symfony requires us to explicitly register it as a service, but we don't need to customize it in any way, so we can just specify its configuration as the tilde (`~`) to use the defaults. + +```yaml + ScriptFUSION\Porter\Provider\Steam\SteamProvider: ~ +``` + +>Note: The tilde is optional and can be omitted. + +Refreshing our browser now should recompile the Symfony DI container and show us the same message as before (without errors). Porter is now injected into our action, ready to use! + +Let's replace the previous `StreamedResponse` closure with a new implementation that uses Porter to import data from the `GetAppList` resource (a resource belonging to `SteamProvider`). + +```diff ++use ScriptFUSION\Porter\Import\Import; + use ScriptFUSION\Porter\Porter; ++use ScriptFUSION\Porter\Provider\Steam\Resource\GetAppList; + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + +- fn () => print 'Hello, Porter!', ++ function () use ($porter): void { ++ foreach ($porter->import(new Import(new GetAppList())) as $app) { ++ echo "$app[appid]\n"; ++ } ++ }, +``` + +We iterate over each result from `GetAppList` and emit it using `echo`, appending a new line to each. Viewing the results in our browser shows us lots of numbers (the ID of each Steam app) but it does not respect the new line character (`\n`) because it renders as HTML by default. Let's fix that by specifying the correct mime type. Below is the complete code, including the new `content-type` header: + +```php +import(new Import(new GetAppList())) as $app) { + echo "$app[appid]\n"; + } + }, + headers: ['content-type' => 'text/plain'], + ); + } +} +``` + +This should output a long list of numbers, one on each line, in the browser. For example: + +>2170321 +1825161 +1897482 +2112761 +1829051 +... + +We now have a Porter service defined that can be injected into as many services or actions as we wish. We can add as many [providers] to the `providers` service locator as we want, without any performance impact, since each service is lazy-loaded when required. + +This just scratches the surface of Porter without going into any details. Explore the [rest of the manual][Readme] to gain a fuller understanding of the features at your disposal. For another worked example, check out the framework-less [quickstart guide][], too. + +⮪ [Back to main Readme][Readme] + + + [Readme]: ../README.md#quick-start + [Steam provider]: https://github.com/Provider/Steam + [Steam 250]: https://steam250.com + [Steam]: https://store.steampowered.com + [ADR]: https://github.com/pmjones/adr + [Amp v3]: https://v3.amphp.org + [Defining a Service Locator]: https://symfony.com/doc/current/service_container/service_subscribers_locators.html#defining-a-service-locator + [Providers]: https://github.com/provider + [CI build]: https://github.com/ScriptFUSION/Porter/actions/workflows/Quickstart%20Symfony.yaml + [Quickstart guide]: Quickstart.md diff --git a/docs/Quickstart.md b/docs/Quickstart.md index 530847a..5a53dd8 100644 --- a/docs/Quickstart.md +++ b/docs/Quickstart.md @@ -1,18 +1,32 @@ Porter Quick Start Guide ======================== -This quick start guide will walk through getting up and running with Porter from scratch and assumes you already have a PHP environment set up with Composer. Let's start by initializing our Composer file by running `composer init` in our project's root directory and accepting the defaults. We can skip defining dependencies interactively because we'll issue separate commands in a moment. +This quick start guide will walk through getting up and running with Porter from scratch, without a framework, and assumes we already have a PHP environment set up with Composer. If you use Symfony, check out the [Symfony quickstart guide][], too. -Let's start with the [European Central Bank][ECB provider] (ECB) provider by including it in our `composer.json` with the following command. +This steps in this guide are automatically verified by a nightly [CI build][] to ensure their correctness. However, if you encounter any errors or get stuck, don't hesitate to file an issue. + +Let's start by initializing our Composer file by running the following command in our project's root directory and accepting the defaults. We can skip defining dependencies interactively because we'll issue separate commands in a moment. + +```sh +composer init +``` + +For this demo we'll use the [European Central Bank][ECB provider] (ECB) provider by including it in our `composer.json` with the following command. + +>Note: The ECB provider requires [Amp v3][], which is currently in beta, so we need to allow beta dependencies temporarily. This can be enabled with the following commands. +> ```sh +> composer config minimum-stability beta +> composer config prefer-stable true +> ``` ```sh composer require provider/european-central-bank ``` -We now have the provider installed along with all its dependencies, including Porter herself. We want to create a `new Porter` instance now, but we need to pass a `ContainerInterface` to her constructor. Any PSR-11 container is valid, but let's use Joomla DI for now. +We now have the provider installed along with all its dependencies, including Porter herself. We want to create a `new Porter` instance now, but we need to pass a `ContainerInterface` to her constructor. [Any PSR-11 container][PSR-11 search] is valid, but let's use [Joomla DI][] for now. ```sh -composer require joomla/di +composer require --with-dependencies joomla/di ``` Create a new container and register an instance of `EuropeanCentralBankProvider` with it. Pass the container to a new Porter instance. Don't forget to include the autoloader! @@ -30,13 +44,16 @@ $container->set(EuropeanCentralBankProvider::class, new EuropeanCentralBankProvi $porter = new Porter($container); ``` -We're now ready to import any of the ECB's resources. Let's import the latest daily foreign exchange rates provided by `DailyForexRates`. Porter's `import()` method requires an `ImportSpecification` that accepts the resource we want to import. +We're now ready to import any of the ECB's resources. Let's import the latest daily foreign exchange rates provided by `DailyForexRates`. Porter's `import()` method requires a `Import` that accepts the resource we want to import. ```php -$rates = $porter->import(new ImportSpecification(new DailyForexRates)); +use ScriptFUSION\Porter\Import\Import; +use ScriptFUSION\Porter\Provider\EuropeanCentralBank\Provider\Resource\DailyForexRates; + +$rates = $porter->import(new Import(new DailyForexRates)); ``` -Porter returns an iterator so we can now loop over the rates and print them out. +Porter returns an iterator, so we can now loop over the rates and print them out. ```php foreach ($rates as $rate) { @@ -55,9 +72,15 @@ DKK: 7.4469 Since these rates come from the European Central Bank, they're relative to the Euro (EUR), which is assumed to always be *1*. We can use this information to write a currency converter that's always up-to-date with the latest exchange rate information. -This just scratches the surface of Porter without going into any details. Explore the [rest of the manual][Readme] at your leisure to gain a fuller understanding of the features at your disposal. +This just scratches the surface of Porter without going into any details. Explore the [rest of the manual][Readme] to gain a fuller understanding of the features at your disposal. + +⮪ [Back to main Readme][Readme] -⮪ [Back to main readme][Readme] - [Readme]: https://github.com/ScriptFUSION/Porter/blob/master/README.md + [Readme]: ../README.md#quick-start [ECB provider]: https://github.com/Provider/European-Central-Bank + [CI build]: https://github.com/ScriptFUSION/Porter/actions/workflows/Quickstart.yaml + [PSR-11 search]: https://packagist.org/explore/?type=library&tags=psr-11 + [Joomla DI]: https://github.com/joomla-framework/di + [Symfony quickstart guide]: Quickstart%20Symfony.md + [Amp v3]: https://v3.amphp.org diff --git a/docs/images/diagrams/Porter UML class diagram 7.0.png b/docs/images/diagrams/Porter UML class diagram 7.0.png new file mode 100644 index 0000000..2d791c1 Binary files /dev/null and b/docs/images/diagrams/Porter UML class diagram 7.0.png differ diff --git a/docs/images/diagrams/Porter data flow diagram 7.0.png b/docs/images/diagrams/Porter data flow diagram 7.0.png new file mode 100644 index 0000000..75841ec Binary files /dev/null and b/docs/images/diagrams/Porter data flow diagram 7.0.png differ diff --git a/docs/images/diagrams/Porter data flow diagram 8.0.webp b/docs/images/diagrams/Porter data flow diagram 8.0.webp new file mode 100644 index 0000000..33e46c9 Binary files /dev/null and b/docs/images/diagrams/Porter data flow diagram 8.0.webp differ diff --git a/src/Cache/CacheItem.php b/src/Cache/CacheItem.php index 93b8fcd..b6b3da0 100644 --- a/src/Cache/CacheItem.php +++ b/src/Cache/CacheItem.php @@ -24,7 +24,7 @@ public function get(): mixed return $this->value; } - public function set(mixed $value): self + public function set(mixed $value): static { $this->value = $value; @@ -36,12 +36,12 @@ public function isHit(): bool return $this->hit; } - public function expiresAt($expiration): self + public function expiresAt($expiration): static { throw new NotImplementedException; } - public function expiresAfter($time): self + public function expiresAfter($time): static { throw new NotImplementedException; } diff --git a/src/Cache/InvalidCacheKeyException.php b/src/Cache/InvalidCacheKeyException.php index e5ee540..1e94c2d 100644 --- a/src/Cache/InvalidCacheKeyException.php +++ b/src/Cache/InvalidCacheKeyException.php @@ -3,7 +3,7 @@ namespace ScriptFUSION\Porter\Cache; -class InvalidCacheKeyException extends \RuntimeException implements \Psr\Cache\InvalidArgumentException +final class InvalidCacheKeyException extends \RuntimeException implements \Psr\Cache\InvalidArgumentException { // Intentionally empty. } diff --git a/src/Cache/MemoryCache.php b/src/Cache/MemoryCache.php index b933893..9352410 100644 --- a/src/Cache/MemoryCache.php +++ b/src/Cache/MemoryCache.php @@ -9,12 +9,12 @@ /** * Provides an in-memory cache with a PSR-6 interface. */ -class MemoryCache extends \ArrayObject implements CacheItemPoolInterface +final class MemoryCache extends \ArrayObject implements CacheItemPoolInterface { /** * @param string $key */ - public function getItem($key): mixed + public function getItem($key): CacheItemInterface { return \Closure::bind( function () use ($key): self { @@ -37,17 +37,21 @@ public function hasItem($key): bool return isset($this[$key]); } - public function clear(): void + public function clear(): bool { $this->exchangeArray([]); + + return true; } - public function deleteItem($key): void + public function deleteItem($key): bool { unset($this[$key]); + + return true; } - public function deleteItems(array $keys): void + public function deleteItems(array $keys): bool { foreach ($keys as $key) { if (!$this->hasItem($key)) { @@ -56,16 +60,22 @@ public function deleteItems(array $keys): void $this->deleteItem($key); } + + return true; } - public function save(CacheItemInterface $item): void + public function save(CacheItemInterface $item): bool { $this[$item->getKey()] = $item->get(); + + return true; } - public function saveDeferred(CacheItemInterface $item): void + public function saveDeferred(CacheItemInterface $item): bool { $this->save($item); + + return true; } public function commit(): bool diff --git a/src/Collection/AsyncFilteredRecords.php b/src/Collection/AsyncFilteredRecords.php deleted file mode 100644 index 76d748b..0000000 --- a/src/Collection/AsyncFilteredRecords.php +++ /dev/null @@ -1,21 +0,0 @@ -filter = $filter; - } - - public function getFilter(): callable - { - return $this->filter; - } -} diff --git a/src/Collection/AsyncPorterRecords.php b/src/Collection/AsyncPorterRecords.php deleted file mode 100644 index 56194ca..0000000 --- a/src/Collection/AsyncPorterRecords.php +++ /dev/null @@ -1,23 +0,0 @@ -specification = $specification; - } - - public function getSpecification(): AsyncImportSpecification - { - return $this->specification; - } -} diff --git a/src/Collection/AsyncProviderRecords.php b/src/Collection/AsyncProviderRecords.php deleted file mode 100644 index 35492ee..0000000 --- a/src/Collection/AsyncProviderRecords.php +++ /dev/null @@ -1,19 +0,0 @@ -resource; - } -} diff --git a/src/Collection/AsyncRecordCollection.php b/src/Collection/AsyncRecordCollection.php deleted file mode 100644 index c3a6dd9..0000000 --- a/src/Collection/AsyncRecordCollection.php +++ /dev/null @@ -1,9 +0,0 @@ -setCount($count); - } -} diff --git a/src/Collection/CountableAsyncProviderRecords.php b/src/Collection/CountableAsyncProviderRecords.php deleted file mode 100644 index ccd63b1..0000000 --- a/src/Collection/CountableAsyncProviderRecords.php +++ /dev/null @@ -1,18 +0,0 @@ -setCount($count); - } -} diff --git a/src/Collection/CountablePorterRecords.php b/src/Collection/CountablePorterRecords.php index 001b0db..998bd9f 100644 --- a/src/Collection/CountablePorterRecords.php +++ b/src/Collection/CountablePorterRecords.php @@ -3,15 +3,15 @@ namespace ScriptFUSION\Porter\Collection; -use ScriptFUSION\Porter\Specification\ImportSpecification; +use ScriptFUSION\Porter\Import\Import; class CountablePorterRecords extends PorterRecords implements \Countable { use CountableRecordsTrait; - public function __construct(RecordCollection $records, int $count, ImportSpecification $specification) + public function __construct(RecordCollection $records, int $count, Import $import) { - parent::__construct($records, $specification); + parent::__construct($records, $import); $this->setCount($count); } diff --git a/src/Collection/PorterRecords.php b/src/Collection/PorterRecords.php index 90ab649..c51318e 100644 --- a/src/Collection/PorterRecords.php +++ b/src/Collection/PorterRecords.php @@ -3,21 +3,20 @@ namespace ScriptFUSION\Porter\Collection; -use ScriptFUSION\Porter\Specification\ImportSpecification; +use ScriptFUSION\Porter\Import\Import; class PorterRecords extends RecordCollection { - private ImportSpecification $specification; - - public function __construct(RecordCollection $records, ImportSpecification $specification) + public function __construct(RecordCollection $records, private readonly Import $import) { parent::__construct($records, $records); - $this->specification = $specification; + // Force generators to run to the first suspension point. + $records->valid(); } - public function getSpecification(): ImportSpecification + public function getImport(): Import { - return $this->specification; + return $this->import; } } diff --git a/src/Collection/RecordCollection.php b/src/Collection/RecordCollection.php index 109f6d0..71093f4 100644 --- a/src/Collection/RecordCollection.php +++ b/src/Collection/RecordCollection.php @@ -12,8 +12,7 @@ public function __construct(private readonly \Iterator $records, private readonl { } - // TODO: Consider throwing our own exception type for clarity, instead of relying on PHP's TypeError. - public function current(): array + public function current(): mixed { return $this->records->current(); } @@ -43,7 +42,7 @@ public function getPreviousCollection(): ?self return $this->previousCollection; } - public function findFirstCollection(): ?self + public function findFirstCollection(): self { do { $previous = $nextPrevious ?? $this->getPreviousCollection(); diff --git a/src/Connector/AsyncConnector.php b/src/Connector/AsyncConnector.php deleted file mode 100644 index 4c66abd..0000000 --- a/src/Connector/AsyncConnector.php +++ /dev/null @@ -1,19 +0,0 @@ -cache = $cache ?: new MemoryCache; } diff --git a/src/Connector/ConnectorWrapper.php b/src/Connector/ConnectorWrapper.php index 4a9e712..82bdc3f 100644 --- a/src/Connector/ConnectorWrapper.php +++ b/src/Connector/ConnectorWrapper.php @@ -11,5 +11,5 @@ interface ConnectorWrapper /** * Gets the wrapped connector. */ - public function getWrappedConnector(): Connector|AsyncConnector; + public function getWrappedConnector(): Connector; } diff --git a/src/Connector/ImportConnector.php b/src/Connector/ImportConnector.php index edd345f..5317b96 100644 --- a/src/Connector/ImportConnector.php +++ b/src/Connector/ImportConnector.php @@ -8,7 +8,6 @@ use ScriptFUSION\Porter\Connector\Recoverable\RecoverableException; use ScriptFUSION\Porter\Connector\Recoverable\RecoverableExceptionHandler; use ScriptFUSION\Porter\Connector\Recoverable\StatelessRecoverableExceptionHandler; -use ScriptFUSION\Porter\Provider\AsyncProvider; use ScriptFUSION\Porter\Provider\Provider; use function ScriptFUSION\Retry\retry; @@ -22,7 +21,7 @@ */ final class ImportConnector implements ConnectorWrapper { - private Connector|AsyncConnector $connector; + private Connector $connector; /** * User-defined exception handler called when a recoverable exception is thrown by Connector::fetch(). @@ -35,21 +34,20 @@ final class ImportConnector implements ConnectorWrapper private ?RecoverableExceptionHandler $resourceExceptionHandler = null; /** - * @param Provider|AsyncProvider $provider Provider. - * @param Connector|AsyncConnector $connector Wrapped connector. + * @param Provider $provider Provider. + * @param Connector $connector Wrapped connector. * @param RecoverableExceptionHandler $recoverableExceptionHandler User's recoverable exception handler. * @param int $maxFetchAttempts Maximum fetch attempts. * @param bool $mustCache True if the response must be cached, otherwise false. - * @param Throttle|null $throttle Connection throttle invoked each time the connector fetches data. May be null - * for synchronous imports only. + * @param Throttle $throttle Connection throttle invoked each time the connector fetches data. */ public function __construct( - private readonly Provider|AsyncProvider $provider, - Connector|AsyncConnector $connector, + private readonly Provider $provider, + Connector $connector, RecoverableExceptionHandler $recoverableExceptionHandler, private readonly int $maxFetchAttempts, bool $mustCache, - private readonly ?Throttle $throttle + private readonly Throttle $throttle ) { if ($mustCache && !$connector instanceof CachingConnector) { throw CacheUnavailableException::createUnsupported(); @@ -75,25 +73,7 @@ public function fetch(DataSource $source): mixed { return retry( $this->maxFetchAttempts, - function () use ($source) { - return $this->connector->fetch($source); - }, - $this->createExceptionHandler() - ); - } - - /** - * Fetches data asynchronously from the specified data source. - * - * @param AsyncDataSource $source Data source. - * - * @return mixed Data. - */ - public function fetchAsync(AsyncDataSource $source): mixed - { - return retry( - $this->maxFetchAttempts, - fn () => $this->throttle->watch($this->connector->fetchAsync(...), $source), + fn () => $this->throttle->async($this->connector->fetch(...), $source)->await(), $this->createExceptionHandler() ); } @@ -142,7 +122,7 @@ private static function invokeHandler( /** * Gets the provider owning the resource being imported. */ - public function getProvider(): Provider|AsyncProvider + public function getProvider(): Provider { return $this->provider; } @@ -150,7 +130,7 @@ public function getProvider(): Provider|AsyncProvider /** * Gets the wrapped connector. */ - public function getWrappedConnector(): Connector|AsyncConnector + public function getWrappedConnector(): Connector { return $this->connector; } @@ -158,7 +138,7 @@ public function getWrappedConnector(): Connector|AsyncConnector /** * Finds the base connector by traversing the stack of wrapped connectors. */ - public function findBaseConnector(): Connector|AsyncConnector + public function findBaseConnector(): Connector { $connector = $this->connector; diff --git a/src/Connector/ImportConnectorFactory.php b/src/Connector/ImportConnectorFactory.php index 1df17f2..b032b98 100644 --- a/src/Connector/ImportConnectorFactory.php +++ b/src/Connector/ImportConnectorFactory.php @@ -4,10 +4,8 @@ namespace ScriptFUSION\Porter\Connector; use ScriptFUSION\Async\Throttle\NullThrottle; -use ScriptFUSION\Porter\Provider\AsyncProvider; use ScriptFUSION\Porter\Provider\Provider; -use ScriptFUSION\Porter\Specification\AsyncImportSpecification; -use ScriptFUSION\Porter\Specification\Specification; +use ScriptFUSION\Porter\Import\Import; use ScriptFUSION\StaticClass; /** @@ -18,24 +16,22 @@ final class ImportConnectorFactory use StaticClass; public static function create( - Provider|AsyncProvider $provider, - Connector|AsyncConnector $connector, - Specification $specification + Provider $provider, + Connector $connector, + Import $import ): ImportConnector { - if ($specification instanceof AsyncImportSpecification) { - $throttle = $specification->getThrottle(); + $throttle = $import->getThrottle(); - if ($throttle instanceof NullThrottle && $connector instanceof ThrottledConnector) { - $throttle = $connector->getThrottle(); - } + if ($throttle instanceof NullThrottle && $connector instanceof ThrottledConnector) { + $throttle = $connector->getThrottle(); } return new ImportConnector( $provider, $connector, - $specification->getRecoverableExceptionHandler(), - $specification->getMaxFetchAttempts(), - $specification->mustCache(), + $import->getRecoverableExceptionHandler(), + $import->getMaxFetchAttempts(), + $import->mustCache(), $throttle ?? null ); } diff --git a/src/Connector/ThrottledConnector.php b/src/Connector/ThrottledConnector.php index 197626c..f1572de 100644 --- a/src/Connector/ThrottledConnector.php +++ b/src/Connector/ThrottledConnector.php @@ -7,8 +7,6 @@ /** * Specifies a connector that is rate-limited by a connection throttle. - * - * Currently only supported for async connectors. */ interface ThrottledConnector { diff --git a/src/Specification/DuplicateTransformerException.php b/src/Import/DuplicateTransformerException.php similarity index 59% rename from src/Specification/DuplicateTransformerException.php rename to src/Import/DuplicateTransformerException.php index 114dd66..4d69f8f 100644 --- a/src/Specification/DuplicateTransformerException.php +++ b/src/Import/DuplicateTransformerException.php @@ -1,12 +1,12 @@ clearTransformers(); } public function __clone() { + $this->resource = clone $this->resource; + $transformers = $this->transformers; $this->clearTransformers()->addTransformers(array_map( - static function (AnysyncTransformer $transformer): AnysyncTransformer { - return clone $transformer; - }, + static fn ($transformer) => clone $transformer, $transformers )); \is_object($this->context) && $this->context = clone $this->context; + isset($this->recoverableExceptionHandler) && $this->recoverableExceptionHandler = clone $this->recoverableExceptionHandler; + + // Throttle is not cloned because it most likely wants to be shared between imports. + } + + /** + * Gets the resource to import. + * + * @return ProviderResource Resource. + */ + final public function getResource(): ProviderResource + { + return $this->resource; } /** @@ -70,7 +93,7 @@ final public function setProviderName(?string $providerName): self /** * Gets the ordered list of transformers. * - * @return AnysyncTransformer[] + * @return Transformer[] */ final public function getTransformers(): array { @@ -78,19 +101,19 @@ final public function getTransformers(): array } /** - * Adds the specified transformer of any sync type. + * Adds the specified transformer to the end of the transformers queue. * - * @param AnysyncTransformer $transformer Transformer. + * @param Transformer $transformer Transformer. * * @return $this */ - final protected function addAnyTransformer(AnysyncTransformer $transformer): self + final public function addTransformer(Transformer $transformer): self { if ($this->hasTransformer($transformer)) { throw new DuplicateTransformerException('Transformer already added.'); } - $this->transformers[spl_object_hash($transformer)] = $transformer; + $this->transformers[spl_object_id($transformer)] = $transformer; return $this; } @@ -98,14 +121,14 @@ final protected function addAnyTransformer(AnysyncTransformer $transformer): sel /** * Adds one or more transformers. * - * @param AnysyncTransformer[] $transformers Transformers. + * @param Transformer[] $transformers Transformers. * * @return $this */ final public function addTransformers(array $transformers): self { foreach ($transformers as $transformer) { - $this->addAnyTransformer($transformer); + $this->addTransformer($transformer); } return $this; @@ -123,9 +146,9 @@ final public function clearTransformers(): self return $this; } - private function hasTransformer(AnysyncTransformer $transformer): bool + private function hasTransformer(Transformer $transformer): bool { - return isset($this->transformers[spl_object_hash($transformer)]); + return isset($this->transformers[spl_object_id($transformer)]); } /** @@ -226,7 +249,7 @@ final public function setMaxFetchAttempts(int $attempts): self final public function getRecoverableExceptionHandler(): RecoverableExceptionHandler { return $this->recoverableExceptionHandler ?? - $this->recoverableExceptionHandler = static::createDefaultRecoverableExceptionHandler(); + $this->recoverableExceptionHandler = new ExponentialAsyncDelayRecoverableExceptionHandler(); } /** @@ -243,5 +266,27 @@ final public function setRecoverableExceptionHandler(RecoverableExceptionHandler return $this; } - abstract protected static function createDefaultRecoverableExceptionHandler(): RecoverableExceptionHandler; + /** + * Gets the connection throttle, invoked each time a connector fetches data. + * + * @return Throttle Connection throttle. + */ + final public function getThrottle(): Throttle + { + return $this->throttle ??= new NullThrottle; + } + + /** + * Sets the connection throttle, invoked each time a connector fetches data. + * + * @param Throttle $throttle Connection throttle. + * + * @return $this + */ + final public function setThrottle(Throttle $throttle): self + { + $this->throttle = $throttle; + + return $this; + } } diff --git a/src/Specification/StaticDataImportSpecification.php b/src/Import/StaticImport.php similarity index 66% rename from src/Specification/StaticDataImportSpecification.php rename to src/Import/StaticImport.php index 150b9cc..35bb9c8 100644 --- a/src/Specification/StaticDataImportSpecification.php +++ b/src/Import/StaticImport.php @@ -1,11 +1,11 @@ providers = $providers; } /** * Imports one or more records from the resource contained in the specified import specification. * - * @param ImportSpecification $specification Import specification. + * @param Import $import Import specification. * * @return PorterRecords|CountablePorterRecords Collection of records. If the total size of the collection is known, * the collection may implement Countable, otherwise PorterRecords is returned. @@ -61,32 +48,32 @@ public function __construct(ContainerInterface $providers) * @throws IncompatibleResourceException Resource emits a single record and must be imported with * importOne() instead. */ - public function import(ImportSpecification $specification): PorterRecords|CountablePorterRecords + public function import(Import $import): PorterRecords|CountablePorterRecords { - if ($specification->getResource() instanceof SingleRecordResource) { + if ($import->getResource() instanceof SingleRecordResource) { throw IncompatibleResourceException::createMustNotImplementInterface(); } - return $this->fetch($specification); + return $this->fetch($import); } /** * Imports one record from the resource contained in the specified import specification. * - * @param ImportSpecification $specification Import specification. + * @param Import $import Import specification. * - * @return array|null Record. + * @return mixed Record. * * @throws IncompatibleResourceException Resource does not implement required interface. * @throws ImportException More than one record was imported. */ - public function importOne(ImportSpecification $specification): ?array + public function importOne(Import $import): mixed { - if (!$specification->getResource() instanceof SingleRecordResource) { + if (!$import->getResource() instanceof SingleRecordResource) { throw IncompatibleResourceException::createMustImplementInterface(); } - $results = $this->fetch($specification); + $results = $this->fetch($import); if (!$results->valid()) { return null; @@ -101,15 +88,11 @@ public function importOne(ImportSpecification $specification): ?array return $one; } - private function fetch(ImportSpecification $specification): PorterRecords + private function fetch(Import $import): PorterRecords { - $specification = clone $specification; - $resource = $specification->getResource(); - $provider = $this->getProvider($specification->getProviderName() ?? $resource->getProviderClassName()); - - if (!$provider instanceof Provider) { - throw new IncompatibleProviderException('Provider'); - } + $import = clone $import; + $resource = $import->getResource(); + $provider = $this->getProvider($import->getProviderName() ?? $resource->getProviderClassName()); if ($resource->getProviderClassName() !== \get_class($provider)) { throw new ForeignResourceException(sprintf( @@ -119,108 +102,20 @@ private function fetch(ImportSpecification $specification): PorterRecords } $records = $resource->fetch( - ImportConnectorFactory::create($provider, $provider->getConnector(), $specification) + ImportConnectorFactory::create($provider, $provider->getConnector(), $import) ); if (!$records instanceof ProviderRecords) { - $records = $this->createProviderRecords($records, $specification->getResource()); + $records = $this->createProviderRecords($records, $import->getResource()); } - $records = $this->transformRecords($records, $specification->getTransformers(), $specification->getContext()); - - return $this->createPorterRecords($records, $specification); - } - - /** - * Imports one or more records asynchronously from the resource contained in the specified asynchronous import - * specification. - * - * @param AsyncImportSpecification $specification Asynchronous import specification. - * - * @return AsyncPorterRecords|CountableAsyncPorterRecords Collection of records. If the total size of the - * collection is known, the collection may implement Countable, otherwise AsyncPorterRecords is returned. - * - * @throws IncompatibleResourceException Resource emits a single record and must be imported with - * importOneAsync() instead. - */ - public function importAsync(AsyncImportSpecification $specification): AsyncPorterRecords|CountableAsyncPorterRecords - { - if ($specification->getAsyncResource() instanceof SingleRecordResource) { - throw IncompatibleResourceException::createMustNotImplementInterfaceAsync(); - } + $records = $this->transformRecords($records, $import->getTransformers(), $import->getContext()); - return $this->fetchAsync($specification); + return $this->createPorterRecords($records, $import); } /** - * Imports one record from the resource contained in the specified asynchronous import specification. - * - * @param AsyncImportSpecification $specification Asynchronous import specification. - * - * @return array|null Record. - * - * @throws IncompatibleResourceException Resource does not implement required interface. - * @throws ImportException More than one record was imported. - */ - public function importOneAsync(AsyncImportSpecification $specification): ?array - { - if (!$specification->getAsyncResource() instanceof SingleRecordResource) { - throw IncompatibleResourceException::createMustImplementInterface(); - } - - $results = $this->fetchAsync($specification); - - if (!$results->valid()) { - return null; - } - - $one = $results->current(); - - if ($results->next() || $results->valid()) { - throw new ImportException('Cannot import one: more than one record imported.'); - } - - return $one; - } - - private function fetchAsync(AsyncImportSpecification $specification): AsyncPorterRecords - { - $specification = clone $specification; - $resource = $specification->getAsyncResource(); - $provider = $this->getProvider($specification->getProviderName() ?? $resource->getProviderClassName()); - - if (!$provider instanceof AsyncProvider) { - throw new IncompatibleProviderException('AsyncProvider'); - } - - if ($resource->getProviderClassName() !== \get_class($provider)) { - throw new ForeignResourceException(sprintf( - 'Cannot fetch data from foreign resource: "%s".', - \get_class($resource) - )); - } - - $records = $resource->fetchAsync( - ImportConnectorFactory::create($provider, $provider->getAsyncConnector(), $specification) - ); - - if (!$records instanceof AsyncProviderRecords) { - $records = new AsyncProviderRecords($records, $specification->getAsyncResource()); - } - - $records = $this->transformRecordsAsync( - $records, - $specification->getTransformers(), - $specification->getContext() - ); - - return $this->createAsyncPorterRecords($records, $specification); - } - - /** - * @param RecordCollection $records * @param Transformer[] $transformers - * @param mixed $context */ private function transformRecords(RecordCollection $records, array $transformers, mixed $context): RecordCollection { @@ -235,27 +130,6 @@ private function transformRecords(RecordCollection $records, array $transformers return $records; } - /** - * @param AsyncRecordCollection $records - * @param AsyncTransformer[] $transformers - * @param mixed $context - */ - private function transformRecordsAsync( - AsyncRecordCollection $records, - array $transformers, - mixed $context - ): AsyncRecordCollection { - foreach ($transformers as $transformer) { - if ($transformer instanceof PorterAware) { - $transformer->setPorter($this); - } - - $records = $transformer->transformAsync($records, $context); - } - - return $records; - } - private function createProviderRecords(\Iterator $records, ProviderResource $resource): ProviderRecords { if ($records instanceof \Countable) { @@ -265,24 +139,13 @@ private function createProviderRecords(\Iterator $records, ProviderResource $res return new ProviderRecords($records, $resource); } - private function createPorterRecords(RecordCollection $records, ImportSpecification $specification): PorterRecords + private function createPorterRecords(RecordCollection $records, Import $import): PorterRecords { if ($records instanceof \Countable) { - return new CountablePorterRecords($records, \count($records), $specification); - } - - return new PorterRecords($records, $specification); - } - - private function createAsyncPorterRecords( - AsyncRecordCollection $records, - AsyncImportSpecification $specification - ): AsyncPorterRecords { - if ($records instanceof \Countable) { - return new CountableAsyncPorterRecords($records, \count($records), $specification); + return new CountablePorterRecords($records, \count($records), $import); } - return new AsyncPorterRecords($records, $specification); + return new PorterRecords($records, $import); } /** @@ -290,11 +153,11 @@ private function createAsyncPorterRecords( * * @param string $name Provider name. * - * @return Provider|AsyncProvider Provider. + * @return Provider Provider. * * @throws ProviderNotFoundException The specified provider was not found. */ - private function getProvider(string $name): Provider|AsyncProvider + private function getProvider(string $name): Provider { if ($this->providers->has($name)) { return $this->providers->get($name); diff --git a/src/Provider/AsyncProvider.php b/src/Provider/AsyncProvider.php deleted file mode 100644 index 0724b35..0000000 --- a/src/Provider/AsyncProvider.php +++ /dev/null @@ -1,17 +0,0 @@ -connector = new NullConnector; } public function getConnector(): Connector diff --git a/src/ProviderNotFoundException.php b/src/ProviderNotFoundException.php index 0304a70..9e33aa3 100644 --- a/src/ProviderNotFoundException.php +++ b/src/ProviderNotFoundException.php @@ -8,7 +8,7 @@ */ final class ProviderNotFoundException extends \RuntimeException { - public function __construct(string $message, \Exception $previous = null) + public function __construct(string $message, ?\Exception $previous = null) { parent::__construct($message, 0, $previous); } diff --git a/src/Specification/AsyncImportSpecification.php b/src/Specification/AsyncImportSpecification.php deleted file mode 100644 index 78d8f49..0000000 --- a/src/Specification/AsyncImportSpecification.php +++ /dev/null @@ -1,81 +0,0 @@ -asyncResource = clone $this->asyncResource; - // Throttle is not cloned because it most likely wants to be shared between imports. - - parent::__clone(); - } - - /** - * Gets the asynchronous resource to import. - * - * @return AsyncResource Asynchronous resource. - */ - final public function getAsyncResource(): AsyncResource - { - return $this->asyncResource; - } - - final public function addTransformer(AsyncTransformer $transformer): self - { - return $this->addAnyTransformer($transformer); - } - - protected static function createDefaultRecoverableExceptionHandler(): RecoverableExceptionHandler - { - return new ExponentialAsyncDelayRecoverableExceptionHandler; - } - - /** - * Gets the asynchronous connection throttle, invoked each time a connector fetches data. - * - * @return Throttle Asynchronous connection throttle. - */ - final public function getThrottle(): Throttle - { - return $this->throttle ?? $this->throttle = new NullThrottle; - } - - /** - * Sets the asynchronous connection throttle, invoked each time a connector fetches data. - * - * @param Throttle $throttle Asynchronous connection throttle. - * - * @return $this - */ - final public function setThrottle(Throttle $throttle): self - { - $this->throttle = $throttle; - - return $this; - } -} diff --git a/src/Specification/ImportSpecification.php b/src/Specification/ImportSpecification.php deleted file mode 100644 index 368f871..0000000 --- a/src/Specification/ImportSpecification.php +++ /dev/null @@ -1,63 +0,0 @@ -resource = $resource; - - parent::__construct(); - } - - public function __clone() - { - $this->resource = clone $this->resource; - - parent::__clone(); - } - - /** - * Gets the resource to import. - * - * @return ProviderResource Resource. - */ - final public function getResource(): ProviderResource - { - return $this->resource; - } - - /** - * Adds the specified transformer to the end of the transformers queue. - * - * @param Transformer $transformer Transformer. - * - * @return $this - */ - final public function addTransformer(Transformer $transformer): self - { - return $this->addAnyTransformer($transformer); - } - - protected static function createDefaultRecoverableExceptionHandler(): RecoverableExceptionHandler - { - return new ExponentialSleepRecoverableExceptionHandler; - } -} diff --git a/src/Transform/AnysyncTransformer.php b/src/Transform/AnysyncTransformer.php deleted file mode 100644 index 71367f9..0000000 --- a/src/Transform/AnysyncTransformer.php +++ /dev/null @@ -1,14 +0,0 @@ -filter), $records, $filter); } - - public function transformAsync(AsyncRecordCollection $records, mixed $context): AsyncRecordCollection - { - return new AsyncFilteredRecords( - (fn () => yield from $this->transform($records, $context))(), - $records, - $this->filter - ); - } } diff --git a/src/Transform/Transformer.php b/src/Transform/Transformer.php index 07dfa34..bc1acfb 100644 --- a/src/Transform/Transformer.php +++ b/src/Transform/Transformer.php @@ -8,7 +8,7 @@ /** * Provides a method to transform imported data. */ -interface Transformer extends AnysyncTransformer +interface Transformer { /** * Transforms the specified record collection, decorated with the specified context data. diff --git a/test/FixtureFactory.php b/test/FixtureFactory.php index 10dfc58..7eaa408 100644 --- a/test/FixtureFactory.php +++ b/test/FixtureFactory.php @@ -7,7 +7,7 @@ use ScriptFUSION\Porter\Connector\ImportConnector; use ScriptFUSION\Porter\Connector\Recoverable\RecoverableExceptionHandler; use ScriptFUSION\Porter\Provider\Provider; -use ScriptFUSION\Porter\Specification\ImportSpecification; +use ScriptFUSION\Porter\Import\Import; use ScriptFUSION\StaticClass; final class FixtureFactory @@ -16,9 +16,9 @@ final class FixtureFactory public static function buildImportConnector( Connector $connector, - RecoverableExceptionHandler $recoverableExceptionHandler = null, - Provider $provider = null, - int $maxFetchAttempts = ImportSpecification::DEFAULT_FETCH_ATTEMPTS, + ?RecoverableExceptionHandler $recoverableExceptionHandler = null, + ?Provider $provider = null, + int $maxFetchAttempts = Import::DEFAULT_FETCH_ATTEMPTS, bool $mustCache = false ): ImportConnector { return new ImportConnector( diff --git a/test/Functional/ThrottlePrecedenceHierarchyTest.php b/test/Functional/ThrottlePrecedenceHierarchyTest.php index ba17668..94db234 100644 --- a/test/Functional/ThrottlePrecedenceHierarchyTest.php +++ b/test/Functional/ThrottlePrecedenceHierarchyTest.php @@ -8,19 +8,18 @@ use Mockery\MockInterface; use PHPUnit\Framework\TestCase; use ScriptFUSION\Async\Throttle\Throttle; -use ScriptFUSION\Porter\Connector\AsyncConnector; -use ScriptFUSION\Porter\Connector\AsyncDataSource; +use ScriptFUSION\Porter\Connector\Connector; +use ScriptFUSION\Porter\Connector\DataSource; use ScriptFUSION\Porter\Connector\ImportConnectorFactory; use ScriptFUSION\Porter\Connector\ThrottledConnector; -use ScriptFUSION\Porter\Provider\AsyncProvider; use ScriptFUSION\Porter\Provider\Provider; -use ScriptFUSION\Porter\Specification\AsyncImportSpecification; +use ScriptFUSION\Porter\Import\Import; use ScriptFUSIONTest\MockFactory; /** - * Tests the throttle hierarchy of precedence. Only applies to async imports. + * Tests the throttle hierarchy of precedence. * - * Specification throttle (preferred) > Connector throttle (default). + * Import throttle (preferred) > Connector throttle (default). * * @see ImportConnectorFactory */ @@ -28,84 +27,84 @@ final class ThrottlePrecedenceHierarchyTest extends TestCase { use MockeryPHPUnitIntegration; - private Throttle|MockInterface $specificationThrottle; + private Throttle|MockInterface $importThrottle; private Throttle|MockInterface $connectorThrottle; - private AsyncImportSpecification $specification; + private Import $import; - private AsyncProvider|Provider|MockInterface $provider; + private Provider|MockInterface $provider; protected function setUp(): void { parent::setUp(); - $this->specificationThrottle = MockFactory::mockThrottle(); + $this->importThrottle = MockFactory::mockThrottle(); $this->connectorThrottle = MockFactory::mockThrottle(); - $this->specification = new AsyncImportSpecification(MockFactory::mockResource( + $this->import = new Import(MockFactory::mockResource( $this->provider = MockFactory::mockProvider() )); } /** - * Tests that when the connector is non-throttling, the specification's throttle is used. + * Tests that when the connector is non-throttling, the import's throttle is used. */ public function testNonThrottledConnector(): void { - $this->specification->setThrottle($this->specificationThrottle); - $this->specificationThrottle->expects('watch')->once()->andReturn(Future::complete()); - $this->connectorThrottle->expects('watch')->never(); + $this->import->setThrottle($this->importThrottle); + $this->importThrottle->expects('async')->once()->andReturn(Future::complete()); + $this->connectorThrottle->expects('async')->never(); $connector = ImportConnectorFactory::create( $this->provider, - $this->provider->getAsyncConnector(), - $this->specification + $this->provider->getConnector(), + $this->import ); - $connector->fetchAsync(\Mockery::mock(AsyncDataSource::class)); + $connector->fetch(\Mockery::mock(DataSource::class)); } /** - * Tests that when the connector is throttled, and the specification's throttle is not set, the connector's + * Tests that when the connector is throttled, and the import's throttle is not set, the connector's * throttle is used. */ public function testThrottledConnector(): void { - $this->specificationThrottle->expects('watch')->never(); - $this->connectorThrottle->expects('watch')->once()->andReturn(Future::complete()); + $this->importThrottle->expects('async')->never(); + $this->connectorThrottle->expects('async')->once()->andReturn(Future::complete()); $connector = ImportConnectorFactory::create( $this->provider, $this->mockThrottledConnector(), - $this->specification + $this->import ); - $connector->fetchAsync(\Mockery::mock(AsyncDataSource::class)); + $connector->fetch(\Mockery::mock(DataSource::class)); } /** - * Tests that when both the connector is throttled and the specification's throttle are set, the specification's + * Tests that when both the connector is throttled and the import's throttle are set, the import's * throttle overrides that of the connector. */ public function testThrottledConnectorOverride(): void { - $this->specification->setThrottle($this->specificationThrottle); - $this->specificationThrottle->expects('watch')->once()->andReturn(Future::complete()); - $this->connectorThrottle->expects('watch')->never(); + $this->import->setThrottle($this->importThrottle); + $this->importThrottle->expects('async')->once()->andReturn(Future::complete()); + $this->connectorThrottle->expects('async')->never(); $connector = ImportConnectorFactory::create( $this->provider, $this->mockThrottledConnector(), - $this->specification + $this->import ); - $connector->fetchAsync(\Mockery::mock(AsyncDataSource::class)); + $connector->fetch(\Mockery::mock(DataSource::class)); } - private function mockThrottledConnector(): AsyncConnector|ThrottledConnector + private function mockThrottledConnector(): Connector|ThrottledConnector { - return \Mockery::mock(AsyncConnector::class, ThrottledConnector::class) + return \Mockery::mock(Connector::class, ThrottledConnector::class) ->shouldReceive('getThrottle') ->andReturn($this->connectorThrottle) ->getMock() diff --git a/test/Integration/Collection/AsyncRecordCollectionTest.php b/test/Integration/Collection/AsyncRecordCollectionTest.php deleted file mode 100644 index 935b70b..0000000 --- a/test/Integration/Collection/AsyncRecordCollectionTest.php +++ /dev/null @@ -1,49 +0,0 @@ -getPreviousCollection()); - } - - /** - * Tests that for each member in a stack of AsyncRecordCollections, the first collection always points to the - * innermost collection. - */ - public function testFindFirstCollection(): void - { - $collection3 = new AsyncFilteredRecords( - $iterator = \Mockery::mock(\Iterator::class), - $collection2 = new AsyncPorterRecords( - $collection1 = - new AsyncProviderRecords($iterator, \Mockery::mock(AsyncResource::class)), - \Mockery::mock(AsyncImportSpecification::class) - ), - [$this, __FUNCTION__] - ); - - self::assertSame($collection1, $collection1->findFirstCollection()); - self::assertSame($collection1, $collection2->findFirstCollection()); - self::assertSame($collection1, $collection3->findFirstCollection()); - } -} diff --git a/test/Integration/Connector/CachingConnectorTest.php b/test/Integration/Connector/CachingConnectorTest.php index 8b0a191..005b2c5 100644 --- a/test/Integration/Connector/CachingConnectorTest.php +++ b/test/Integration/Connector/CachingConnectorTest.php @@ -65,7 +65,7 @@ public function testCacheBypassedForDifferentOptions(): void } /** - * That that when the generated cache key contains non-compliant PSR-6 characters, + * Tests that when the generated cache key contains non-compliant PSR-6 characters, * InvalidCacheKeyException is thrown. */ public function testValidateCacheKey(): void @@ -88,10 +88,26 @@ public function testGetWrappedConnector(): void /** * Tests that cloning the caching connector also clones the wrapped connector. */ - public function testClone(): void + public function testCloneConnector(): void { $clone = clone $this->connector; self::assertNotSame($this->wrappedConnector, $clone->getWrappedConnector()); } + + /** + * Tests that cloning the caching connector does not clone the cache item pool (both instances point to the same + * pool). + */ + public function testCloneCacheItemPool(): void + { + $clone = clone $this->connector; + + $cacheA = \Closure::bind(fn () => $this->cache, $this->connector, $this->connector)(); + $cacheB = \Closure::bind(fn () => $this->cache, $clone, $clone)(); + self::assertSame($cacheA, $cacheB); + + $clone->fetch($this->source); + self::assertSame(1, $cacheA->count(), 'Fetch on clone updates original cache item pool.'); + } } diff --git a/test/Integration/Connector/ImportConnectorTest.php b/test/Integration/Connector/ImportConnectorTest.php index 269ac48..c2d6462 100644 --- a/test/Integration/Connector/ImportConnectorTest.php +++ b/test/Integration/Connector/ImportConnectorTest.php @@ -6,7 +6,6 @@ use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; use Mockery\MockInterface; use PHPUnit\Framework\TestCase; -use ScriptFUSION\Porter\Connector\AsyncDataSource; use ScriptFUSION\Porter\Connector\Connector; use ScriptFUSION\Porter\Connector\DataSource; use ScriptFUSION\Porter\Connector\ImportConnector; @@ -24,14 +23,11 @@ final class ImportConnectorTest extends TestCase private DataSource|MockInterface $source; - private AsyncDataSource|MockInterface $asyncSource; - protected function setUp(): void { parent::setUp(); $this->source = \Mockery::mock(DataSource::class); - $this->asyncSource = \Mockery::mock(AsyncDataSource::class); } /** @@ -104,11 +100,11 @@ public function testStatelessExceptionHandlerNotCloned(): void * Tests that when a user recoverable exception handler throws an exception, the handler's exception can be * captured. */ - public function testAsyncUserRecoverableExceptionHandler(): void + public function testUserRecoverableExceptionHandler(): void { $connector = FixtureFactory::buildImportConnector( \Mockery::mock(Connector::class) - ->shouldReceive('fetchAsync') + ->expects('fetch') ->andThrow(new TestRecoverableException) ->getMock(), new StatelessRecoverableExceptionHandler( @@ -117,9 +113,9 @@ public function testAsyncUserRecoverableExceptionHandler(): void ); try { - $connector->fetchAsync($this->asyncSource); + $connector->fetch($this->source); } catch (\Exception $e) { - self::assertSame($exception, $e); + self::assertSame($exception, $e, $e->getMessage()); } } @@ -127,11 +123,11 @@ public function testAsyncUserRecoverableExceptionHandler(): void * Tests that when a resource recoverable exception handler throws an exception, the handler's exception can be * captured. */ - public function testAsyncResourceRecoverableExceptionHandler(): void + public function testResourceRecoverableExceptionHandler(): void { $connector = FixtureFactory::buildImportConnector( \Mockery::mock(Connector::class) - ->shouldReceive('fetchAsync') + ->expects('fetch') ->andThrow(new TestRecoverableException) ->getMock() ); @@ -141,9 +137,9 @@ public function testAsyncResourceRecoverableExceptionHandler(): void )); try { - $connector->fetchAsync($this->asyncSource); + $connector->fetch($this->source); } catch (\Exception $e) { - self::assertSame($exception, $e); + self::assertSame($exception, $e, $e->getMessage()); } } @@ -151,11 +147,11 @@ public function testAsyncResourceRecoverableExceptionHandler(): void * Tests that when user and resource recoverable exception handlers are both set, both handlers are invoked, * resource handler first and user handler second. */ - public function testAsyncUserAndResourceRecoverableExceptionHandlers(): void + public function testUserAndResourceRecoverableExceptionHandlers(): void { $connector = FixtureFactory::buildImportConnector( \Mockery::mock(Connector::class) - ->shouldReceive('fetchAsync') + ->expects('fetch')->twice() ->andThrow(new TestRecoverableException) ->getMock(), new StatelessRecoverableExceptionHandler(self::createExceptionThrowingClosure($e2 = new \Exception)) @@ -166,22 +162,22 @@ public function testAsyncUserAndResourceRecoverableExceptionHandlers(): void )); try { - $connector->fetchAsync($this->asyncSource); + $connector->fetch($this->source); } catch (\Exception $exception) { - self::assertSame($e1, $exception); + self::assertSame($e1, $exception, $exception->getMessage()); } try { - $connector->fetchAsync($this->asyncSource); + $connector->fetch($this->source); } catch (\Exception $exception) { - self::assertSame($e2, $exception); + self::assertSame($e2, $exception, $exception->getMessage()); } } /** * Creates a closure that only throws an exception on the first invocation. */ - private static function createExceptionThrowingClosure(\Exception $exception = null): \Closure + private static function createExceptionThrowingClosure(?\Exception $exception = null): \Closure { return static function () use ($exception): void { static $invocationCount; diff --git a/test/Integration/Specification/StaticDataImportSpecificationTest.php b/test/Integration/Import/StaticImportTest.php similarity index 50% rename from test/Integration/Specification/StaticDataImportSpecificationTest.php rename to test/Integration/Import/StaticImportTest.php index 89407a0..4acfa86 100644 --- a/test/Integration/Specification/StaticDataImportSpecificationTest.php +++ b/test/Integration/Import/StaticImportTest.php @@ -1,22 +1,22 @@ import(new StaticDataImportSpecification(new \ArrayIterator([$output = ['foo']]))); + ->import(new StaticImport(new \ArrayIterator([$output = ['foo']]))); self::assertSame($output, $records->current()); } diff --git a/test/Integration/PorterAsyncTest.php b/test/Integration/PorterAsyncTest.php deleted file mode 100644 index 6ad2d39..0000000 --- a/test/Integration/PorterAsyncTest.php +++ /dev/null @@ -1,234 +0,0 @@ -specification = new AsyncImportSpecification($this->resource); - $this->singleSpecification = new AsyncImportSpecification($this->singleResource); - } - - /** - * Tests that the full async import path, via connector, resource and provider, fetches a record correctly. - */ - public function testImportAsync(): void - { - $records = $this->porter->importAsync($this->specification); - - $this->specification->setThrottle(new DualThrottle()); - self::assertInstanceOf(AsyncPorterRecords::class, $records); - self::assertNotSame($this->specification, $records->getSpecification(), 'Specification was not cloned.'); - self::assertTrue($records->valid()); - self::assertSame(['foo'], $records->current()); - } - - /** - * Tests that when importing a single record resource, an exception is thrown. - */ - public function testImportSingle(): void - { - $this->expectException(IncompatibleResourceException::class); - $this->expectExceptionMessage('importOneAsync()'); - - $this->porter->importAsync($this->singleSpecification); - } - - /** - * Tests that the full async import path, via connector, resource and provider, fetches one record correctly. - */ - public function testImportOneAsync(): void - { - self::assertSame(['foo'], $this->porter->importOneAsync($this->singleSpecification)); - } - - /** - * Tests that when importing one from a resource not marked with SingleRecordResource, an exception is thrown. - */ - public function testImportOneNonSingleAsync(): void - { - $this->expectException(IncompatibleResourceException::class); - $this->expectExceptionMessage(SingleRecordResource::class); - - $this->porter->importOneAsync(new AsyncImportSpecification(\Mockery::mock(AsyncResource::class))); - } - - /** - * Tests that when the resource is countable, the count is propagated to the outermost collection and the records - * are intact. - */ - public function testImportCountableAsyncRecords(): void - { - $this->resource->shouldReceive('fetchAsync')->andReturn( - new CountableAsyncProviderRecords(new \ArrayIterator([$record = ['foo']]), $count = 123, $this->resource) - ); - - $records = $this->porter->importAsync($this->specification); - - // Innermost collection. - self::assertInstanceOf(\Countable::class, $first = $records->findFirstCollection()); - self::assertCount($count, $first); - - // Outermost collection. - self::assertInstanceOf(CountableAsyncPorterRecords::class, $records); - self::assertCount($count, $records); - - self::assertTrue($records->valid()); - self::assertSame($record, $records->current()); - } - - /** - * Tests that when importOne receives multiple records from a resource, an exception is thrown. - */ - public function testImportOneOfManyAsync(): void - { - $this->singleResource->shouldReceive('fetchAsync')->andReturn(new \ArrayIterator([['foo'], ['bar']])); - - $this->expectException(ImportException::class); - $this->porter->importOneAsync($this->singleSpecification); - } - - /** - * Tests that when importing from a provider that does not implement AsyncProvider, an exception is thrown. - */ - public function testImportIncompatibleProviderAsync(): void - { - $this->registerProvider(\Mockery::mock(Provider::class), $providerName = 'foo'); - - $this->expectException(IncompatibleProviderException::class); - $this->expectExceptionMessageMatches('[\bAsyncProvider\b]'); - $this->porter->importAsync($this->specification->setProviderName($providerName)); - } - - /** - * Tests that when a resource's provider class name does not match the provider an exception is thrown. - */ - public function testImportForeignResourceAsync(): void - { - // Replace existing provider with a different one. - $this->registerProvider(MockFactory::mockProvider(), \get_class($this->provider)); - - $this->expectException(ForeignResourceException::class); - $this->porter->importAsync($this->specification); - } - - /** - * Tests that a stack of async filter transformers are applied correctly. - * The order is deterministic because filters yield immediately. - */ - public function testFilterAsync(): void - { - $this->resource->shouldReceive('fetchAsync')->andReturnUsing( - fn () => yield from array_map(static fn (int $i): array => [$i], range(1, 10)) - ); - - // Filter out even numbers. - $this->specification->addTransformer( - new FilterTransformer(fn (array $record) => $record[0] % 2) - ); - - $importAndExpect = function ($expect): void { - $records = $this->porter->importAsync($this->specification); - - $filtered = array_map(static fn (array $record): int => $record[0], iterator_to_array($records)); - - self::assertSame($expect, $filtered); - }; - - $importAndExpect([1, 3, 5, 7, 9]); - - // Filter out numbers below 6. - $this->specification->addTransformer( - new FilterTransformer(fn (array $record) => $record[0] > 5) - ); - - $importAndExpect([7, 9]); - } - - /** - * Tests that when an AsyncTransformer is PorterAware it receives the Porter instance that invoked it. - */ - public function testPorterAwareAsyncTransformer(): void - { - $this->porter->importAsync( - $this->specification->addTransformer( - \Mockery::mock(implode(',', [AsyncTransformer::class, PorterAware::class])) - ->shouldReceive('setPorter') - ->with($this->porter) - ->once() - ->shouldReceive('transformAsync') - ->andReturn(\Mockery::mock(AsyncRecordCollection::class)) - ->getMock() - ) - ); - } - - /** - * Tests that a working throttle implementation is invoked during fetch operations. - */ - public function testThrottle(): void - { - $this->specification->setThrottle($throttle = new DualThrottle); - $throttle->setMaxConcurrency(1); - - $records = async($this->porter->importAsync(...), $this->specification); - delay(0); - self::assertTrue($throttle->isThrottling()); - - $records->await(); - self::assertFalse($throttle->isThrottling()); - } - - /** - * Tests that a working throttle implementation can be called from multiple fibers queueing excess objects. - */ - public function testThrottleConcurrentFibers(): void - { - $this->specification->setThrottle($throttle = new DualThrottle); - $throttle->setMaxPerSecond(1); - - $import = function (): void { - $records = async($this->porter->importAsync(...), $this->specification)->await(); - delay(0); - - while ($records->valid()) { - $records->next(); - } - }; - - $start = microtime(true); - - Future\await([async($import), async($import), async($import)]); - - self::assertGreaterThan(3, microtime(true) - $start); - } -} diff --git a/test/Integration/PorterSyncTest.php b/test/Integration/PorterSyncTest.php deleted file mode 100644 index c793835..0000000 --- a/test/Integration/PorterSyncTest.php +++ /dev/null @@ -1,397 +0,0 @@ -porter->import($this->specification); - - self::assertInstanceOf(PorterRecords::class, $records); - self::assertNotSame($this->specification, $records->getSpecification(), 'Specification was not cloned.'); - self::assertSame(['foo'], $records->current()); - - /** @var ProviderRecords $previous */ - self::assertInstanceOf(ProviderRecords::class, $previous = $records->getPreviousCollection()); - self::assertNotSame($this->resource, $previous->getResource(), 'Resource was not cloned.'); - } - - /** - * Tests that when the resource is countable, the count is propagated to the outermost collection. - */ - public function testImportCountableRecords(): void - { - $records = $this->porter->import( - new StaticDataImportSpecification(new \ArrayIterator(range(1, $count = 10))) - ); - - // Innermost collection. - self::assertInstanceOf(\Countable::class, $first = $records->findFirstCollection()); - self::assertCount($count, $first); - - // Outermost collection. - self::assertInstanceOf(CountablePorterRecords::class, $records); - self::assertCount($count, $records); - } - - /** - * Tests that when the resource is countable the count is lost when filtering is applied. - */ - public function testImportAndFilterCountableRecords(): void - { - $records = $this->porter->import( - (new StaticDataImportSpecification( - new \ArrayIterator(range(1, 10)) - ))->addTransformer(new FilterTransformer((__METHOD__)(...))) - ); - - // Innermost collection. - self::assertInstanceOf(\Countable::class, $records->findFirstCollection()); - - // Outermost collection. - self::assertNotInstanceOf(\Countable::class, $records); - } - - /** - * Tests that when importing multiple records, records may be rewound when the iterator supports this. - */ - public function testRewind(): void - { - $this->resource->shouldReceive('fetch')->andReturn(new \ArrayIterator([$i1 = ['foo'], $i2 = ['bar']])); - - $records = $this->porter->import($this->specification); - - self::assertTrue($records->valid()); - self::assertCount(2, $records); - self::assertSame($i1, $records->current()); - $records->next(); - self::assertSame($i2, $records->current()); - $records->rewind(); - self::assertSame($i1, $records->current()); - } - - /** - * Tests that when a Transformer is PorterAware it receives the Porter instance that invoked it. - */ - public function testPorterAwareTransformer(): void - { - $this->porter->import( - $this->specification->addTransformer( - \Mockery::mock(implode(',', [Transformer::class, PorterAware::class])) - ->shouldReceive('setPorter') - ->with($this->porter) - ->once() - ->shouldReceive('transform') - ->andReturn(\Mockery::mock(RecordCollection::class)) - ->getMock() - ) - ); - } - - /** - * Tests that when provider name is specified in an import specification its value is used instead of the default - * provider class name of the resource. - */ - public function testImportCustomProviderName(): void - { - $this->registerProvider( - $provider = clone $this->provider, - $providerName = 'foo' - ); - - $records = $this->porter->import( - (new ImportSpecification(MockFactory::mockResource($provider, new \ArrayIterator([$output = ['bar']])))) - ->setProviderName($providerName) - ); - - self::assertSame($output, $records->current()); - } - - /** - * Tests that when a resource does not return an iterator, an exception is thrown. - */ - public function testImportFailure(): void - { - $this->resource->shouldReceive('fetch')->andReturn(null); - - $this->expectException(\TypeError::class); - $this->expectExceptionMessage(\get_class($this->resource)); - $this->porter->import($this->specification); - } - - public function testImportUnregisteredProvider(): void - { - $this->expectException(ProviderNotFoundException::class); - $this->expectExceptionMessage($providerName = 'foo'); - $this->expectExceptionCode(0); - - $this->porter->import($this->specification->setProviderName("\"$providerName\"")); - } - - /** - * Tests that when importing from a provider that does not implement Provider, an exception is thrown. - */ - public function testImportIncompatibleProvider(): void - { - $this->registerProvider(\Mockery::mock(AsyncProvider::class), $providerName = 'foo'); - - $this->expectException(IncompatibleProviderException::class); - $this->expectExceptionMessageMatches('[\bProvider\b]'); - $this->porter->import($this->specification->setProviderName($providerName)); - } - - /** - * Tests that when a resource's provider class name does not match the provider an exception is thrown. - */ - public function testImportForeignResource(): void - { - // Replace existing provider with a different one. - $this->registerProvider(MockFactory::mockProvider(), \get_class($this->provider)); - - $this->expectException(ForeignResourceException::class); - $this->porter->import($this->specification); - } - - /** - * Tests that when importing a single record resource, an exception is thrown. - */ - public function testImportSingle(): void - { - $this->expectException(IncompatibleResourceException::class); - $this->expectExceptionMessage('importOne()'); - - $this->porter->import($this->singleSpecification); - } - - /** - * Tests that when a resource returns ProviderRecords, Porter does not wrap the collection again. - */ - public function testProviderRecordsNotDoubleWrapped(): void - { - $this->resource->shouldReceive('fetch') - ->andReturn($records = new ProviderRecords(new \ArrayIterator([]), $this->resource)); - - $imported = $this->porter->import($this->specification); - - self::assertInstanceOf(PorterRecords::class, $imported); - self::assertSame($records, $imported->getPreviousCollection()); - } - - #endregion - - #region Import one - - public function testImportOne(): void - { - $result = $this->porter->importOne($this->singleSpecification); - - self::assertSame(['foo'], $result); - } - - public function testImportOneOfNone(): void - { - $this->singleResource->shouldReceive('fetch')->andReturn(new \EmptyIterator); - - $result = $this->porter->importOne($this->singleSpecification); - - self::assertNull($result); - } - - public function testImportOneOfMany(): void - { - $this->singleResource->shouldReceive('fetch')->andReturn(new \ArrayIterator([['foo'], ['bar']])); - - $this->expectException(ImportException::class); - $this->porter->importOne($this->singleSpecification); - } - - /** - * Tests that when importing one from a resource not marked with SingleRecordResource, an exception is thrown. - */ - public function testImportOneNonSingle(): void - { - $this->expectException(IncompatibleResourceException::class); - $this->expectExceptionMessage(SingleRecordResource::class); - - $this->porter->importOne(new ImportSpecification(\Mockery::mock(ProviderResource::class))); - } - - #endregion - - #region Durability - - /** - * Tests that when a connector throws the recoverable exception type, the connection attempt is retried once. - */ - public function testOneTry(): void - { - $this->arrangeConnectorException(new TestRecoverableException); - - $this->expectException(FailingTooHardException::class); - $this->expectExceptionMessage('1'); - $this->porter->import($this->specification->setMaxFetchAttempts(1)); - } - - /** - * Tests that when a connector throws an exception type derived from the recoverable exception type, the connection - * is retried. - */ - public function testDerivedRecoverableException(): void - { - $this->arrangeConnectorException(new TestRecoverableException); - - $this->expectException(FailingTooHardException::class); - $this->porter->import($this->specification->setMaxFetchAttempts(1)); - } - - /** - * Tests that when a connector throws the recoverable exception type, the connection can be retried the default - * number of times (more than once). - */ - public function testDefaultTries(): void - { - $this->arrangeConnectorException(new TestRecoverableException); - // Speed up test by circumventing exponential backoff default handler. - $this->specification->setRecoverableExceptionHandler(new TestRecoverableExceptionHandler); - - $this->expectException(FailingTooHardException::class); - $this->expectExceptionMessage((string)ImportSpecification::DEFAULT_FETCH_ATTEMPTS); - $this->porter->import($this->specification); - } - - /** - * Tests that when a connector throws a non-recoverable exception type, the connection is not retried. - */ - public function testUnrecoverableException(): void - { - // Subclass Exception so it's not an ancestor of any other exception. - $this->arrangeConnectorException($exception = \Mockery::mock(\Exception::class)); - - $this->expectException(\get_class($exception)); - $this->porter->import($this->specification); - } - - /** - * Tests that when a custom fetch exception handler is specified and the connector throws a recoverable exception - * type, the handler is called on each retry. - */ - public function testCustomFetchExceptionHandler(): void - { - $this->specification->setRecoverableExceptionHandler( - \Mockery::mock(RecoverableExceptionHandler::class) - ->shouldReceive('initialize') - ->once() - ->shouldReceive('__invoke') - ->times(ImportSpecification::DEFAULT_FETCH_ATTEMPTS - 1) - ->getMock() - ); - - $this->arrangeConnectorException(new TestRecoverableException); - - $this->expectException(FailingTooHardException::class); - $this->porter->import($this->specification); - } - - /** - * Tests that when a provider fetch exception handler is specified and the connector throws a recoverable - * exception, the handler is called before the user handler. - */ - public function testCustomProviderFetchExceptionHandler(): void - { - $this->specification->setRecoverableExceptionHandler( - new StatelessRecoverableExceptionHandler(static function (): void { - throw new \LogicException('This exception must not be thrown!'); - }) - ); - - $this->arrangeConnectorException($connectorException = - new TestRecoverableException('This exception is caught by the provider handler.')); - - $this->resource - ->shouldReceive('fetch') - ->andReturnUsing(static function (ImportConnector $connector) use ($connectorException): \Generator { - $connector->setRecoverableExceptionHandler(new StatelessRecoverableExceptionHandler( - static function (\Exception $exception) use ($connectorException) { - self::assertSame($connectorException, $exception); - - throw new \RuntimeException('This exception is thrown by the provider handler.'); - } - )); - - yield $connector->fetch(\Mockery::mock(DataSource::class)); - }) - ; - - $this->expectException(\RuntimeException::class); - $this->porter->importOne($this->singleSpecification); - } - - #endregion - - public function testFilter(): void - { - $this->resource->shouldReceive('fetch')->andReturnUsing( - static function (): \Generator { - foreach (range(1, 10) as $integer) { - yield [$integer]; - } - } - ); - - $records = $this->porter->import( - $this->specification - ->addTransformer(new FilterTransformer($filter = static function (array $record): int { - return $record[0] % 2; - })) - ); - - self::assertInstanceOf(PorterRecords::class, $records); - self::assertSame([[1], [3], [5], [7], [9]], iterator_to_array($records)); - - /** @var FilteredRecords $previous */ - self::assertInstanceOf(FilteredRecords::class, $previous = $records->getPreviousCollection()); - self::assertNotSame($previous->getFilter(), $filter, 'Filter was not cloned.'); - } - - /** - * Tests that when caching is required but a caching facility is unavailable, an exception is thrown. - */ - public function testCacheUnavailable(): void - { - $this->expectException(CacheUnavailableException::class); - - $this->porter->import($this->specification->enableCache()); - } -} diff --git a/test/Integration/PorterTest.php b/test/Integration/PorterTest.php index 866a2fc..ced998f 100644 --- a/test/Integration/PorterTest.php +++ b/test/Integration/PorterTest.php @@ -7,29 +7,52 @@ use Mockery\MockInterface; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; -use ScriptFUSION\Porter\Connector\AsyncConnector; +use ScriptFUSION\Async\Throttle\DualThrottle; +use ScriptFUSION\Porter\Cache\CacheUnavailableException; +use ScriptFUSION\Porter\Collection\CountablePorterRecords; +use ScriptFUSION\Porter\Collection\FilteredRecords; +use ScriptFUSION\Porter\Collection\PorterRecords; +use ScriptFUSION\Porter\Collection\ProviderRecords; +use ScriptFUSION\Porter\Collection\RecordCollection; use ScriptFUSION\Porter\Connector\Connector; +use ScriptFUSION\Porter\Connector\DataSource; +use ScriptFUSION\Porter\Connector\ImportConnector; +use ScriptFUSION\Porter\Connector\Recoverable\RecoverableExceptionHandler; +use ScriptFUSION\Porter\Connector\Recoverable\StatelessRecoverableExceptionHandler; +use ScriptFUSION\Porter\ForeignResourceException; +use ScriptFUSION\Porter\ImportException; +use ScriptFUSION\Porter\IncompatibleResourceException; use ScriptFUSION\Porter\Porter; -use ScriptFUSION\Porter\Provider\AsyncProvider; +use ScriptFUSION\Porter\PorterAware; use ScriptFUSION\Porter\Provider\Provider; -use ScriptFUSION\Porter\Provider\Resource\AsyncResource; +use ScriptFUSION\Porter\Provider\ProviderFactory; use ScriptFUSION\Porter\Provider\Resource\ProviderResource; -use ScriptFUSION\Porter\Specification\AsyncImportSpecification; -use ScriptFUSION\Porter\Specification\ImportSpecification; +use ScriptFUSION\Porter\Provider\Resource\SingleRecordResource; +use ScriptFUSION\Porter\ProviderNotFoundException; +use ScriptFUSION\Porter\Import\Import; +use ScriptFUSION\Porter\Import\StaticImport; +use ScriptFUSION\Porter\Transform\FilterTransformer; +use ScriptFUSION\Porter\Transform\Transformer; +use ScriptFUSION\Retry\FailingTooHardException; use ScriptFUSIONTest\MockFactory; +use ScriptFUSIONTest\Stubs\TestRecoverableException; +use ScriptFUSIONTest\Stubs\TestRecoverableExceptionHandler; +use function Amp\async; +use function Amp\delay; +use function Amp\Future\await; -abstract class PorterTest extends TestCase +final class PorterTest extends TestCase { use MockeryPHPUnitIntegration; - protected Porter $porter; - protected Provider|AsyncProvider|MockInterface $provider; - protected ProviderResource|AsyncResource|MockInterface $resource; - protected ProviderResource|AsyncResource|MockInterface $singleResource; - protected Connector|AsyncConnector|MockInterface $connector; - protected ImportSpecification|AsyncImportSpecification $specification; - protected ImportSpecification|AsyncImportSpecification $singleSpecification; - protected ContainerInterface|MockInterface $container; + private Porter $porter; + private Provider|MockInterface $provider; + private ProviderResource|MockInterface $resource; + private ProviderResource|MockInterface $singleResource; + private Connector|MockInterface $connector; + private Import $import; + private Import $singleImport; + private ContainerInterface|MockInterface $container; protected function setUp(): void { @@ -40,14 +63,14 @@ protected function setUp(): void $this->registerProvider($this->provider = MockFactory::mockProvider()); $this->connector = $this->provider->getConnector(); $this->resource = MockFactory::mockResource($this->provider); - $this->specification = new ImportSpecification($this->resource); + $this->import = new Import($this->resource); $this->singleResource = MockFactory::mockSingleRecordResource($this->provider); - $this->singleSpecification = new ImportSpecification($this->singleResource); + $this->singleImport = new Import($this->singleResource); } - protected function registerProvider(Provider|AsyncProvider $provider, string $name = null): void + private function registerProvider(Provider $provider, ?string $name = null): void { - $name = $name ?? \get_class($provider); + $name ??= \get_class($provider); $this->container ->shouldReceive('has')->with($name)->andReturn(true) @@ -58,8 +81,431 @@ protected function registerProvider(Provider|AsyncProvider $provider, string $na /** * Arranges for the current connector to throw an exception in the retry callback. */ - protected function arrangeConnectorException(\Exception $exception): void + private function arrangeConnectorException(\Exception $exception): void { $this->connector->shouldReceive('fetch')->andThrow($exception); } + + #region Import + + /** + * Tests that the full import path, via connector, resource and provider, fetches a record correctly. + */ + public function testImport(): void + { + $records = $this->porter->import($this->import); + + self::assertInstanceOf(PorterRecords::class, $records); + self::assertNotSame($this->import, $records->getImport(), 'Import was not cloned.'); + self::assertSame(['foo'], $records->current()); + + /** @var ProviderRecords $previous */ + self::assertInstanceOf(ProviderRecords::class, $previous = $records->getPreviousCollection()); + self::assertNotSame($this->resource, $previous->getResource(), 'Resource was not cloned.'); + } + + /** + * Tests that when the resource is countable, the count is propagated to the outermost collection. + */ + public function testImportCountableRecords(): void + { + $records = $this->porter->import( + new StaticImport(new \ArrayIterator(range(1, $count = 10))) + ); + + // Innermost collection. + self::assertInstanceOf(\Countable::class, $first = $records->findFirstCollection()); + self::assertCount($count, $first); + + // Outermost collection. + self::assertInstanceOf(CountablePorterRecords::class, $records); + self::assertCount($count, $records); + } + + /** + * Tests that when the resource is countable the count is lost when filtering is applied. + */ + public function testImportAndFilterCountableRecords(): void + { + $records = $this->porter->import( + (new StaticImport( + new \ArrayIterator(array_map(fn ($i) => [$i], range(1, 10))) + ))->addTransformer(new FilterTransformer(fn () => true)) + ); + + // Innermost collection. + self::assertInstanceOf(\Countable::class, $records->findFirstCollection()); + + // Outermost collection. + self::assertNotInstanceOf(\Countable::class, $records); + } + + /** + * Tests that when importing multiple records, records may be rewound when the iterator supports this. + */ + public function testRewind(): void + { + $this->resource->shouldReceive('fetch')->andReturn(new \ArrayIterator([$i1 = ['foo'], $i2 = ['bar']])); + + $records = $this->porter->import($this->import); + + self::assertTrue($records->valid()); + self::assertCount(2, $records); + self::assertSame($i1, $records->current()); + $records->next(); + self::assertSame($i2, $records->current()); + $records->rewind(); + self::assertSame($i1, $records->current()); + } + + /** + * Tests that when importing records implemented using deferred execution with generators, the generator runs up + * to the first suspension point instead of being paused at the start. + */ + public function testImportGenerator(): void + { + $this->resource->expects('fetch')->andReturnUsing(function () use (&$init): \Generator { + $init = true; + + yield []; + }); + + $this->porter->import($this->import); + + self::assertTrue($init); + } + + /** + * Tests that when a Transformer is PorterAware it receives the Porter instance that invoked it. + */ + public function testPorterAwareTransformer(): void + { + $this->porter->import( + $this->import->addTransformer( + \Mockery::mock(implode(',', [Transformer::class, PorterAware::class])) + ->shouldReceive('setPorter') + ->with($this->porter) + ->once() + ->shouldReceive('transform') + ->andReturn(\Mockery::spy(RecordCollection::class)) + ->getMock() + ) + ); + } + + /** + * Tests that when provider name is specified in an import specification, its value is used instead of the default + * provider class name of the resource. + */ + public function testImportCustomProviderName(): void + { + $this->registerProvider( + $provider = clone $this->provider, + $providerName = 'foo' + ); + + $records = $this->porter->import( + (new Import(MockFactory::mockResource($provider, new \ArrayIterator([$output = ['bar']])))) + ->setProviderName($providerName) + ); + + self::assertSame($output, $records->current()); + } + + /** + * Tests that when a resource does not return an iterator, an exception is thrown. + */ + public function testImportFailure(): void + { + $this->resource->shouldReceive('fetch')->andReturn(null); + + $this->expectException(\TypeError::class); + $this->expectExceptionMessage(\get_class($this->resource)); + $this->porter->import($this->import); + } + + public function testImportUnregisteredProvider(): void + { + $this->expectException(ProviderNotFoundException::class); + $this->expectExceptionMessage($providerName = 'foo'); + $this->expectExceptionCode(0); + + $this->porter->import($this->import->setProviderName("\"$providerName\"")); + } + + /** + * Tests that when a resource's provider class name does not match the provider an exception is thrown. + */ + public function testImportForeignResource(): void + { + // Replace existing provider with a different one. + $this->registerProvider(MockFactory::mockProvider(), \get_class($this->provider)); + + $this->expectException(ForeignResourceException::class); + $this->porter->import($this->import); + } + + /** + * Tests that when importing a single record resource, an exception is thrown. + */ + public function testImportSingle(): void + { + $this->expectException(IncompatibleResourceException::class); + $this->expectExceptionMessage('importOne()'); + + $this->porter->import($this->singleImport); + } + + /** + * Tests that when a resource returns ProviderRecords, Porter does not wrap the collection again. + */ + public function testProviderRecordsNotDoubleWrapped(): void + { + $this->resource->shouldReceive('fetch') + ->andReturn($records = new ProviderRecords(new \ArrayIterator([]), $this->resource)); + + $imported = $this->porter->import($this->import); + + self::assertInstanceOf(PorterRecords::class, $imported); + self::assertSame($records, $imported->getPreviousCollection()); + } + + #endregion + + #region Import one + + public function testImportOne(): void + { + $result = $this->porter->importOne($this->singleImport); + + self::assertSame(['foo'], $result); + } + + public function testImportOneOfNone(): void + { + $this->singleResource->shouldReceive('fetch')->andReturn(new \EmptyIterator); + + $result = $this->porter->importOne($this->singleImport); + + self::assertNull($result); + } + + public function testImportOneOfMany(): void + { + $this->singleResource->shouldReceive('fetch')->andReturn(new \ArrayIterator([['foo'], ['bar']])); + + $this->expectException(ImportException::class); + $this->porter->importOne($this->singleImport); + } + + /** + * Tests that when importing one from a resource not marked with SingleRecordResource, an exception is thrown. + */ + public function testImportOneNonSingle(): void + { + $this->expectException(IncompatibleResourceException::class); + $this->expectExceptionMessage(SingleRecordResource::class); + + $this->porter->importOne(new Import(\Mockery::mock(ProviderResource::class))); + } + + #endregion + + #region Durability + + /** + * Tests that when a connector throws the recoverable exception type, the connection attempt is retried once. + */ + public function testOneTry(): void + { + $this->arrangeConnectorException(new TestRecoverableException); + + $this->expectException(FailingTooHardException::class); + $this->expectExceptionMessage('1'); + $this->porter->import($this->import->setMaxFetchAttempts(1)); + } + + /** + * Tests that when a connector throws an exception type derived from the recoverable exception type, the connection + * is retried. + */ + public function testDerivedRecoverableException(): void + { + $this->arrangeConnectorException(new TestRecoverableException); + + $this->expectException(FailingTooHardException::class); + $this->porter->import($this->import->setMaxFetchAttempts(1)); + } + + /** + * Tests that when a connector throws the recoverable exception type, the connection can be retried the default + * number of times (more than once). + */ + public function testDefaultTries(): void + { + $this->arrangeConnectorException(new TestRecoverableException); + // Speed up test by circumventing exponential backoff default handler. + $this->import->setRecoverableExceptionHandler(new TestRecoverableExceptionHandler); + + $this->expectException(FailingTooHardException::class); + $this->expectExceptionMessage((string)Import::DEFAULT_FETCH_ATTEMPTS); + $this->porter->import($this->import); + } + + /** + * Tests that when a connector throws a non-recoverable exception type, the connection is not retried. + */ + public function testUnrecoverableException(): void + { + // Subclass Exception so it's not an ancestor of any other exception. + $this->arrangeConnectorException($exception = \Mockery::mock(\Exception::class)); + + $this->expectException(\get_class($exception)); + $this->porter->import($this->import); + } + + /** + * Tests that when a custom fetch exception handler is specified and the connector throws a recoverable exception + * type, the handler is called on each retry. + */ + public function testCustomFetchExceptionHandler(): void + { + $this->import->setRecoverableExceptionHandler( + \Mockery::mock(RecoverableExceptionHandler::class) + ->shouldReceive('initialize') + ->once() + ->shouldReceive('__invoke') + ->times(Import::DEFAULT_FETCH_ATTEMPTS - 1) + ->getMock() + ); + + $this->arrangeConnectorException(new TestRecoverableException); + + $this->expectException(FailingTooHardException::class); + $this->porter->import($this->import); + } + + /** + * Tests that when a provider fetch exception handler is specified and the connector throws a recoverable + * exception, the handler is called before the user handler. + */ + public function testCustomProviderFetchExceptionHandler(): void + { + $this->import->setRecoverableExceptionHandler( + new StatelessRecoverableExceptionHandler(static function (): void { + throw new \LogicException('This exception must not be thrown!'); + }) + ); + + $this->arrangeConnectorException($connectorException = + new TestRecoverableException('This exception is caught by the provider handler.')); + + $this->resource + ->shouldReceive('fetch') + ->andReturnUsing(static function (ImportConnector $connector) use ($connectorException): \Generator { + $connector->setRecoverableExceptionHandler(new StatelessRecoverableExceptionHandler( + static function (\Exception $exception) use ($connectorException) { + self::assertSame($connectorException, $exception); + + throw new \RuntimeException('This exception is thrown by the provider handler.'); + } + )); + + yield $connector->fetch(\Mockery::mock(DataSource::class)); + }) + ; + + $this->expectException(\RuntimeException::class); + $this->porter->importOne($this->singleImport); + } + + #endregion + + public function testFilter(): void + { + $this->resource->shouldReceive('fetch')->andReturnUsing( + static function (): \Generator { + foreach (range(1, 10) as $integer) { + yield [$integer]; + } + } + ); + + $records = $this->porter->import( + $this->import + ->addTransformer(new FilterTransformer($filter = static function (array $record): int { + return $record[0] % 2; + })) + ); + + self::assertInstanceOf(PorterRecords::class, $records); + self::assertSame([[1], [3], [5], [7], [9]], iterator_to_array($records)); + + /** @var FilteredRecords $previous */ + self::assertInstanceOf(FilteredRecords::class, $previous = $records->getPreviousCollection()); + self::assertNotSame($previous->getFilter(), $filter, 'Filter was not cloned.'); + } + + /** + * Tests that when caching is required but a caching facility is unavailable, an exception is thrown. + */ + public function testCacheUnavailable(): void + { + $this->expectException(CacheUnavailableException::class); + + $this->porter->import($this->import->enableCache()); + } + + #region Throttle + + /** + * Tests that a working throttle implementation is invoked during fetch operations. + */ + public function testThrottle(): void + { + $this->import->setThrottle($throttle = new DualThrottle); + $throttle->setMaxConcurrency(1); + + $records = async($this->porter->import(...), $this->import); + delay(0); + self::assertTrue($throttle->isThrottling()); + + $records->await(); + self::assertFalse($throttle->isThrottling()); + } + + /** + * Tests that a working throttle implementation can be called from multiple fibers queueing excess operations. + */ + public function testThrottleConcurrentFibers(): void + { + $this->import->setThrottle($throttle = new DualThrottle); + $throttle->setMaxPerSecond(1); + + $import = fn () => async($this->porter->import(...), $this->import)->await(); + + $start = microtime(true); + await([async($import), async($import), async($import)]); + + self::assertGreaterThan(3, microtime(true) - $start); + } + + #endregion + + /** + * Tests that when a provider is fetched from the provider factory multiple times, the provider factory is only + * created once. + */ + public function testGetOrCreateProviderFactory(): void + { + $property = new \ReflectionProperty($this->porter, 'providerFactory'); + + $this->porter->import($spec = new StaticImport(new \EmptyIterator())); + self::assertInstanceOf(ProviderFactory::class, $factory1 = $property->getValue($this->porter)); + + $this->porter->import($spec); + self::assertInstanceOf(ProviderFactory::class, $factory2 = $property->getValue($this->porter)); + + self::assertSame($factory1, $factory2); + } } diff --git a/test/MockFactory.php b/test/MockFactory.php index 5a17139..23d2a6d 100644 --- a/test/MockFactory.php +++ b/test/MockFactory.php @@ -5,14 +5,10 @@ use Mockery\MockInterface; use ScriptFUSION\Async\Throttle\Throttle; -use ScriptFUSION\Porter\Connector\AsyncConnector; -use ScriptFUSION\Porter\Connector\AsyncDataSource; use ScriptFUSION\Porter\Connector\Connector; use ScriptFUSION\Porter\Connector\DataSource; use ScriptFUSION\Porter\Connector\ImportConnector; -use ScriptFUSION\Porter\Provider\AsyncProvider; use ScriptFUSION\Porter\Provider\Provider; -use ScriptFUSION\Porter\Provider\Resource\AsyncResource; use ScriptFUSION\Porter\Provider\Resource\ProviderResource; use ScriptFUSION\Porter\Provider\Resource\SingleRecordResource; use ScriptFUSION\StaticClass; @@ -23,41 +19,33 @@ final class MockFactory { use StaticClass; - public static function mockProvider(): Provider|AsyncProvider|MockInterface + public static function mockProvider(): Provider|MockInterface { - return \Mockery::namedMock(uniqid(Provider::class), Provider::class, AsyncProvider::class) + return \Mockery::namedMock(uniqid(Provider::class), Provider::class) ->shouldReceive('getConnector') ->andReturn( \Mockery::mock(Connector::class) ->shouldReceive('fetch') - ->andReturn('foo') - ->getMock() - ->byDefault() - ) - ->byDefault() - ->shouldReceive('getAsyncConnector') - ->andReturn( - \Mockery::mock(AsyncConnector::class) - ->shouldReceive('fetchAsync') ->andReturnUsing(static function (): mixed { delay(0); return 'foo'; }) ->getMock() + ->byDefault() ) ->byDefault() ->getMock() ; } - public static function mockResource(Provider $provider, \Iterator $return = null, bool $single = false) - : ProviderResource|AsyncResource|MockInterface + public static function mockResource(Provider $provider, ?\Iterator $return = null, bool $single = false) + : ProviderResource|MockInterface { - /** @var ProviderResource|AsyncResource|MockInterface $resource */ + /** @var ProviderResource|MockInterface $resource */ $resource = \Mockery::mock( ...array_merge( - [ProviderResource::class, AsyncResource::class], + [ProviderResource::class], $single ? [SingleRecordResource::class] : [] ) ) @@ -68,11 +56,6 @@ public static function mockResource(Provider $provider, \Iterator $return = null return new \ArrayIterator([[$connector->fetch(\Mockery::mock(DataSource::class))]]); }) ->byDefault() - ->shouldReceive('fetchAsync') - ->andReturnUsing(static function (ImportConnector $connector): \Iterator { - return new \ArrayIterator([[$connector->fetchAsync(\Mockery::mock(AsyncDataSource::class))]]); - }) - ->byDefault() ->getMock() ; @@ -83,7 +66,7 @@ public static function mockResource(Provider $provider, \Iterator $return = null return $resource; } - public static function mockSingleRecordResource(Provider $provider): ProviderResource|AsyncResource|MockInterface + public static function mockSingleRecordResource(Provider $provider): ProviderResource|MockInterface { return self::mockResource($provider, null, true); } @@ -91,7 +74,7 @@ public static function mockSingleRecordResource(Provider $provider): ProviderRes public static function mockThrottle(): Throttle|MockInterface { return \Mockery::mock(Throttle::class) - ->allows('watch') + ->allows('async') ->andReturnUsing(fn (\Closure $closure, mixed ...$args) => async($closure, ...$args)) ->byDefault() ->getMock() diff --git a/test/Unit/AsyncImportSpecificationTest.php b/test/Unit/AsyncImportSpecificationTest.php deleted file mode 100644 index 528066a..0000000 --- a/test/Unit/AsyncImportSpecificationTest.php +++ /dev/null @@ -1,98 +0,0 @@ -specification = new AsyncImportSpecification($this->resource = \Mockery::mock(AsyncResource::class)); - } - - /** - * Tests that only async transformers can be added. - */ - public function testAddTransformer(): void - { - $this->specification->addTransformer($transformer = \Mockery::mock(AsyncTransformer::class)); - self::assertContains($transformer, $this->specification->getTransformers()); - - $this->expectException(\TypeError::class); - $this->specification->addTransformer(\Mockery::mock(Transformer::class)); - } - - /** - * Tests that the default exception handler is of the expected type. - */ - public function testDefaultExceptionHandler(): void - { - self::assertInstanceOf( - ExponentialAsyncDelayRecoverableExceptionHandler::class, - $this->specification->getRecoverableExceptionHandler() - ); - } - - public function testClone(): void - { - $this->specification - ->addTransformer(\Mockery::mock(AsyncTransformer::class)) - ->setContext($context = new class { - // Intentionally empty. - }) - ->setRecoverableExceptionHandler($handler = \Mockery::mock(RecoverableExceptionHandler::class)) - ; - - $specification = clone $this->specification; - - self::assertNotSame($this->resource, $specification->getAsyncResource()); - - self::assertNotSame( - array_values($this->specification->getTransformers()), - array_values($specification->getTransformers()) - ); - self::assertNotSame( - array_keys($this->specification->getTransformers()), - array_keys($specification->getTransformers()) - ); - self::assertCount(\count($this->specification->getTransformers()), $specification->getTransformers()); - - self::assertNotSame($context, $specification->getContext()); - self::assertNotSame($handler, $specification->getRecoverableExceptionHandler()); - } - - /** - * Tests that a custom throttle can be set. - */ - public function testThrottle(): void - { - self::assertSame( - $throttle = \Mockery::mock(Throttle::class), - $this->specification->setThrottle($throttle)->getThrottle() - ); - } - - /** - * Tests that when no throttle is set, a default throttle is produced. - */ - public function testDefaultThrottle(): void - { - self::assertInstanceOf(Throttle::class, $this->specification->getThrottle()); - } -} diff --git a/test/Unit/Cache/MemoryCacheTest.php b/test/Unit/Cache/MemoryCacheTest.php index 8a44c4f..fddfb87 100644 --- a/test/Unit/Cache/MemoryCacheTest.php +++ b/test/Unit/Cache/MemoryCacheTest.php @@ -8,6 +8,9 @@ use ScriptFUSION\Porter\Cache\InvalidArgumentException; use ScriptFUSION\Porter\Cache\MemoryCache; +/** + * @see MemoryCache + */ final class MemoryCacheTest extends TestCase { private MemoryCache $cache; @@ -50,21 +53,21 @@ public function testHasItem(): void public function testClear(): void { - $this->cache->clear(); + self::assertTrue($this->cache->clear()); self::assertEmpty($this->cache->getArrayCopy()); } public function testDeleteItem(): void { - $this->cache->deleteItem('foo'); + self::assertTrue($this->cache->deleteItem('foo')); self::assertFalse($this->cache->hasItem('foo')); } public function testDeleteItems(): void { - $this->cache->deleteItems(['foo']); + self::assertTrue($this->cache->deleteItems(['foo'])); self::assertEmpty($this->cache->getArrayCopy()); } @@ -78,14 +81,14 @@ public function testDeleteInvalidItem(): void public function testSave(): void { - $this->cache->save($this->cache->getItem('bar')->set('baz')); + self::assertTrue($this->cache->save($this->cache->getItem('bar')->set('baz'))); self::assertSame('baz', $this->cache->getItem('bar')->get()); } public function testSaveDeferred(): void { - $this->cache->saveDeferred($this->cache->getItem('bar')->set('baz')); + self::assertTrue($this->cache->saveDeferred($this->cache->getItem('bar')->set('baz'))); self::assertSame('baz', $this->cache->getItem('bar')->get()); } diff --git a/test/Unit/Collection/AsyncFilteredRecordsTest.php b/test/Unit/Collection/AsyncFilteredRecordsTest.php deleted file mode 100644 index 59eb016..0000000 --- a/test/Unit/Collection/AsyncFilteredRecordsTest.php +++ /dev/null @@ -1,28 +0,0 @@ -getFilter()); - } -} diff --git a/test/Unit/Collection/AsyncPorterRecordsTest.php b/test/Unit/Collection/AsyncPorterRecordsTest.php deleted file mode 100644 index 3f75479..0000000 --- a/test/Unit/Collection/AsyncPorterRecordsTest.php +++ /dev/null @@ -1,28 +0,0 @@ -getSpecification()); - } -} diff --git a/test/Unit/Collection/AsyncProviderRecordsTest.php b/test/Unit/Collection/AsyncProviderRecordsTest.php deleted file mode 100644 index d950037..0000000 --- a/test/Unit/Collection/AsyncProviderRecordsTest.php +++ /dev/null @@ -1,28 +0,0 @@ -getResource()); - } -} diff --git a/test/Unit/Collection/PorterRecordsTest.php b/test/Unit/Collection/PorterRecordsTest.php index 639cbcc..bff368a 100644 --- a/test/Unit/Collection/PorterRecordsTest.php +++ b/test/Unit/Collection/PorterRecordsTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\TestCase; use ScriptFUSION\Porter\Collection\PorterRecords; use ScriptFUSION\Porter\Collection\RecordCollection; -use ScriptFUSION\Porter\Specification\ImportSpecification; +use ScriptFUSION\Porter\Import\Import; /** * @see PorterRecords @@ -14,15 +14,15 @@ final class PorterRecordsTest extends TestCase { /** - * Tests that the specification passed at construction time is the same as that retrieved from the accessor method. + * Tests that the import passed at construction time is the same as that retrieved from the accessor method. */ - public function testGetSpecification(): void + public function testGetImport(): void { $records = new PorterRecords( - \Mockery::mock(RecordCollection::class), - $specification = \Mockery::mock(ImportSpecification::class) + \Mockery::spy(RecordCollection::class), + $import = \Mockery::mock(Import::class) ); - self::assertSame($specification, $records->getSpecification()); + self::assertSame($import, $records->getImport()); } } diff --git a/test/Unit/Collection/RecordCollectionTest.php b/test/Unit/Collection/RecordCollectionTest.php index 60818d1..9ad6245 100644 --- a/test/Unit/Collection/RecordCollectionTest.php +++ b/test/Unit/Collection/RecordCollectionTest.php @@ -20,11 +20,6 @@ final class RecordCollectionTest extends TestCase */ public function testFindFirstCollection(): void { - /** - * @var RecordCollection $collection1 - * @var RecordCollection $collection2 - * @var RecordCollection $collection3 - */ $collection3 = \Mockery::mock( RecordCollection::class, [ @@ -45,18 +40,16 @@ public function testFindFirstCollection(): void } /** - * Tests that when a RecordCollection yields a non-array datum, an exception is thrown. + * Tests that when a RecordCollection yields a non-array datum, the datum is returned as-is. */ public function testNonArrayYield(): void { /** @var RecordCollection $collection */ $collection = \Mockery::mock( RecordCollection::class, - [new \ArrayIterator(['foo'])] + [new \ArrayIterator([$datum = 'foo'])] )->makePartial(); - $this->expectException(\TypeError::class); - $this->expectExceptionMessageMatches('[must be of( the)? type array]'); - $collection->current(); + self::assertSame($datum, $collection->current()); } } diff --git a/test/Unit/ImportSpecificationTest.php b/test/Unit/ImportSpecificationTest.php deleted file mode 100644 index 94a94d1..0000000 --- a/test/Unit/ImportSpecificationTest.php +++ /dev/null @@ -1,173 +0,0 @@ -specification = new ImportSpecification( - $this->resource = \Mockery::mock(ProviderResource::class) - ); - } - - public function testClone(): void - { - $this->specification - ->addTransformer(\Mockery::mock(Transformer::class)) - ->setContext($context = (object)[]) - ->setRecoverableExceptionHandler($handler = \Mockery::mock(RecoverableExceptionHandler::class)) - ; - - $specification = clone $this->specification; - - self::assertNotSame($this->resource, $specification->getResource()); - - self::assertNotSame( - array_values($this->specification->getTransformers()), - array_values($specification->getTransformers()) - ); - self::assertNotSame( - array_keys($this->specification->getTransformers()), - array_keys($specification->getTransformers()) - ); - self::assertCount(\count($this->specification->getTransformers()), $specification->getTransformers()); - - self::assertNotSame($context, $specification->getContext()); - self::assertNotSame($handler, $specification->getRecoverableExceptionHandler()); - } - - public function testGetResource(): void - { - self::assertSame($this->resource, $this->specification->getResource()); - } - - public function testProviderName(): void - { - self::assertNull($this->specification->getProviderName()); - self::assertSame($name = 'foo', $this->specification->setProviderName($name)->getProviderName()); - self::assertNull($this->specification->setProviderName(null)->getProviderName()); - } - - public function testAddTransformer(): void - { - self::assertEmpty($this->specification->getTransformers()); - - $this->specification->addTransformer($transformer1 = \Mockery::mock(Transformer::class)); - self::assertCount(1, $this->specification->getTransformers()); - self::assertContains($transformer1, $this->specification->getTransformers()); - - $this->specification->addTransformer($transformer2 = \Mockery::mock(Transformer::class)); - self::assertCount(2, $this->specification->getTransformers()); - self::assertContains($transformer1, $this->specification->getTransformers()); - self::assertContains($transformer2, $this->specification->getTransformers()); - - $this->expectException(\TypeError::class); - $this->specification->addTransformer(\Mockery::mock(AsyncTransformer::class)); - } - - public function testAddTransformers(): void - { - self::assertEmpty($this->specification->getTransformers()); - - $this->specification->addTransformers([ - $transformer1 = \Mockery::mock(Transformer::class), - $transformer2 = \Mockery::mock(Transformer::class), - ]); - - self::assertCount(2, $this->specification->getTransformers()); - self::assertContains($transformer1, $this->specification->getTransformers()); - self::assertContains($transformer2, $this->specification->getTransformers()); - } - - public function testAddSameTransformer(): void - { - $this->specification->addTransformer($transformer = \Mockery::mock(Transformer::class)); - - $this->expectException(DuplicateTransformerException::class); - $this->specification->addTransformer($transformer); - } - - public function testClearTransformers(): void - { - $this->specification->addTransformer($transformer = \Mockery::mock(Transformer::class)); - self::assertContains($transformer, $this->specification->getTransformers()); - - $this->specification->clearTransformers(); - self::assertEmpty($this->specification->getTransformers()); - } - - public function testContext(): void - { - self::assertSame($context = 'foo', $this->specification->setContext($context)->getContext()); - } - - public function testCache(): void - { - self::assertFalse($this->specification->mustCache()); - - $this->specification->enableCache(); - self::assertTrue($this->specification->mustCache()); - - $this->specification->disableCache(); - self::assertFalse($this->specification->mustCache()); - } - - /** - * @dataProvider provideValidFetchAttempts - */ - public function testValidMaxFetchAttempts(int $value): void - { - self::assertSame($value, $this->specification->setMaxFetchAttempts($value)->getMaxFetchAttempts()); - } - - public function provideValidFetchAttempts(): array - { - return [ - [1], - [PHP_INT_MAX], - ]; - } - - /** - * @dataProvider provideInvalidFetchAttempts - */ - public function testInvalidMaxFetchAttempts(int|float $value, string $exceptionType): void - { - $this->expectException($exceptionType); - $this->specification->setMaxFetchAttempts($value); - } - - public function provideInvalidFetchAttempts(): array - { - return [ - 'Too low, positive' => [0, \InvalidArgumentException::class], - 'Too low, negative' => [-1, \InvalidArgumentException::class], - 'Float in range' => [1.9, \TypeError::class], - ]; - } - - public function testExceptionHandler(): void - { - self::assertSame( - $handler = \Mockery::mock(RecoverableExceptionHandler::class), - $this->specification->setRecoverableExceptionHandler($handler)->getRecoverableExceptionHandler() - ); - } -} diff --git a/test/Unit/ImportTest.php b/test/Unit/ImportTest.php new file mode 100644 index 0000000..d030821 --- /dev/null +++ b/test/Unit/ImportTest.php @@ -0,0 +1,184 @@ +import = new Import( + $this->resource = \Mockery::mock(ProviderResource::class) + ); + } + + public function testClone(): void + { + $this->import + ->addTransformer(\Mockery::mock(Transformer::class)) + ->setContext($context = (object)[]) + ->setRecoverableExceptionHandler($handler = \Mockery::mock(RecoverableExceptionHandler::class)) + ; + + $import = clone $this->import; + + self::assertNotSame($this->resource, $import->getResource()); + + self::assertNotSame( + array_values($this->import->getTransformers()), + array_values($import->getTransformers()) + ); + self::assertNotSame( + array_keys($this->import->getTransformers()), + array_keys($import->getTransformers()) + ); + self::assertCount(\count($this->import->getTransformers()), $import->getTransformers()); + + self::assertNotSame($context, $import->getContext()); + self::assertNotSame($handler, $import->getRecoverableExceptionHandler()); + } + + public function testGetResource(): void + { + self::assertSame($this->resource, $this->import->getResource()); + } + + public function testProviderName(): void + { + self::assertNull($this->import->getProviderName()); + self::assertSame($name = 'foo', $this->import->setProviderName($name)->getProviderName()); + self::assertNull($this->import->setProviderName(null)->getProviderName()); + } + + public function testAddTransformer(): void + { + self::assertEmpty($this->import->getTransformers()); + + $this->import->addTransformer($transformer1 = \Mockery::mock(Transformer::class)); + self::assertCount(1, $this->import->getTransformers()); + self::assertContains($transformer1, $this->import->getTransformers()); + + $this->import->addTransformer($transformer2 = \Mockery::mock(Transformer::class)); + self::assertCount(2, $this->import->getTransformers()); + self::assertContains($transformer1, $this->import->getTransformers()); + self::assertContains($transformer2, $this->import->getTransformers()); + + $this->expectException(\TypeError::class); + $this->import->addTransformer(\Mockery::mock(AsyncTransformer::class)); + } + + public function testAddTransformers(): void + { + self::assertEmpty($this->import->getTransformers()); + + $this->import->addTransformers([ + $transformer1 = \Mockery::mock(Transformer::class), + $transformer2 = \Mockery::mock(Transformer::class), + ]); + + self::assertCount(2, $this->import->getTransformers()); + self::assertContains($transformer1, $this->import->getTransformers()); + self::assertContains($transformer2, $this->import->getTransformers()); + } + + public function testAddSameTransformer(): void + { + $this->import->addTransformer($transformer = \Mockery::mock(Transformer::class)); + + $this->expectException(DuplicateTransformerException::class); + $this->import->addTransformer($transformer); + } + + public function testClearTransformers(): void + { + $this->import->addTransformer($transformer = \Mockery::mock(Transformer::class)); + self::assertContains($transformer, $this->import->getTransformers()); + + $this->import->clearTransformers(); + self::assertEmpty($this->import->getTransformers()); + } + + public function testContext(): void + { + self::assertSame($context = 'foo', $this->import->setContext($context)->getContext()); + } + + public function testCache(): void + { + self::assertFalse($this->import->mustCache()); + + $this->import->enableCache(); + self::assertTrue($this->import->mustCache()); + + $this->import->disableCache(); + self::assertFalse($this->import->mustCache()); + } + + /** + * @dataProvider provideValidFetchAttempts + */ + public function testValidMaxFetchAttempts(int $value): void + { + self::assertSame($value, $this->import->setMaxFetchAttempts($value)->getMaxFetchAttempts()); + } + + public function provideValidFetchAttempts(): array + { + return [ + [1], + [PHP_INT_MAX], + ]; + } + + /** + * @dataProvider provideInvalidFetchAttempts + */ + public function testInvalidMaxFetchAttempts(int|float $value, string $exceptionType): void + { + $this->expectException($exceptionType); + $this->import->setMaxFetchAttempts($value); + } + + public function provideInvalidFetchAttempts(): array + { + return [ + 'Too low, positive' => [0, \InvalidArgumentException::class], + 'Too low, negative' => [-1, \InvalidArgumentException::class], + 'Float in range' => [1.9, \TypeError::class], + ]; + } + + public function testExceptionHandler(): void + { + self::assertSame( + $handler = \Mockery::mock(RecoverableExceptionHandler::class), + $this->import->setRecoverableExceptionHandler($handler)->getRecoverableExceptionHandler() + ); + } + + /** + * Tests that a custom throttle can be set. + */ + public function testThrottle(): void + { + self::assertSame( + $throttle = \Mockery::mock(Throttle::class), + $this->import->setThrottle($throttle)->getThrottle() + ); + } +} diff --git a/test/Unit/PorterAwareTraitTest.php b/test/Unit/PorterAwareTraitTest.php index c1aa7d2..8a0c839 100644 --- a/test/Unit/PorterAwareTraitTest.php +++ b/test/Unit/PorterAwareTraitTest.php @@ -4,6 +4,7 @@ namespace ScriptFUSIONTest\Unit; use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; use ScriptFUSION\Porter\Porter; use ScriptFUSION\Porter\PorterAwareTrait; @@ -14,14 +15,12 @@ public function testGetSetPorter(): void /** @var PorterAwareTrait $porterAware */ $porterAware = $this->getObjectForTrait(PorterAwareTrait::class); - $porterAware->setPorter($porter = \Mockery::mock(Porter::class)); + $porterAware->setPorter($porter = new Porter(\Mockery::mock(ContainerInterface::class))); self::assertSame( $porter, \Closure::bind( - function (): Porter { - return $this->getPorter(); - }, + fn () => $this->getPorter(), $porterAware, $porterAware )() diff --git a/test/infection.json b/test/infection.json index 0de521d..36a7149 100644 --- a/test/infection.json +++ b/test/infection.json @@ -2,7 +2,7 @@ "timeout": 10, "source": { "directories": [ - "src" + "../src" ] }, "phpUnit": { diff --git a/test/phpunit.xml b/test/phpunit.xml index 695737b..e140a3a 100644 --- a/test/phpunit.xml +++ b/test/phpunit.xml @@ -1,5 +1,6 @@ .