diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..410d4a1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +/.github export-ignore +/tests export-ignore +/.gitignore export-ignore +/Makefile export-ignore +/phpunit.xml.dist export-ignore diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..ef7eb61 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: [nyholm, jderusse] diff --git a/.github/workflows/.editorconfig b/.github/workflows/.editorconfig new file mode 100644 index 0000000..7bd3346 --- /dev/null +++ b/.github/workflows/.editorconfig @@ -0,0 +1,2 @@ +[*.yml] +indent_size = 2 diff --git a/.github/workflows/branch_alias.yml b/.github/workflows/branch_alias.yml new file mode 100644 index 0000000..a79ba44 --- /dev/null +++ b/.github/workflows/branch_alias.yml @@ -0,0 +1,76 @@ +name: Update branch alias + +on: + push: + tags: ['*'] + +jobs: + branch-alias: + name: Update branch alias + runs-on: ubuntu-latest + + steps: + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + coverage: none + + - name: Find branch alias + id: find_alias + run: | + TAG=$(echo $GITHUB_REF | cut -d'/' -f 3) + echo "Last tag was $TAG" + ARR=(${TAG//./ }) + ARR[1]=$((${ARR[1]}+1)) + echo ::set-output name=alias::${ARR[0]}.${ARR[1]} + + - name: Checkout main repo + run: | + git clone --branch master https://${{ secrets.BOT_GITHUB_TOKEN }}:x-oauth-basic@github.com/async-aws/aws aws + + - name: Update branch alias + run: | + cd aws/src/Service/TimestreamQuery + CURRENT_ALIAS=$(composer config extra.branch-alias.dev-master | cut -d'-' -f 1) + + # If there is a current value on the branch alias + if [ ! -z $CURRENT_ALIAS ]; then + NEW_ALIAS=${{ steps.find_alias.outputs.alias }} + CURRENT_ARR=(${CURRENT_ALIAS//./ }) + NEW_ARR=(${NEW_ALIAS//./ }) + + if [ ${CURRENT_ARR[0]} -gt ${NEW_ARR[0]} ]; then + echo "The current value for major version is larger" + exit 1; + fi + + if [ ${CURRENT_ARR[0]} -eq ${NEW_ARR[0]} ] && [ ${CURRENT_ARR[1]} -gt ${NEW_ARR[1]} ]; then + echo "The current value for minor version is larger" + exit 1; + fi + fi + + composer config extra.branch-alias.dev-master ${{ steps.find_alias.outputs.alias }}-dev + + - name: Commit & push the new files + run: | + echo "::group::git status" + cd aws + git status + echo "::endgroup::" + + git add -N . + if [[ $(git diff --numstat | wc -l) -eq 0 ]]; then + echo "No changes found. Exiting." + exit 0; + fi + + git config --local user.email "github@async-aws.com" + git config --local user.name "AsyncAws Bot" + + echo "::group::git push" + git add . + git commit -m "Update branch alias" + git push + echo "::endgroup::" diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..1a72276 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,27 @@ +name: BC Check + +on: + push: + branches: + - master + +jobs: + roave-bc-check: + name: Roave BC Check + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Modify composer.json + run: | + sed -i -re 's/"require": \{/"minimum-stability": "dev","prefer-stable": true,"require": \{/' composer.json + cat composer.json + + git config --local user.email "github@async-aws.com" + git config --local user.name "AsyncAws Bot" + git commit -am "Allow unstable dependencies" + + - name: Roave BC Check + uses: docker://nyholm/roave-bc-check-ga diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4e80845 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: Tests + +on: + push: + branches: + - master + +jobs: + + build: + name: Build + runs-on: ubuntu-latest + strategy: + max-parallel: 10 + matrix: + php: ['7.2', '7.3', '7.4', '8.0', '8.1'] + + steps: + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - name: Checkout code + uses: actions/checkout@v2 + + - name: Initialize tests + run: make initialize + + - name: Download dependencies + run: | + composer config minimum-stability dev + composer req symfony/phpunit-bridge --no-update + composer update --no-interaction --prefer-dist --optimize-autoloader --prefer-stable + + - name: Run tests + run: ./vendor/bin/simple-phpunit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ef8091 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +*.cache +composer.lock diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..457d417 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Change Log + +## NOT RELEASED + +## 0.1.0 + +First version diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..50402d9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2021 Jérémy Derussé, Tobias Nyholm + +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/Makefile b/Makefile new file mode 100644 index 0000000..850dffc --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +.EXPORT_ALL_VARIABLES: + +initialize: start-docker +start-docker: + echo "Noop" + +test: initialize + ./vendor/bin/simple-phpunit + +clean: stop-docker +stop-docker: + echo "Noop" diff --git a/README.md b/README.md new file mode 100644 index 0000000..b7ba35a --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# AsyncAws Timestream Query Client + +![](https://github.com/async-aws/timestream-query/workflows/Tests/badge.svg?branch=master) +![](https://github.com/async-aws/timestream-query/workflows/BC%20Check/badge.svg?branch=master) + +An API client for Timestream Query. + +## Install + +```cli +composer require async-aws/timestream-query +``` + +## Documentation + +See https://async-aws.com/clients/timestream-query.html for documentation. + +## Contribute + +Contributions are welcome and appreciated. Please read https://async-aws.com/contribute/ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..adbd8fa --- /dev/null +++ b/composer.json @@ -0,0 +1,34 @@ +{ + "name": "async-aws/timestream-query", + "description": "Timestream Query client, part of the AWS SDK provided by AsyncAws.", + "license": "MIT", + "type": "library", + "keywords": [ + "aws", + "amazon", + "sdk", + "async-aws", + "timestream-query" + ], + "require": { + "php": "^7.2.5 || ^8.0", + "ext-filter": "*", + "ext-json": "*", + "async-aws/core": "^1.9" + }, + "autoload": { + "psr-4": { + "AsyncAws\\TimestreamQuery\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "AsyncAws\\TimestreamQuery\\Tests\\": "tests/" + } + }, + "extra": { + "branch-alias": { + "dev-master": "0.1-dev" + } + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..9894ce3 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,23 @@ + + + + + ./src + + + + + + + + ./tests/ + + + diff --git a/src/Enum/ScalarType.php b/src/Enum/ScalarType.php new file mode 100644 index 0000000..7bc0538 --- /dev/null +++ b/src/Enum/ScalarType.php @@ -0,0 +1,38 @@ + true, + self::BOOLEAN => true, + self::DATE => true, + self::DOUBLE => true, + self::INTEGER => true, + self::INTERVAL_DAY_TO_SECOND => true, + self::INTERVAL_YEAR_TO_MONTH => true, + self::TIME => true, + self::TIMESTAMP => true, + self::UNKNOWN => true, + self::VARCHAR => true, + ][$value]); + } +} diff --git a/src/Exception/AccessDeniedException.php b/src/Exception/AccessDeniedException.php new file mode 100644 index 0000000..ea10425 --- /dev/null +++ b/src/Exception/AccessDeniedException.php @@ -0,0 +1,21 @@ +toArray(false); + + if (null !== $v = (isset($data['message']) ? (string) $data['message'] : null)) { + $this->message = $v; + } + } +} diff --git a/src/Exception/ConflictException.php b/src/Exception/ConflictException.php new file mode 100644 index 0000000..9393502 --- /dev/null +++ b/src/Exception/ConflictException.php @@ -0,0 +1,21 @@ +toArray(false); + + if (null !== $v = (isset($data['message']) ? (string) $data['message'] : null)) { + $this->message = $v; + } + } +} diff --git a/src/Exception/InternalServerException.php b/src/Exception/InternalServerException.php new file mode 100644 index 0000000..a07da2f --- /dev/null +++ b/src/Exception/InternalServerException.php @@ -0,0 +1,21 @@ +toArray(false); + + if (null !== $v = (isset($data['message']) ? (string) $data['message'] : null)) { + $this->message = $v; + } + } +} diff --git a/src/Exception/InvalidEndpointException.php b/src/Exception/InvalidEndpointException.php new file mode 100644 index 0000000..1a46949 --- /dev/null +++ b/src/Exception/InvalidEndpointException.php @@ -0,0 +1,21 @@ +toArray(false); + + if (null !== $v = (isset($data['message']) ? (string) $data['message'] : null)) { + $this->message = $v; + } + } +} diff --git a/src/Exception/QueryExecutionException.php b/src/Exception/QueryExecutionException.php new file mode 100644 index 0000000..bc86392 --- /dev/null +++ b/src/Exception/QueryExecutionException.php @@ -0,0 +1,21 @@ +toArray(false); + + if (null !== $v = (isset($data['message']) ? (string) $data['message'] : null)) { + $this->message = $v; + } + } +} diff --git a/src/Exception/ThrottlingException.php b/src/Exception/ThrottlingException.php new file mode 100644 index 0000000..5ad23e6 --- /dev/null +++ b/src/Exception/ThrottlingException.php @@ -0,0 +1,21 @@ +toArray(false); + + if (null !== $v = (isset($data['message']) ? (string) $data['message'] : null)) { + $this->message = $v; + } + } +} diff --git a/src/Exception/ValidationException.php b/src/Exception/ValidationException.php new file mode 100644 index 0000000..0d791f3 --- /dev/null +++ b/src/Exception/ValidationException.php @@ -0,0 +1,21 @@ +toArray(false); + + if (null !== $v = (isset($data['message']) ? (string) $data['message'] : null)) { + $this->message = $v; + } + } +} diff --git a/src/Input/CancelQueryRequest.php b/src/Input/CancelQueryRequest.php new file mode 100644 index 0000000..318aeeb --- /dev/null +++ b/src/Input/CancelQueryRequest.php @@ -0,0 +1,85 @@ +queryId = $input['QueryId'] ?? null; + parent::__construct($input); + } + + public static function create($input): self + { + return $input instanceof self ? $input : new self($input); + } + + public function getQueryId(): ?string + { + return $this->queryId; + } + + /** + * @internal + */ + public function request(): Request + { + // Prepare headers + $headers = [ + 'Content-Type' => 'application/x-amz-json-1.0', + 'X-Amz-Target' => 'Timestream_20181101.CancelQuery', + ]; + + // Prepare query + $query = []; + + // Prepare URI + $uriString = '/'; + + // Prepare Body + $bodyPayload = $this->requestBody(); + $body = empty($bodyPayload) ? '{}' : json_encode($bodyPayload, 4194304); + + // Return the Request + return new Request('POST', $uriString, $query, $headers, StreamFactory::create($body)); + } + + public function setQueryId(?string $value): self + { + $this->queryId = $value; + + return $this; + } + + private function requestBody(): array + { + $payload = []; + if (null === $v = $this->queryId) { + throw new InvalidArgument(sprintf('Missing parameter "QueryId" for "%s". The value cannot be null.', __CLASS__)); + } + $payload['QueryId'] = $v; + + return $payload; + } +} diff --git a/src/Input/PrepareQueryRequest.php b/src/Input/PrepareQueryRequest.php new file mode 100644 index 0000000..674cf0b --- /dev/null +++ b/src/Input/PrepareQueryRequest.php @@ -0,0 +1,111 @@ +queryString = $input['QueryString'] ?? null; + $this->validateOnly = $input['ValidateOnly'] ?? null; + parent::__construct($input); + } + + public static function create($input): self + { + return $input instanceof self ? $input : new self($input); + } + + public function getQueryString(): ?string + { + return $this->queryString; + } + + public function getValidateOnly(): ?bool + { + return $this->validateOnly; + } + + /** + * @internal + */ + public function request(): Request + { + // Prepare headers + $headers = [ + 'Content-Type' => 'application/x-amz-json-1.0', + 'X-Amz-Target' => 'Timestream_20181101.PrepareQuery', + ]; + + // Prepare query + $query = []; + + // Prepare URI + $uriString = '/'; + + // Prepare Body + $bodyPayload = $this->requestBody(); + $body = empty($bodyPayload) ? '{}' : json_encode($bodyPayload, 4194304); + + // Return the Request + return new Request('POST', $uriString, $query, $headers, StreamFactory::create($body)); + } + + public function setQueryString(?string $value): self + { + $this->queryString = $value; + + return $this; + } + + public function setValidateOnly(?bool $value): self + { + $this->validateOnly = $value; + + return $this; + } + + private function requestBody(): array + { + $payload = []; + if (null === $v = $this->queryString) { + throw new InvalidArgument(sprintf('Missing parameter "QueryString" for "%s". The value cannot be null.', __CLASS__)); + } + $payload['QueryString'] = $v; + if (null !== $v = $this->validateOnly) { + $payload['ValidateOnly'] = (bool) $v; + } + + return $payload; + } +} diff --git a/src/Input/QueryRequest.php b/src/Input/QueryRequest.php new file mode 100644 index 0000000..959549e --- /dev/null +++ b/src/Input/QueryRequest.php @@ -0,0 +1,165 @@ +queryString = $input['QueryString'] ?? null; + $this->clientToken = $input['ClientToken'] ?? null; + $this->nextToken = $input['NextToken'] ?? null; + $this->maxRows = $input['MaxRows'] ?? null; + parent::__construct($input); + } + + public static function create($input): self + { + return $input instanceof self ? $input : new self($input); + } + + public function getClientToken(): ?string + { + return $this->clientToken; + } + + public function getMaxRows(): ?int + { + return $this->maxRows; + } + + public function getNextToken(): ?string + { + return $this->nextToken; + } + + public function getQueryString(): ?string + { + return $this->queryString; + } + + /** + * @internal + */ + public function request(): Request + { + // Prepare headers + $headers = [ + 'Content-Type' => 'application/x-amz-json-1.0', + 'X-Amz-Target' => 'Timestream_20181101.Query', + ]; + + // Prepare query + $query = []; + + // Prepare URI + $uriString = '/'; + + // Prepare Body + $bodyPayload = $this->requestBody(); + $body = empty($bodyPayload) ? '{}' : json_encode($bodyPayload, 4194304); + + // Return the Request + return new Request('POST', $uriString, $query, $headers, StreamFactory::create($body)); + } + + public function setClientToken(?string $value): self + { + $this->clientToken = $value; + + return $this; + } + + public function setMaxRows(?int $value): self + { + $this->maxRows = $value; + + return $this; + } + + public function setNextToken(?string $value): self + { + $this->nextToken = $value; + + return $this; + } + + public function setQueryString(?string $value): self + { + $this->queryString = $value; + + return $this; + } + + private function requestBody(): array + { + $payload = []; + if (null === $v = $this->queryString) { + throw new InvalidArgument(sprintf('Missing parameter "QueryString" for "%s". The value cannot be null.', __CLASS__)); + } + $payload['QueryString'] = $v; + if (null === $v = $this->clientToken) { + $v = uuid_create(\UUID_TYPE_RANDOM); + } + $payload['ClientToken'] = $v; + if (null !== $v = $this->nextToken) { + $payload['NextToken'] = $v; + } + if (null !== $v = $this->maxRows) { + $payload['MaxRows'] = $v; + } + + return $payload; + } +} diff --git a/src/Result/CancelQueryResponse.php b/src/Result/CancelQueryResponse.php new file mode 100644 index 0000000..2abce6c --- /dev/null +++ b/src/Result/CancelQueryResponse.php @@ -0,0 +1,29 @@ +initialize(); + + return $this->cancellationMessage; + } + + protected function populateResult(Response $response): void + { + $data = $response->toArray(); + + $this->cancellationMessage = isset($data['CancellationMessage']) ? (string) $data['CancellationMessage'] : null; + } +} diff --git a/src/Result/PrepareQueryResponse.php b/src/Result/PrepareQueryResponse.php new file mode 100644 index 0000000..c75b9b7 --- /dev/null +++ b/src/Result/PrepareQueryResponse.php @@ -0,0 +1,140 @@ +initialize(); + + return $this->columns; + } + + /** + * @return ParameterMapping[] + */ + public function getParameters(): array + { + $this->initialize(); + + return $this->parameters; + } + + public function getQueryString(): string + { + $this->initialize(); + + return $this->queryString; + } + + protected function populateResult(Response $response): void + { + $data = $response->toArray(); + + $this->queryString = (string) $data['QueryString']; + $this->columns = $this->populateResultSelectColumnList($data['Columns']); + $this->parameters = $this->populateResultParameterMappingList($data['Parameters']); + } + + private function populateResultColumnInfo(array $json): ColumnInfo + { + return new ColumnInfo([ + 'Name' => isset($json['Name']) ? (string) $json['Name'] : null, + 'Type' => $this->populateResultType($json['Type']), + ]); + } + + /** + * @return ColumnInfo[] + */ + private function populateResultColumnInfoList(array $json): array + { + $items = []; + foreach ($json as $item) { + $items[] = $this->populateResultColumnInfo($item); + } + + return $items; + } + + private function populateResultParameterMapping(array $json): ParameterMapping + { + return new ParameterMapping([ + 'Name' => (string) $json['Name'], + 'Type' => $this->populateResultType($json['Type']), + ]); + } + + /** + * @return ParameterMapping[] + */ + private function populateResultParameterMappingList(array $json): array + { + $items = []; + foreach ($json as $item) { + $items[] = $this->populateResultParameterMapping($item); + } + + return $items; + } + + private function populateResultSelectColumn(array $json): SelectColumn + { + return new SelectColumn([ + 'Name' => isset($json['Name']) ? (string) $json['Name'] : null, + 'Type' => empty($json['Type']) ? null : $this->populateResultType($json['Type']), + 'DatabaseName' => isset($json['DatabaseName']) ? (string) $json['DatabaseName'] : null, + 'TableName' => isset($json['TableName']) ? (string) $json['TableName'] : null, + 'Aliased' => isset($json['Aliased']) ? filter_var($json['Aliased'], \FILTER_VALIDATE_BOOLEAN) : null, + ]); + } + + /** + * @return SelectColumn[] + */ + private function populateResultSelectColumnList(array $json): array + { + $items = []; + foreach ($json as $item) { + $items[] = $this->populateResultSelectColumn($item); + } + + return $items; + } + + private function populateResultType(array $json): Type + { + return new Type([ + 'ScalarType' => isset($json['ScalarType']) ? (string) $json['ScalarType'] : null, + 'ArrayColumnInfo' => empty($json['ArrayColumnInfo']) ? null : $this->populateResultColumnInfo($json['ArrayColumnInfo']), + 'TimeSeriesMeasureValueColumnInfo' => empty($json['TimeSeriesMeasureValueColumnInfo']) ? null : $this->populateResultColumnInfo($json['TimeSeriesMeasureValueColumnInfo']), + 'RowColumnInfo' => !isset($json['RowColumnInfo']) ? null : $this->populateResultColumnInfoList($json['RowColumnInfo']), + ]); + } +} diff --git a/src/Result/QueryResponse.php b/src/Result/QueryResponse.php new file mode 100644 index 0000000..f85ba75 --- /dev/null +++ b/src/Result/QueryResponse.php @@ -0,0 +1,247 @@ + + */ +class QueryResponse extends Result implements \IteratorAggregate +{ + /** + * A unique ID for the given query. + */ + private $queryId; + + /** + * A pagination token that can be used again on a `Query` call to get the next set of results. + */ + private $nextToken; + + /** + * The result set rows returned by the query. + */ + private $rows; + + /** + * The column data types of the returned result set. + */ + private $columnInfo; + + /** + * Information about the status of the query, including progress and bytes scanned. + */ + private $queryStatus; + + /** + * @return ColumnInfo[] + */ + public function getColumnInfo(): array + { + $this->initialize(); + + return $this->columnInfo; + } + + /** + * Iterates over Rows. + * + * @return \Traversable + */ + public function getIterator(): \Traversable + { + yield from $this->getRows(); + } + + public function getNextToken(): ?string + { + $this->initialize(); + + return $this->nextToken; + } + + public function getQueryId(): string + { + $this->initialize(); + + return $this->queryId; + } + + public function getQueryStatus(): ?QueryStatus + { + $this->initialize(); + + return $this->queryStatus; + } + + /** + * @param bool $currentPageOnly When true, iterates over items of the current page. Otherwise also fetch items in the next pages. + * + * @return iterable + */ + public function getRows(bool $currentPageOnly = false): iterable + { + if ($currentPageOnly) { + $this->initialize(); + yield from $this->rows; + + return; + } + + $client = $this->awsClient; + if (!$client instanceof TimestreamQueryClient) { + throw new InvalidArgument('missing client injected in paginated result'); + } + if (!$this->input instanceof QueryRequest) { + throw new InvalidArgument('missing last request injected in paginated result'); + } + $input = clone $this->input; + $page = $this; + while (true) { + $page->initialize(); + if ($page->nextToken) { + $input->setNextToken($page->nextToken); + + $this->registerPrefetch($nextPage = $client->query($input)); + } else { + $nextPage = null; + } + + yield from $page->rows; + + if (null === $nextPage) { + break; + } + + $this->unregisterPrefetch($nextPage); + $page = $nextPage; + } + } + + protected function populateResult(Response $response): void + { + $data = $response->toArray(); + + $this->queryId = (string) $data['QueryId']; + $this->nextToken = isset($data['NextToken']) ? (string) $data['NextToken'] : null; + $this->rows = $this->populateResultRowList($data['Rows']); + $this->columnInfo = $this->populateResultColumnInfoList($data['ColumnInfo']); + $this->queryStatus = empty($data['QueryStatus']) ? null : $this->populateResultQueryStatus($data['QueryStatus']); + } + + private function populateResultColumnInfo(array $json): ColumnInfo + { + return new ColumnInfo([ + 'Name' => isset($json['Name']) ? (string) $json['Name'] : null, + 'Type' => $this->populateResultType($json['Type']), + ]); + } + + /** + * @return ColumnInfo[] + */ + private function populateResultColumnInfoList(array $json): array + { + $items = []; + foreach ($json as $item) { + $items[] = $this->populateResultColumnInfo($item); + } + + return $items; + } + + private function populateResultDatum(array $json): Datum + { + return new Datum([ + 'ScalarValue' => isset($json['ScalarValue']) ? (string) $json['ScalarValue'] : null, + 'TimeSeriesValue' => !isset($json['TimeSeriesValue']) ? null : $this->populateResultTimeSeriesDataPointList($json['TimeSeriesValue']), + 'ArrayValue' => !isset($json['ArrayValue']) ? null : $this->populateResultDatumList($json['ArrayValue']), + 'RowValue' => empty($json['RowValue']) ? null : $this->populateResultRow($json['RowValue']), + 'NullValue' => isset($json['NullValue']) ? filter_var($json['NullValue'], \FILTER_VALIDATE_BOOLEAN) : null, + ]); + } + + /** + * @return Datum[] + */ + private function populateResultDatumList(array $json): array + { + $items = []; + foreach ($json as $item) { + $items[] = $this->populateResultDatum($item); + } + + return $items; + } + + private function populateResultQueryStatus(array $json): QueryStatus + { + return new QueryStatus([ + 'ProgressPercentage' => isset($json['ProgressPercentage']) ? (float) $json['ProgressPercentage'] : null, + 'CumulativeBytesScanned' => isset($json['CumulativeBytesScanned']) ? (string) $json['CumulativeBytesScanned'] : null, + 'CumulativeBytesMetered' => isset($json['CumulativeBytesMetered']) ? (string) $json['CumulativeBytesMetered'] : null, + ]); + } + + private function populateResultRow(array $json): Row + { + return new Row([ + 'Data' => $this->populateResultDatumList($json['Data']), + ]); + } + + /** + * @return Row[] + */ + private function populateResultRowList(array $json): array + { + $items = []; + foreach ($json as $item) { + $items[] = $this->populateResultRow($item); + } + + return $items; + } + + private function populateResultTimeSeriesDataPoint(array $json): TimeSeriesDataPoint + { + return new TimeSeriesDataPoint([ + 'Time' => (string) $json['Time'], + 'Value' => $this->populateResultDatum($json['Value']), + ]); + } + + /** + * @return TimeSeriesDataPoint[] + */ + private function populateResultTimeSeriesDataPointList(array $json): array + { + $items = []; + foreach ($json as $item) { + $items[] = $this->populateResultTimeSeriesDataPoint($item); + } + + return $items; + } + + private function populateResultType(array $json): Type + { + return new Type([ + 'ScalarType' => isset($json['ScalarType']) ? (string) $json['ScalarType'] : null, + 'ArrayColumnInfo' => empty($json['ArrayColumnInfo']) ? null : $this->populateResultColumnInfo($json['ArrayColumnInfo']), + 'TimeSeriesMeasureValueColumnInfo' => empty($json['TimeSeriesMeasureValueColumnInfo']) ? null : $this->populateResultColumnInfo($json['TimeSeriesMeasureValueColumnInfo']), + 'RowColumnInfo' => !isset($json['RowColumnInfo']) ? null : $this->populateResultColumnInfoList($json['RowColumnInfo']), + ]); + } +} diff --git a/src/TimestreamQueryClient.php b/src/TimestreamQueryClient.php new file mode 100644 index 0000000..b5e0057 --- /dev/null +++ b/src/TimestreamQueryClient.php @@ -0,0 +1,153 @@ +getResponse($input->request(), new RequestContext(['operation' => 'CancelQuery', 'region' => $input->getRegion(), 'exceptionMapping' => [ + 'AccessDeniedException' => AccessDeniedException::class, + 'InternalServerException' => InternalServerException::class, + 'ThrottlingException' => ThrottlingException::class, + 'ValidationException' => ValidationException::class, + 'InvalidEndpointException' => InvalidEndpointException::class, + ]])); + + return new CancelQueryResponse($response); + } + + /** + * A synchronous operation that allows you to submit a query with parameters to be stored by Timestream for later + * running. Timestream only supports using this operation with the `PrepareQueryRequest$ValidateOnly` set to `true`. + * + * @see https://docs.aws.amazon.com/timestream/latest/developerguide/API_Operations_Amazon_Timestream_Query.html/API_PrepareQuery.html + * @see https://docs.aws.amazon.com/aws-sdk-php/v3/api/api-query.timestream-2018-11-01.html#preparequery + * + * @param array{ + * QueryString: string, + * ValidateOnly?: bool, + * @region?: string, + * }|PrepareQueryRequest $input + * + * @throws AccessDeniedException + * @throws InternalServerException + * @throws ThrottlingException + * @throws ValidationException + * @throws InvalidEndpointException + */ + public function prepareQuery($input): PrepareQueryResponse + { + $input = PrepareQueryRequest::create($input); + $response = $this->getResponse($input->request(), new RequestContext(['operation' => 'PrepareQuery', 'region' => $input->getRegion(), 'exceptionMapping' => [ + 'AccessDeniedException' => AccessDeniedException::class, + 'InternalServerException' => InternalServerException::class, + 'ThrottlingException' => ThrottlingException::class, + 'ValidationException' => ValidationException::class, + 'InvalidEndpointException' => InvalidEndpointException::class, + ]])); + + return new PrepareQueryResponse($response); + } + + /** + * `Query` is a synchronous operation that enables you to run a query against your Amazon Timestream data. `Query` will + * time out after 60 seconds. You must update the default timeout in the SDK to support a timeout of 60 seconds. See the + * code sample for details. + * + * @see https://docs.aws.amazon.com/timestream/latest/developerguide/code-samples.run-query.html + * @see https://docs.aws.amazon.com/timestream/latest/developerguide/API_Operations_Amazon_Timestream_Query.html/API_Query.html + * @see https://docs.aws.amazon.com/aws-sdk-php/v3/api/api-query.timestream-2018-11-01.html#query + * + * @param array{ + * QueryString: string, + * ClientToken?: string, + * NextToken?: string, + * MaxRows?: int, + * @region?: string, + * }|QueryRequest $input + * + * @throws AccessDeniedException + * @throws ConflictException + * @throws InternalServerException + * @throws QueryExecutionException + * @throws ThrottlingException + * @throws ValidationException + * @throws InvalidEndpointException + */ + public function query($input): QueryResponse + { + $input = QueryRequest::create($input); + $response = $this->getResponse($input->request(), new RequestContext(['operation' => 'Query', 'region' => $input->getRegion(), 'exceptionMapping' => [ + 'AccessDeniedException' => AccessDeniedException::class, + 'ConflictException' => ConflictException::class, + 'InternalServerException' => InternalServerException::class, + 'QueryExecutionException' => QueryExecutionException::class, + 'ThrottlingException' => ThrottlingException::class, + 'ValidationException' => ValidationException::class, + 'InvalidEndpointException' => InvalidEndpointException::class, + ]])); + + return new QueryResponse($response, $this, $input); + } + + protected function getAwsErrorFactory(): AwsErrorFactoryInterface + { + return new JsonRpcAwsErrorFactory(); + } + + protected function getEndpointMetadata(?string $region): array + { + if (null === $region) { + $region = Configuration::DEFAULT_REGION; + } + + return [ + 'endpoint' => "https://query.timestream.$region.amazonaws.com", + 'signRegion' => $region, + 'signService' => 'timestream', + 'signVersions' => ['v4'], + ]; + } +} diff --git a/src/ValueObject/ColumnInfo.php b/src/ValueObject/ColumnInfo.php new file mode 100644 index 0000000..2d87fd3 --- /dev/null +++ b/src/ValueObject/ColumnInfo.php @@ -0,0 +1,48 @@ +name = $input['Name'] ?? null; + $this->type = isset($input['Type']) ? Type::create($input['Type']) : null; + } + + public static function create($input): self + { + return $input instanceof self ? $input : new self($input); + } + + public function getName(): ?string + { + return $this->name; + } + + public function getType(): Type + { + return $this->type; + } +} diff --git a/src/ValueObject/Datum.php b/src/ValueObject/Datum.php new file mode 100644 index 0000000..11e778d --- /dev/null +++ b/src/ValueObject/Datum.php @@ -0,0 +1,88 @@ +scalarValue = $input['ScalarValue'] ?? null; + $this->timeSeriesValue = isset($input['TimeSeriesValue']) ? array_map([TimeSeriesDataPoint::class, 'create'], $input['TimeSeriesValue']) : null; + $this->arrayValue = isset($input['ArrayValue']) ? array_map([Datum::class, 'create'], $input['ArrayValue']) : null; + $this->rowValue = isset($input['RowValue']) ? Row::create($input['RowValue']) : null; + $this->nullValue = $input['NullValue'] ?? null; + } + + public static function create($input): self + { + return $input instanceof self ? $input : new self($input); + } + + /** + * @return Datum[] + */ + public function getArrayValue(): array + { + return $this->arrayValue ?? []; + } + + public function getNullValue(): ?bool + { + return $this->nullValue; + } + + public function getRowValue(): ?Row + { + return $this->rowValue; + } + + public function getScalarValue(): ?string + { + return $this->scalarValue; + } + + /** + * @return TimeSeriesDataPoint[] + */ + public function getTimeSeriesValue(): array + { + return $this->timeSeriesValue ?? []; + } +} diff --git a/src/ValueObject/ParameterMapping.php b/src/ValueObject/ParameterMapping.php new file mode 100644 index 0000000..276ca33 --- /dev/null +++ b/src/ValueObject/ParameterMapping.php @@ -0,0 +1,43 @@ +name = $input['Name'] ?? null; + $this->type = isset($input['Type']) ? Type::create($input['Type']) : null; + } + + public static function create($input): self + { + return $input instanceof self ? $input : new self($input); + } + + public function getName(): string + { + return $this->name; + } + + public function getType(): Type + { + return $this->type; + } +} diff --git a/src/ValueObject/QueryStatus.php b/src/ValueObject/QueryStatus.php new file mode 100644 index 0000000..7356afa --- /dev/null +++ b/src/ValueObject/QueryStatus.php @@ -0,0 +1,61 @@ +progressPercentage = $input['ProgressPercentage'] ?? null; + $this->cumulativeBytesScanned = $input['CumulativeBytesScanned'] ?? null; + $this->cumulativeBytesMetered = $input['CumulativeBytesMetered'] ?? null; + } + + public static function create($input): self + { + return $input instanceof self ? $input : new self($input); + } + + public function getCumulativeBytesMetered(): ?string + { + return $this->cumulativeBytesMetered; + } + + public function getCumulativeBytesScanned(): ?string + { + return $this->cumulativeBytesScanned; + } + + public function getProgressPercentage(): ?float + { + return $this->progressPercentage; + } +} diff --git a/src/ValueObject/Row.php b/src/ValueObject/Row.php new file mode 100644 index 0000000..d6f661f --- /dev/null +++ b/src/ValueObject/Row.php @@ -0,0 +1,37 @@ +data = isset($input['Data']) ? array_map([Datum::class, 'create'], $input['Data']) : null; + } + + public static function create($input): self + { + return $input instanceof self ? $input : new self($input); + } + + /** + * @return Datum[] + */ + public function getData(): array + { + return $this->data ?? []; + } +} diff --git a/src/ValueObject/SelectColumn.php b/src/ValueObject/SelectColumn.php new file mode 100644 index 0000000..b8f7f5a --- /dev/null +++ b/src/ValueObject/SelectColumn.php @@ -0,0 +1,79 @@ +name = $input['Name'] ?? null; + $this->type = isset($input['Type']) ? Type::create($input['Type']) : null; + $this->databaseName = $input['DatabaseName'] ?? null; + $this->tableName = $input['TableName'] ?? null; + $this->aliased = $input['Aliased'] ?? null; + } + + public static function create($input): self + { + return $input instanceof self ? $input : new self($input); + } + + public function getAliased(): ?bool + { + return $this->aliased; + } + + public function getDatabaseName(): ?string + { + return $this->databaseName; + } + + public function getName(): ?string + { + return $this->name; + } + + public function getTableName(): ?string + { + return $this->tableName; + } + + public function getType(): ?Type + { + return $this->type; + } +} diff --git a/src/ValueObject/TimeSeriesDataPoint.php b/src/ValueObject/TimeSeriesDataPoint.php new file mode 100644 index 0000000..404bd74 --- /dev/null +++ b/src/ValueObject/TimeSeriesDataPoint.php @@ -0,0 +1,48 @@ +time = $input['Time'] ?? null; + $this->value = isset($input['Value']) ? Datum::create($input['Value']) : null; + } + + public static function create($input): self + { + return $input instanceof self ? $input : new self($input); + } + + public function getTime(): string + { + return $this->time; + } + + public function getValue(): Datum + { + return $this->value; + } +} diff --git a/src/ValueObject/Type.php b/src/ValueObject/Type.php new file mode 100644 index 0000000..52088ad --- /dev/null +++ b/src/ValueObject/Type.php @@ -0,0 +1,75 @@ +scalarType = $input['ScalarType'] ?? null; + $this->arrayColumnInfo = isset($input['ArrayColumnInfo']) ? ColumnInfo::create($input['ArrayColumnInfo']) : null; + $this->timeSeriesMeasureValueColumnInfo = isset($input['TimeSeriesMeasureValueColumnInfo']) ? ColumnInfo::create($input['TimeSeriesMeasureValueColumnInfo']) : null; + $this->rowColumnInfo = isset($input['RowColumnInfo']) ? array_map([ColumnInfo::class, 'create'], $input['RowColumnInfo']) : null; + } + + public static function create($input): self + { + return $input instanceof self ? $input : new self($input); + } + + public function getArrayColumnInfo(): ?ColumnInfo + { + return $this->arrayColumnInfo; + } + + /** + * @return ColumnInfo[] + */ + public function getRowColumnInfo(): array + { + return $this->rowColumnInfo ?? []; + } + + /** + * @return ScalarType::*|null + */ + public function getScalarType(): ?string + { + return $this->scalarType; + } + + public function getTimeSeriesMeasureValueColumnInfo(): ?ColumnInfo + { + return $this->timeSeriesMeasureValueColumnInfo; + } +} diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/tests/Integration/TimestreamQueryClientTest.php b/tests/Integration/TimestreamQueryClientTest.php new file mode 100644 index 0000000..ddd1a58 --- /dev/null +++ b/tests/Integration/TimestreamQueryClientTest.php @@ -0,0 +1,80 @@ +getClient(); + + $input = new CancelQueryRequest([ + 'QueryId' => 'change me', + ]); + $result = $client->cancelQuery($input); + + $result->resolve(); + + self::assertSame('changeIt', $result->getCancellationMessage()); + } + + public function testPrepareQuery(): void + { + self::markTestIncomplete('Cannot test without support for timestream.'); + + $client = $this->getClient(); + + $input = new PrepareQueryRequest([ + 'QueryString' => 'change me', + 'ValidateOnly' => false, + ]); + $result = $client->prepareQuery($input); + + $result->resolve(); + + self::assertSame('changeIt', $result->getQueryString()); + // self::assertTODO(expected, $result->getColumns()); + // self::assertTODO(expected, $result->getParameters()); + } + + public function testQuery(): void + { + self::markTestIncomplete('Cannot test without support for timestream.'); + + $client = $this->getClient(); + + $input = new QueryRequest([ + 'QueryString' => 'change me', + 'ClientToken' => 'change me', + 'NextToken' => 'change me', + 'MaxRows' => 1337, + ]); + $result = $client->query($input); + + $result->resolve(); + + self::assertSame('changeIt', $result->getQueryId()); + self::assertSame('changeIt', $result->getNextToken()); + // self::assertTODO(expected, $result->getRows()); + // self::assertTODO(expected, $result->getColumnInfo()); + // self::assertTODO(expected, $result->getQueryStatus()); + } + + private function getClient(): TimestreamQueryClient + { + self::fail('Not implemented'); + + return new TimestreamQueryClient([ + 'endpoint' => 'http://localhost', + ], new NullProvider()); + } +} diff --git a/tests/Unit/Input/CancelQueryRequestTest.php b/tests/Unit/Input/CancelQueryRequestTest.php new file mode 100644 index 0000000..4b87c8c --- /dev/null +++ b/tests/Unit/Input/CancelQueryRequestTest.php @@ -0,0 +1,29 @@ + 'qwertyuiop', + ]); + + // see https://docs.aws.amazon.com/timestream/latest/developerguide/API_Operations_Amazon_Timestream_Query.html/API_CancelQuery.html + $expected = ' + POST / HTTP/1.0 + Content-Type: application/x-amz-json-1.0 + x-amz-target: Timestream_20181101.CancelQuery + + { + "QueryId": "qwertyuiop" + } + '; + + self::assertRequestEqualsHttpRequest($expected, $input->request()); + } +} diff --git a/tests/Unit/Input/PrepareQueryRequestTest.php b/tests/Unit/Input/PrepareQueryRequestTest.php new file mode 100644 index 0000000..fcdd603 --- /dev/null +++ b/tests/Unit/Input/PrepareQueryRequestTest.php @@ -0,0 +1,31 @@ + 'SELECT * FROM db.tbl ORDER BY time DESC LIMIT 10', + 'ValidateOnly' => true, + ]); + + // see https://docs.aws.amazon.com/timestream/latest/developerguide/API_Operations_Amazon_Timestream_Query.html/API_PrepareQuery.html + $expected = ' + POST / HTTP/1.0 + Content-Type: application/x-amz-json-1.0 + x-amz-target: Timestream_20181101.PrepareQuery + + { + "QueryString": "SELECT * FROM db.tbl ORDER BY time DESC LIMIT 10", + "ValidateOnly": true + } + '; + + self::assertRequestEqualsHttpRequest($expected, $input->request()); + } +} diff --git a/tests/Unit/Input/QueryRequestTest.php b/tests/Unit/Input/QueryRequestTest.php new file mode 100644 index 0000000..82b2cde --- /dev/null +++ b/tests/Unit/Input/QueryRequestTest.php @@ -0,0 +1,31 @@ + 'qwertyuiop', + 'QueryString' => 'SELECT * FROM db.tbl ORDER BY time DESC LIMIT 10', + ]); + + // see https://docs.aws.amazon.com/timestream/latest/developerguide/API_Operations_Amazon_Timestream_Query.html/API_Query.html + $expected = ' + POST / HTTP/1.0 + Content-Type: application/x-amz-json-1.0 + x-amz-target: Timestream_20181101.Query + + { + "ClientToken": "qwertyuiop", + "QueryString": "SELECT * FROM db.tbl ORDER BY time DESC LIMIT 10" + } + '; + + self::assertRequestEqualsHttpRequest($expected, $input->request()); + } +} diff --git a/tests/Unit/Result/CancelQueryResponseTest.php b/tests/Unit/Result/CancelQueryResponseTest.php new file mode 100644 index 0000000..177c48c --- /dev/null +++ b/tests/Unit/Result/CancelQueryResponseTest.php @@ -0,0 +1,26 @@ +request('POST', 'http://localhost'), $client, new NullLogger())); + + self::assertSame('Query cancelled.', $result->getCancellationMessage()); + } +} diff --git a/tests/Unit/Result/PrepareQueryResponseTest.php b/tests/Unit/Result/PrepareQueryResponseTest.php new file mode 100644 index 0000000..7924008 --- /dev/null +++ b/tests/Unit/Result/PrepareQueryResponseTest.php @@ -0,0 +1,50 @@ +request('POST', 'http://localhost'), $client, new NullLogger())); + + self::assertCount(1, $result->getColumns()); + self::assertInstanceOf(SelectColumn::class, $result->getColumns()[0]); + self::assertFalse($result->getColumns()[0]->getAliased()); + self::assertSame('db', $result->getColumns()[0]->getDatabaseName()); + self::assertSame('foo', $result->getColumns()[0]->getName()); + self::assertSame('tbl', $result->getColumns()[0]->getTableName()); + self::assertInstanceOf(Type::class, $result->getColumns()[0]->getType()); + self::assertSame(ScalarType::VARCHAR, $result->getColumns()[0]->getType()->getScalarType()); + self::assertSame([], $result->getParameters()); + self::assertSame('query string', $result->getQueryString()); + } +} diff --git a/tests/Unit/Result/QueryResponseTest.php b/tests/Unit/Result/QueryResponseTest.php new file mode 100644 index 0000000..058bb20 --- /dev/null +++ b/tests/Unit/Result/QueryResponseTest.php @@ -0,0 +1,73 @@ +request('POST', 'http://localhost'), $client, new NullLogger()), new TimestreamQueryClient(), new QueryRequest([])); + + self::assertCount(1, $result->getColumnInfo()); + self::assertInstanceOf(ColumnInfo::class, $result->getColumnInfo()[0]); + self::assertSame('foo', $result->getColumnInfo()[0]->getName()); + self::assertInstanceOf(Type::class, $result->getColumnInfo()[0]->getType()); + self::assertSame(ScalarType::VARCHAR, $result->getColumnInfo()[0]->getType()->getScalarType()); + self::assertSame('qwertyuiop', $result->getQueryId()); + self::assertInstanceOf(QueryStatus::class, $result->getQueryStatus()); + self::assertSame('1024', $result->getQueryStatus()->getCumulativeBytesMetered()); + self::assertSame('800', $result->getQueryStatus()->getCumulativeBytesScanned()); + self::assertSame(1.0, $result->getQueryStatus()->getProgressPercentage()); + + $rows = iterator_to_array($result->getRows(true)); + + self::assertCount(1, $rows); + self::assertInstanceOf(Row::class, $rows[0]); + self::assertCount(1, $rows[0]->getData()); + self::assertInstanceOf(Datum::class, $rows[0]->getData()[0]); + self::assertSame('datum', $rows[0]->getData()[0]->getScalarValue()); + } +} diff --git a/tests/Unit/TimestreamQueryClientTest.php b/tests/Unit/TimestreamQueryClientTest.php new file mode 100644 index 0000000..b7a9877 --- /dev/null +++ b/tests/Unit/TimestreamQueryClientTest.php @@ -0,0 +1,60 @@ + 'qwertyuiop', + ]); + $result = $client->cancelQuery($input); + + self::assertInstanceOf(CancelQueryResponse::class, $result); + self::assertFalse($result->info()['resolved']); + } + + public function testPrepareQuery(): void + { + $client = new TimestreamQueryClient([], new NullProvider(), new MockHttpClient()); + + $input = new PrepareQueryRequest([ + 'QueryString' => 'SELECT * FROM db.tbl ORDER BY time DESC LIMIT 10', + 'ValidateOnly' => true, + + ]); + $result = $client->prepareQuery($input); + + self::assertInstanceOf(PrepareQueryResponse::class, $result); + self::assertFalse($result->info()['resolved']); + } + + public function testQuery(): void + { + $client = new TimestreamQueryClient([], new NullProvider(), new MockHttpClient()); + + $input = new QueryRequest([ + 'ClientToken' => 'qwertyuiop', + 'QueryString' => 'SELECT * FROM db.tbl ORDER BY time DESC LIMIT 10', + + ]); + $result = $client->query($input); + + self::assertInstanceOf(QueryResponse::class, $result); + self::assertFalse($result->info()['resolved']); + } +}