From f5dbac99db1419d287ff344a6c90071fa9337066 Mon Sep 17 00:00:00 2001 From: Vincent Boon Date: Wed, 27 Jul 2022 16:11:40 +0200 Subject: [PATCH] Initial commit --- .editorconfig | 7 + .github/CONTRIBUTING.md | 55 ++++++ .github/SECURITY.md | 3 + .gitignore | 3 + LICENSE.md | 16 ++ README.md | 229 ++++++++++++++++++++++++ composer.json | 52 ++++++ config/dynamics.php | 31 ++++ phpunit.xml | 17 ++ src/Client/ClientFactory.php | 102 +++++++++++ src/Client/ClientHttpProvider.php | 47 +++++ src/Commands/TestConnection.php | 31 ++++ src/Concerns/CanBeSerialized.php | 24 +++ src/Concerns/HasCasts.php | 16 ++ src/Concerns/HasData.php | 49 +++++ src/Concerns/HasKeys.php | 42 +++++ src/Exceptions/DynamicsException.php | 18 ++ src/Exceptions/ModifiedException.php | 7 + src/Exceptions/NotFoundException.php | 7 + src/Exceptions/UnreachableException.php | 7 + src/OData/BaseResource.php | 133 ++++++++++++++ src/OData/Pages/ArchivedSalesLine.php | 21 +++ src/OData/Pages/ArchivedSalesOrder.php | 20 +++ src/OData/Pages/Contact.php | 12 ++ src/OData/Pages/Country.php | 12 ++ src/OData/Pages/Customer.php | 12 ++ src/OData/Pages/Item.php | 12 ++ src/OData/Pages/ItemCrossReference.php | 17 ++ src/OData/Pages/ItemLedgerEntry.php | 16 ++ src/OData/Pages/SalesDiscount.php | 25 +++ src/OData/Pages/SalesHeader.php | 13 ++ src/OData/Pages/SalesInvoice.php | 12 ++ src/OData/Pages/SalesInvoiceLine.php | 17 ++ src/OData/Pages/SalesLine.php | 18 ++ src/OData/Pages/SalesOrder.php | 13 ++ src/OData/Pages/SalesPrice.php | 24 +++ src/OData/Pages/SalesShipment.php | 12 ++ src/OData/Pages/SalesShipmentHeader.php | 12 ++ src/OData/Pages/SalesShipmentLine.php | 17 ++ src/OData/Pages/ShipToAddress.php | 13 ++ src/OData/Resource.php | 11 ++ src/Query/QueryBuilder.php | 160 +++++++++++++++++ src/ServiceProvider.php | 49 +++++ tests/OData/BaseResourceTest.php | 58 ++++++ tests/TestCase.php | 16 ++ 45 files changed, 1488 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/SECURITY.md create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/dynamics.php create mode 100644 phpunit.xml create mode 100644 src/Client/ClientFactory.php create mode 100644 src/Client/ClientHttpProvider.php create mode 100644 src/Commands/TestConnection.php create mode 100644 src/Concerns/CanBeSerialized.php create mode 100644 src/Concerns/HasCasts.php create mode 100644 src/Concerns/HasData.php create mode 100644 src/Concerns/HasKeys.php create mode 100644 src/Exceptions/DynamicsException.php create mode 100644 src/Exceptions/ModifiedException.php create mode 100644 src/Exceptions/NotFoundException.php create mode 100644 src/Exceptions/UnreachableException.php create mode 100644 src/OData/BaseResource.php create mode 100644 src/OData/Pages/ArchivedSalesLine.php create mode 100644 src/OData/Pages/ArchivedSalesOrder.php create mode 100644 src/OData/Pages/Contact.php create mode 100644 src/OData/Pages/Country.php create mode 100644 src/OData/Pages/Customer.php create mode 100644 src/OData/Pages/Item.php create mode 100644 src/OData/Pages/ItemCrossReference.php create mode 100644 src/OData/Pages/ItemLedgerEntry.php create mode 100644 src/OData/Pages/SalesDiscount.php create mode 100644 src/OData/Pages/SalesHeader.php create mode 100644 src/OData/Pages/SalesInvoice.php create mode 100644 src/OData/Pages/SalesInvoiceLine.php create mode 100644 src/OData/Pages/SalesLine.php create mode 100644 src/OData/Pages/SalesOrder.php create mode 100644 src/OData/Pages/SalesPrice.php create mode 100644 src/OData/Pages/SalesShipment.php create mode 100644 src/OData/Pages/SalesShipmentHeader.php create mode 100644 src/OData/Pages/SalesShipmentLine.php create mode 100644 src/OData/Pages/ShipToAddress.php create mode 100644 src/OData/Resource.php create mode 100644 src/Query/QueryBuilder.php create mode 100644 src/ServiceProvider.php create mode 100644 tests/OData/BaseResourceTest.php create mode 100644 tests/TestCase.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9975675 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..b4ae1c4 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,55 @@ +# Contributing + +Contributions are **welcome** and will be fully **credited**. + +Please read and understand the contribution guide before creating an issue or pull request. + +## Etiquette + +This project is open source, and as such, the maintainers give their free time to build and maintain the source code +held within. They make the code freely available in the hope that it will be of use to other developers. It would be +extremely unfair for them to suffer abuse or anger for their hard work. + +Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the +world that developers are civilized and selfless people. + +It's the duty of the maintainer to ensure that all submissions to the project are of sufficient +quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. + +## Viability + +When requesting or submitting new features, first consider whether it might be useful to others. Open +source projects are used by many developers, who may have entirely different needs to your own. Think about +whether or not your feature is likely to be used by other users of the project. + +## Procedure + +Before filing an issue: + +- Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. +- Check to make sure your feature suggestion isn't already present within the project. +- Check the pull requests tab to ensure that the bug doesn't have a fix in progress. +- Check the pull requests tab to ensure that the feature isn't already in progress. + +Before submitting a pull request: + +- Check the codebase to ensure that your feature doesn't already exist. +- Check the pull requests to ensure that another person hasn't already submitted the feature or fix. + +## Requirements + +If the project maintainer has any additional requirements, you will find them listed here. + +- **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](https://pear.php.net/package/PHP_CodeSniffer). + +- **Add tests!** - Your patch won't be accepted if it doesn't have tests. + +- **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. + +- **Consider our release cycle** - We try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option. + +- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. + +- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. + +**Happy coding**! diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..d65e6df --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,3 @@ +# Security Policy + +If you discover any security related issues, please email security@justbetter.nl instead of using the issue tracker. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..50b321e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +vendor +composer.lock +.phpunit.result.cache diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..2fe24ac --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,16 @@ +The MIT License (MIT) + +Copyright (c) JustBetter + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d0863c3 --- /dev/null +++ b/README.md @@ -0,0 +1,229 @@ +# Dynamics Client + +This package will connect you to your Microsoft Dynamics web services via OData. Custom web services can easily be +implemented and mapped to your liking. + +The way we interact with OData has been inspired by Laravel's Query Builder. + +```php +$customer = Customer::query()->findOrFail('1000'); + +$customer->update([ + 'Name' => 'John Doe', +]); + +$customers = Customer::query() + ->where('City', '=', 'Alkmaar') + ->lazy(); + +$items = Item::query() + ->whereIn('No', ['1000', '2000']) + ->get(); + +$customer = Customer::new()->create([ + 'Name' => 'Jane Doe', +]) +``` + +## Installation + +Install the composer package. + +```shell +composer require justbetter/laravel-dynamics-client +``` + +## Setup + +Publish the configuration of the package. + +```shell +php artisan vendor:publish --provider="JustBetter\DynamicsClient\ServiceProvider" --tag=config +``` + +## Configuration + +Add your Dynamics credentials in the `.env`: + +``` +DYNAMICS_BASE_URL=https://127.0.0.1:7048/DYNAMICS +DYNAMICS_VERSION=ODataV4 +DYNAMICS_COMPANY= +DYNAMICS_USERNAME= +DYNAMICS_PASSWORD= +DYNAMICS_PAGE_SIZE=1000 +``` + +Be sure the `DYNAMICS_PAGE_SIZE` is set equally to the `Max Page Size` under `OData Services` in the configuration of +Dynamics. This is crucial for the functionalities of the `lazy` method of the `QueryBuilder`. + +### Authentication + +> **Note:** Be sure that Dynamics has been properly configured for OData. + +This package uses NTLM authentication by default. If you are required to use basic auth you can change this in +your `.env`. + +``` +DYNAMICS_AUTH=basic +``` + +| Type | Authentication Type | +|------------|----------------------| +| On-Premise | NTLM authentication | +| 365 | Basic authentication | + +### Connections + +Multiple connections are supported. You can easily update your `dynamics` configuration to add as many connections as +you wish. + +```php +// Will use the default connection. +Customer::query()->first(); + +// Uses the supplied connection. +Customer::query('other_connection')->first(); +``` + +## Adding web services + +Adding a web service to your configuration is easily done. Start by creating your own resource class to map te data to. + +```php +use JustBetter\DynamicsClient\OData\BaseResource; + +class Customer extends BaseResource +{ + // +} +``` + +### Primary Key + +By default, the primary key of a resource will default to `No` as a string. You can override this by supplying the +variable `$primaryKey`. + +```php +public array $primaryKey = [ + 'Code', +]; +``` + +### Data Casting + +Fields in resources will by default be treated as a string. For some fields, like a line number, this should be casted +to an integer. + +```php +public array $casts = [ + 'Line_No' => 'int', +]; +``` + +### Registering Your Resource + +Lastly, you should register your resource in your configuration file to let the package know where the web service is +located. This should correspond to the service name configured in Dynamics. + +If your resource class name is the same as the service name, no manual configuration is needed. + +> **Note:** Make sure your web service is published. + +```php +return [ + + /* Resource Configuration */ + 'resources' => [ + Customer::class => 'CustomerCard', + ], + +]; +``` + +## Query Builder + +Querying data is easily done using the QueryBuilder. + +Using the `get` method will only return the first result page. If you wish to efficiently loop through all records, +use `lazy` instead. + +```php +$customers = Customer::query() + ->where('City', '=', 'Alkmaar') + ->lazy() + ->each(function(Customer $customer): void { + // + }); +``` + +See the `QueryBuilder` class for all available methods. + +## Creating records + +Create a new record. + +```php +Customer::new()->create([ + 'Name' => 'John Doe' +]) +``` + +## Updating records + +Update an existing record. + +```php +$customer = Customer::query()->find('1000'); +$customer->update([ + 'Name' => 'John Doe', +]); +``` + +## Deleting records + +Delete a record. + +```php +$customer = Customer::query()->find('1000'); +$customer->delete(); +``` + +## Debugging + +If you wish to review your query before you sent it, you may want to use the `dd` function on the builder. + +```php +Customer::query() + ->where('City', '=', 'Alkmaar') + ->whereIn('No', ['1000', '2000']) + ->dd(); + +// Customer?$filter=City eq 'Alkmaar' and (No eq '1000' or No eq '2000') +``` + +## Commands + +You can run the following command to check if you can successfully connect to Dynamics. + +```shell +php artisan dynamics:connect {connection?} +``` + +## Contributing + +Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for details. + +## Security Vulnerabilities + +Please review [our security policy](../../security/policy) on how to report security vulnerabilities. + +## Credits + +- [Vincent Boon](https://github.com/VincentBean) +- [Ramon Rietdijk](https://github.com/ramonrietdijk) +- [All Contributors](../../contributors) + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..17e17a1 --- /dev/null +++ b/composer.json @@ -0,0 +1,52 @@ +{ + "name": "just-better/laravel-dynamics-client", + "description": "A client to connect with Microsoft Dynamics", + "type": "package", + "authors": [ + { + "name": "Vincent Boon", + "email": "vincent@justbetter.nl", + "role": "Developer" + }, + { + "name": "Ramon Rietdijk", + "email": "ramon@justbetter.nl", + "role": "Developer" + } + ], + "require": { + "php": "^8.0", + "justbetter/odata-client": "^1.0", + "laravel/framework": "^9.0" + }, + "require-dev": { + "laravel/pint": "^1.1", + "orchestra/testbench": "^7.0", + "phpunit/phpunit": "^9.5.10" + }, + "autoload": { + "psr-4": { + "JustBetter\\DynamicsClient\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "JustBetter\\DynamicsClient\\Tests\\": "tests" + } + }, + "scripts": { + "test": "phpunit" + }, + "config": { + "sort-packages": true + }, + "extra": { + "laravel": { + "providers": [ + "JustBetter\\DynamicsClient\\ServiceProvider" + ] + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/config/dynamics.php b/config/dynamics.php new file mode 100644 index 0000000..5d3884f --- /dev/null +++ b/config/dynamics.php @@ -0,0 +1,31 @@ + [ + Customer::class => 'CustomerCard', + ], + + /* Default Dynamics Connection Name */ + 'connection' => env('DYNAMICS_CONNECTION', 'default'), + + /* Available Dynamics Connections */ + 'connections' => [ + 'default' => [ + 'base_url' => env('DYNAMICS_BASE_URL'), + 'version' => env('DYNAMICS_VERSION', 'ODataV4'), + 'company' => env('DYNAMICS_COMPANY'), + 'username' => env('DYNAMICS_USERNAME'), + 'password' => env('DYNAMICS_PASSWORD'), + 'auth' => env('DYNAMICS_AUTH', 'ntlm'), + 'page_size' => env('DYNAMICS_PAGE_SIZE', 1000), + 'options' => [ + 'connect_timeout' => 5, + ], + ], + ], + +]; diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..6dcf683 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,17 @@ + + + + + ./tests/* + + + + + ./src + + + diff --git a/src/Client/ClientFactory.php b/src/Client/ClientFactory.php new file mode 100644 index 0000000..b23f1e2 --- /dev/null +++ b/src/Client/ClientFactory.php @@ -0,0 +1,102 @@ + $connection]) + ); + } + + $this + ->options($config['options']) + ->url($config['base_url'], $config['version'], "Company('{$config['company']}')") + ->auth($config['username'], $config['password'], $config['auth']) + ->header('Accept', 'application/json') + ->header('Content-Type', 'application/json'); + } + + public static function make(string $connection): static + { + return new static($connection); + } + + public function options(array $options): static + { + $this->options = $options; + + return $this; + } + + public function option(string $option, mixed $value): static + { + $this->options[$option] = $value; + + return $this; + } + + public function headers(array $headers): static + { + $this->options['headers'] = $headers; + + return $this; + } + + public function header(string $key, string $value): static + { + $this->options['headers'][$key] = $value; + + return $this; + } + + public function etag(string $etag = null): static + { + $this->header('If-Match', 'W/"\''.$etag.'\'"'); + + return $this; + } + + public function url(string ...$url): static + { + $this->url = implode('/', $url); + + return $this; + } + + public function auth(string $username, string $password, string $auth): static + { + $credentials = [ + $username, + $password, + ]; + + if ($auth === 'ntlm') { + $credentials[] = 'ntlm'; + } + + $this->option('auth', $credentials); + + return $this; + } + + public function fabricate(): ODataClient + { + $httpProvider = new ClientHttpProvider(); + $httpProvider->setExtraOptions($this->options); + + return new ODataClient($this->url, null, $httpProvider); + } +} diff --git a/src/Client/ClientHttpProvider.php b/src/Client/ClientHttpProvider.php new file mode 100644 index 0000000..76f4246 --- /dev/null +++ b/src/Client/ClientHttpProvider.php @@ -0,0 +1,47 @@ +hasResponse() + ? $exception->getResponse()->getBody()->getContents() + : $exception->getMessage(); + + $code = $exception->getCode(); + + /** @var class-string $mapping */ + $mapping = match ($code) { + 404 => NotFoundException::class, + 412 => ModifiedException::class, + default => DynamicsException::class, + }; + + /** @var DynamicsException $dynamicsException */ + $dynamicsException = new $mapping($message, $code, $exception); + + throw $dynamicsException->setRequest($request); + } catch (ConnectException $exception) { + throw (new UnreachableException($exception->getMessage(), $exception->getCode(), $exception)) + ->setRequest($request); + } catch (TransferException $exception) { + throw (new DynamicsException($exception->getMessage(), $exception->getCode(), $exception)) + ->setRequest($request); + } + } +} diff --git a/src/Commands/TestConnection.php b/src/Commands/TestConnection.php new file mode 100644 index 0000000..4102b91 --- /dev/null +++ b/src/Commands/TestConnection.php @@ -0,0 +1,31 @@ +argument('connection') ?? config('dynamics.connection'); + + $client = ClientFactory::make($connection)->fabricate(); + $client->setEntityReturnType(false); + + /** @var ODataResponse $response */ + $response = $client->get(''); + + $company = $response->getBody(); + + $this->info('Successfully connected to company "'.$company['Name'].'"'); + + return static::SUCCESS; + } +} diff --git a/src/Concerns/CanBeSerialized.php b/src/Concerns/CanBeSerialized.php new file mode 100644 index 0000000..95de6a5 --- /dev/null +++ b/src/Concerns/CanBeSerialized.php @@ -0,0 +1,24 @@ + $this->connection, + 'endpoint' => $this->endpoint, + 'data' => $this->getIdentifierData(), + ]; + } + + public function __unserialize(array $data): void + { + $this + ->setConnection($data['connection']) + ->setEndpoint($data['endpoint']) + ->setData($data['data']) + ->refresh(); + } +} diff --git a/src/Concerns/HasCasts.php b/src/Concerns/HasCasts.php new file mode 100644 index 0000000..70b4bb2 --- /dev/null +++ b/src/Concerns/HasCasts.php @@ -0,0 +1,16 @@ +casts[$key] ?? null) { + 'int', 'date', 'decimal' => (string) $value, + default => '\''.$value.'\'', + }; + } +} diff --git a/src/Concerns/HasData.php b/src/Concerns/HasData.php new file mode 100644 index 0000000..710d91d --- /dev/null +++ b/src/Concerns/HasData.php @@ -0,0 +1,49 @@ +data; + } + + public function setData(array $data): static + { + $this->data = $data; + + return $this; + } + + public function offsetExists(mixed $offset): bool + { + return array_key_exists($offset, $this->data); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->data[$offset]; + } + + public function offsetSet($offset, $value): void + { + if (is_null($offset)) { + return; + } + + $this->data[$offset] = $value; + } + + public function offsetUnset(mixed $offset): void + { + unset($this->data[$offset]); + } + + public function toArray(): array + { + return $this->data; + } +} diff --git a/src/Concerns/HasKeys.php b/src/Concerns/HasKeys.php new file mode 100644 index 0000000..f5a6ee8 --- /dev/null +++ b/src/Concerns/HasKeys.php @@ -0,0 +1,42 @@ +primaryKey) + ->mapWithKeys(fn (string $key): array => [$key => $this[$key]]) + ->toArray(); + } + + public function getIdentifierString(): string + { + $values = collect($this->getIdentifierData()); + + $includeKeyNames = $this->includeKeyNames && $values->count() > 1; + + return $values + ->filter(fn (mixed $value): bool => $value !== null) + ->map(function (mixed $value, string $key) use ($includeKeyNames): string { + $cast = $this->cast($key, $value); + + return $includeKeyNames ? $key.'='.$cast : $cast; + }) + ->implode(','); + } + + /* Full OData URL for this specific resource */ + public function getResourceUrl(): string + { + return $this->endpoint.'('.$this->getIdentifierString().')'; + } +} diff --git a/src/Exceptions/DynamicsException.php b/src/Exceptions/DynamicsException.php new file mode 100644 index 0000000..cb7a063 --- /dev/null +++ b/src/Exceptions/DynamicsException.php @@ -0,0 +1,18 @@ +request = $request; + + return $this; + } +} diff --git a/src/Exceptions/ModifiedException.php b/src/Exceptions/ModifiedException.php new file mode 100644 index 0000000..46e9400 --- /dev/null +++ b/src/Exceptions/ModifiedException.php @@ -0,0 +1,7 @@ +connection = $connection ?? config('dynamics.connection'); + $this->endpoint = $endpoint ?? config('dynamics.resources.'.static::class, Str::afterLast(static::class, '\\')); + } + + public static function new(?string $connection = null, ?string $endpoint = null): static + { + return new static($connection, $endpoint); + } + + public static function query(?string $connection = null, ?string $endpoint = null): QueryBuilder + { + return static::new($connection, $endpoint)->newQuery(); + } + + public function setConnection(string $connection): static + { + $this->connection = $connection; + + return $this; + } + + public function setEndpoint(string $endpoint): static + { + $this->endpoint = $endpoint; + + return $this; + } + + public function fromEntity(Entity $entity): static + { + $this->setData($entity->toArray()); + + return $this; + } + + public function fromPage(BaseResource $page): static + { + $this->setData($page->getData()); + + return $this; + } + + public function create(array $data): ?static + { + /** @var array $entities */ + $entities = $this->client()->post($this->endpoint, $data); + + if (empty($entities)) { + return null; + } + + $entity = reset($entities); + + return static::new($this->connection, $this->endpoint)->fromEntity($entity); + } + + public function update(array $data, bool $force = false): ?static + { + $this + ->client($this->etag($force)) + ->patch($this->getResourceUrl(), $data); + + return $this->refresh(); + } + + public function delete(bool $force = false): void + { + $this + ->client($this->etag($force)) + ->delete($this->getResourceUrl()); + } + + public function refresh(): static + { + $values = collect($this->primaryKey) + ->map(fn (string $key): mixed => $this[$key]) + ->toArray(); + + $baseResource = static::query($this->connection, $this->endpoint)->find(...$values); + + return $this->fromPage($baseResource); + } + + public function etag(bool $force = false): string + { + return $force ? '*' : $this->data['ETag']; + } + + public function client(?string $etag = null): ODataClient + { + $factory = ClientFactory::make($this->connection); + + if ($etag) { + $factory->etag($etag); + } + + return $factory->fabricate(); + } + + public function newQuery(): QueryBuilder + { + return new QueryBuilder($this->client(), $this->connection, $this->endpoint, static::class); + } +} diff --git a/src/OData/Pages/ArchivedSalesLine.php b/src/OData/Pages/ArchivedSalesLine.php new file mode 100644 index 0000000..bd2e9d7 --- /dev/null +++ b/src/OData/Pages/ArchivedSalesLine.php @@ -0,0 +1,21 @@ + 'int', + 'Version_No' => 'int', + ]; +} diff --git a/src/OData/Pages/ArchivedSalesOrder.php b/src/OData/Pages/ArchivedSalesOrder.php new file mode 100644 index 0000000..9bf827e --- /dev/null +++ b/src/OData/Pages/ArchivedSalesOrder.php @@ -0,0 +1,20 @@ + 'int', + 'Version_No' => 'int', + ]; +} diff --git a/src/OData/Pages/Contact.php b/src/OData/Pages/Contact.php new file mode 100644 index 0000000..e96d895 --- /dev/null +++ b/src/OData/Pages/Contact.php @@ -0,0 +1,12 @@ + 'int', + ]; +} diff --git a/src/OData/Pages/SalesDiscount.php b/src/OData/Pages/SalesDiscount.php new file mode 100644 index 0000000..deb605d --- /dev/null +++ b/src/OData/Pages/SalesDiscount.php @@ -0,0 +1,25 @@ + 'date', + 'Minimum_Quantity' => 'decimal', + ]; +} diff --git a/src/OData/Pages/SalesHeader.php b/src/OData/Pages/SalesHeader.php new file mode 100644 index 0000000..3a987ab --- /dev/null +++ b/src/OData/Pages/SalesHeader.php @@ -0,0 +1,13 @@ + 'int', + ]; +} diff --git a/src/OData/Pages/SalesLine.php b/src/OData/Pages/SalesLine.php new file mode 100644 index 0000000..e4c775b --- /dev/null +++ b/src/OData/Pages/SalesLine.php @@ -0,0 +1,18 @@ + 'int', + ]; +} diff --git a/src/OData/Pages/SalesOrder.php b/src/OData/Pages/SalesOrder.php new file mode 100644 index 0000000..dc61739 --- /dev/null +++ b/src/OData/Pages/SalesOrder.php @@ -0,0 +1,13 @@ + 'date', + 'Minimum_Quantity' => 'decimal', + ]; +} diff --git a/src/OData/Pages/SalesShipment.php b/src/OData/Pages/SalesShipment.php new file mode 100644 index 0000000..2545307 --- /dev/null +++ b/src/OData/Pages/SalesShipment.php @@ -0,0 +1,12 @@ + 'int', + ]; +} diff --git a/src/OData/Pages/ShipToAddress.php b/src/OData/Pages/ShipToAddress.php new file mode 100644 index 0000000..3e9c85a --- /dev/null +++ b/src/OData/Pages/ShipToAddress.php @@ -0,0 +1,13 @@ +builder = (new Builder($client))->from($endpoint); + } + + public function __call(string $name, array $arguments): mixed + { + if (method_exists($this, $name)) { + return $this->$name(...$arguments); + } + + $this->builder->$name(...$arguments); + + return $this; + } + + public function mapToClass(Entity $entity): BaseResource + { + /** @var class-string $class */ + $class = $this->class; + + return $class::new($this->connection, $this->endpoint)->fromEntity($entity); + } + + public function get(): Enumerable + { + return $this->builder->get()->map(fn (Entity $entity): BaseResource => $this->mapToClass($entity)); + } + + public function first(): ?BaseResource + { + /** @var ?Entity $entity */ + $entity = $this->builder->first(); + + return is_null($entity) + ? null + : $this->mapToClass($entity); + } + + public function firstOrFail(): BaseResource + { + $resource = $this->first(); + + if ($resource === null) { + throw new NotFoundException(); + } + + return $resource; + } + + public function find(mixed ...$values): ?BaseResource + { + /** @var class-string $class */ + $class = $this->class; + + /** @var BaseResource $baseResource */ + $baseResource = app($class); + + /** @var ?Entity $entity */ + $entity = $this->builder->where(array_combine($baseResource->primaryKey, $values))->first(); + + return is_null($entity) + ? null + : $this->mapToClass($entity); + } + + public function findOrFail(mixed ...$values): BaseResource + { + $resource = $this->find(...$values); + + if ($resource === null) { + throw new NotFoundException(); + } + + return $resource; + } + + public function lazy(): LazyCollection + { + return LazyCollection::make(function (): Generator { + $pageSize = config('dynamics.connections.'.$this->connection.'.page_size'); + $page = 0; + + $hasNext = true; + + while ($hasNext) { + if ($page > 0) { + $this->builder->skip($page * $pageSize); + } + + $this->builder->take($pageSize); + + $records = $this->get(); + + $hasNext = $records->count() === $pageSize; + + yield from $records; + + $page++; + } + }); + } + + public function limit(int $limit): static + { + $this->builder->take($limit); + + return $this; + } + + public function whereIn(string $field, array $values): static + { + $this->builder->where(function (Builder $builder) use ($field, $values): void { + foreach (array_values($values) as $index => $value) { + $method = $index === 0 ? 'where' : 'orWhere'; + + $builder->$method($field, '=', $value); + } + }); + + return $this; + } + + public function when(mixed $statement, Closure $closure): static + { + if ($statement) { + $closure($this, $statement); + } + + return $this; + } + + public function dd(): void + { + dd($this->builder->toRequest()); + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php new file mode 100644 index 0000000..d6b65fb --- /dev/null +++ b/src/ServiceProvider.php @@ -0,0 +1,49 @@ +registerConfig(); + } + + protected function registerConfig(): static + { + $this->mergeConfigFrom(__DIR__.'/../config/dynamics.php', 'dynamics'); + + return $this; + } + + public function boot(): void + { + $this + ->bootConfig() + ->bootCommands(); + } + + protected function bootConfig(): static + { + $this->publishes([ + __DIR__.'/../config/dynamics.php' => config_path('dynamics.php'), + ], 'config'); + + return $this; + } + + protected function bootCommands(): static + { + if ($this->app->runningInConsole()) { + $this->commands([ + TestConnection::class, + ]); + } + + return $this; + } +} diff --git a/tests/OData/BaseResourceTest.php b/tests/OData/BaseResourceTest.php new file mode 100644 index 0000000..0f65a5a --- /dev/null +++ b/tests/OData/BaseResourceTest.php @@ -0,0 +1,58 @@ +set('dynamics.connections.::default::', [ + 'base_url' => '::base_url::', + 'company' => '::company::', + 'username' => '::username::', + 'password' => '::password::', + 'auth' => '::auth::', + 'options' => [ + 'connect_timeout' => 5, + ], + ]); + + config()->set('dynamics.connections.::other-connection::', [ + 'base_url' => '::base_url::', + 'company' => '::company::', + 'username' => '::username::', + 'password' => '::password::', + 'auth' => '::auth::', + 'options' => [ + 'connect_timeout' => 5, + ], + ]); + + config()->set('dynamics.connection', '::default::'); + } + + /** @test */ + public function it_can_get_the_default_connection(): void + { + $page = new Customer(); + + $this->assertEquals('::default::', $page->connection); + } + + /** @test */ + public function it_can_set_the_connection(): void + { + $page = new Customer('::other-connection::'); + + $this->assertEquals('::other-connection::', $page->connection); + + $page = Customer::new('::other-connection::'); + + $this->assertEquals('::other-connection::', $page->connection); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..e4c7818 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,16 @@ +