diff --git a/.env.dist b/.env.dist new file mode 100644 index 0000000..ad1ae3a --- /dev/null +++ b/.env.dist @@ -0,0 +1,4 @@ +# Minimal setup for getting Query API Client +TESTS_QUERY_API_URL= +TESTS_STORAGE_API_TOKEN= +TESTS_STORAGE_API_URL= diff --git a/.github/workflows/branch.yml b/.github/workflows/branch.yml new file mode 100644 index 0000000..9cae8b2 --- /dev/null +++ b/.github/workflows/branch.yml @@ -0,0 +1,28 @@ +name: Build + +on: + push: + branches-ignore: + - main + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Build development container + run: docker compose build dev + + - name: Install dependencies + run: docker compose run --rm dev composer install + + - name: Run tests and CI checks + run: | + docker compose run --rm \ + -e TESTS_QUERY_API_URL=${{ vars.TESTS_QUERY_API_URL }} \ + -e TESTS_STORAGE_API_TOKEN=${{ secrets.TESTS_STORAGE_API_TOKEN }} \ + -e TESTS_STORAGE_API_URL=${{ vars.TESTS_STORAGE_API_URL }} \ + dev composer ci diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8773461 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Dependencies +/vendor/ + +# Environment files (contains sensitive information) +.env + +# PHPUnit +.phpunit.result.cache +/build + +# Coverage reports +/coverage/ + +# IDE files +.vscode/ +.idea/ +.claude/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7335b4f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM php:8.4 + +ENV COMPOSER_ALLOW_SUPERUSER 1 +ARG COMPOSER_FLAGS="--prefer-dist --no-interaction --classmap-authoritative --no-scripts" + +RUN apt-get update -q \ + && apt-get install git unzip \ + -y --no-install-recommends + +RUN echo "memory_limit = -1" >> /usr/local/etc/php/php.ini +RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin/ --filename=composer + +WORKDIR /code/ + +COPY composer.json . +RUN composer install $COMPOSER_FLAGS --no-scripts --no-autoloader +COPY . . +RUN composer install $COMPOSER_FLAGS diff --git a/README.md b/README.md index ff9be34..dc0fb0b 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,114 @@ -# Template +# Keboola Query Service API PHP Client + +[![Build](https://github.com/keboola/query-api-php-client/actions/workflows/branch.yml/badge.svg)](https://github.com/keboola/query-api-php-client/actions/workflows/branch.yml) + +PHP client for Keboola Query Service API. ## Installation -Clone this repository +```shell +composer require keboola/query-api-php-client +``` + +## Usage + +```php + 'https://query.keboola.com', + 'token' => 'your-storage-api-token' +]); + +// Submit a query job +$response = $client->submitQueryJob('main', 'workspace-123', [ + 'statements' => ['SELECT * FROM table1'], + 'transactional' => true +]); + +$queryJobId = $response['queryJobId']; + +// Get job status +$status = $client->getJobStatus($queryJobId); + +// Get job results +$results = $client->getJobResults($queryJobId, $statementId); + +// Cancel job +$client->cancelJob($queryJobId, ['reason' => 'User requested cancellation']); + +// Health check +$health = $client->healthCheck(); +``` + +## Configuration Options + +The client constructor accepts the following configuration options: + +- `url` (required): Query Service API URL (e.g., `https://query.keboola.com`) +- `token` (required): Storage API token +- `backoffMaxTries` (optional): Number of retry attempts for failed requests (default: 3) +- `userAgent` (optional): Additional user agent string to append +- `handler` (optional): Custom Guzzle handler stack + +**Note**: The `healthCheck()` endpoint does not require authentication and will work without a valid token. + +## API Methods + +- `submitQueryJob(string $branchId, string $workspaceId, array $requestBody): array` +- `getJobStatus(string $queryJobId): array` +- `getJobResults(string $queryJobId, string $statementId): array` +- `cancelJob(string $queryJobId, array $requestBody = []): array` +- `healthCheck(): array` + +## Development + +### Running Tests + +#### Unit Tests +Run unit tests: +```shell +vendor/bin/phpunit tests/ClientTest.php +``` + +#### Functional Tests +Functional tests require environment variables to be set: + +- `TESTS_STORAGE_API_TOKEN` - Storage API authentication token +- `TESTS_QUERY_API_URL` - Query Service API endpoint URL +- `TESTS_STORAGE_API_URL` - Storage API endpoint URL + +Run functional tests: +```shell +vendor/bin/phpunit tests/Functional/ +``` + +#### All Tests +Run all tests: +```shell +composer run tests +``` + +### Code Quality + +Run code style check: +```shell +composer run phpcs +``` + +Fix code style issues: +```shell +composer run phpcbf +``` -## License +Run static analysis: +```shell +composer run phpstan +``` -MIT licensed, see [LICENSE](./LICENSE) file. +Run all CI checks. Check [Github Workflows](./.github/workflows) for more details +```shell +composer run ci +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..f36c776 --- /dev/null +++ b/composer.json @@ -0,0 +1,51 @@ +{ + "name": "keboola/query-api-php-client", + "description": "Keboola Query Service API PHP Client", + "homepage": "https://keboola.com", + "license": "MIT", + "config": { + "lock": false, + "optimize-autoloader": true, + "sort-packages": true, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + }, + "autoload": { + "psr-4": { + "Keboola\\QueryApi\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Keboola\\QueryApi\\Tests\\": "tests/" + } + }, + "require": { + "php": "^8.4", + "ext-json": "*", + "guzzlehttp/guzzle": "~7.0", + "psr/log": "~1.0" + }, + "require-dev": { + "keboola/coding-standard": "^16.0", + "keboola/storage-api-client": "^18.0", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "^3", + "symfony/dotenv": "^6.0" + }, + "scripts": { + "phpcs": "phpcs -n src tests", + "phpcbf": "phpcbf -n src tests", + "phpstan": "phpstan analyse --no-progress --level=max src tests -c phpstan.neon", + "tests": "phpunit --coverage-clover build/logs/clover.xml --coverage-xml=build/logs/coverage-xml --log-junit=build/logs/phpunit.junit.xml", + "ci": [ + "@composer validate --no-check-all --strict", + "@phpcs", + "@phpstan", + "@tests" + ] + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..88ec0d0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + dev: + image: keboola/query-api-php-client + build: . + volumes: + - ./:/code + working_dir: /code + command: bash + environment: + - TESTS_QUERY_API_URL + - TESTS_STORAGE_API_TOKEN + - TESTS_STORAGE_API_URL diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..513fae5 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,9 @@ + + + The Keboola coding standard. + + src + tests + + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..1bf460a --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,9 @@ +parameters: + level: max + paths: + - src + - tests + ignoreErrors: + - + identifier: argument.type + path: tests/ClientTest.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..a8eac34 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,29 @@ + + + + + tests + tests/Functional + + + tests/Functional + + + + + + src + + + \ No newline at end of file diff --git a/src/Client.php b/src/Client.php new file mode 100644 index 0000000..b3b1c25 --- /dev/null +++ b/src/Client.php @@ -0,0 +1,272 @@ +apiUrl = rtrim($config['url'], '/'); + $this->tokenString = $config['token']; + $this->backoffMaxTries = $config['backoffMaxTries'] ?? self::DEFAULT_BACKOFF_RETRIES; + $this->userAgent = self::DEFAULT_USER_AGENT; + + if (isset($config['userAgent'])) { + $this->userAgent .= ' ' . $config['userAgent']; + } + + $this->initClient($config); + } + + /** + * @param array{handler?: HandlerStack} $config + */ + private function initClient(array $config): void + { + $handlerStack = $config['handler'] ?? HandlerStack::create(); + $handlerStack->push(Middleware::retry($this->createRetryDecider(), $this->createRetryDelay())); + + // Add request mapping middleware for headers + $handlerStack->push(Middleware::mapRequest( + function (RequestInterface $request) { + return $this->addRequestHeaders($request); + }, + )); + + $this->client = new GuzzleClient([ + 'base_uri' => $this->apiUrl, + 'handler' => $handlerStack, + 'connect_timeout' => self::GUZZLE_CONNECT_TIMEOUT_SECONDS, + 'timeout' => self::GUZZLE_TIMEOUT_SECONDS, + ]); + } + + private function createRetryDecider(): callable + { + return function ( + int $retries, + RequestInterface $request, + ?ResponseInterface $response = null, + ?Throwable $exception = null, + ): bool { + if ($retries >= $this->backoffMaxTries) { + return false; + } + + if ($exception instanceof ConnectException) { + return true; + } + + if ($response && $response->getStatusCode() >= 500) { + return true; + } + + return false; + }; + } + + private function createRetryDelay(): callable + { + return function (int $numberOfRetries): int { + return 1000 * (2 ** $numberOfRetries); + }; + } + + /** + * Add request headers with selective authentication + */ + private function addRequestHeaders(RequestInterface $request): RequestInterface + { + $path = $request->getUri()->getPath(); + + // Start with base headers that all requests need + $baseRequest = $request + ->withHeader('User-Agent', $this->userAgent) + ->withHeader('Content-Type', 'application/json'); + + // Skip authentication for health-check endpoints + if (str_contains($path, '/health-check')) { + return $baseRequest; + } + + // Add Storage API token for all other endpoints that require authentication + return $baseRequest->withHeader('X-StorageAPI-Token', $this->tokenString); + } + + /** + * Submit a new query job + * + * @param array{statements: string[], transactional?: bool} $requestBody + * @return array + */ + public function submitQueryJob(string $branchId, string $workspaceId, array $requestBody): array + { + $url = sprintf('/api/v1/branches/%s/workspaces/%s/queries', $branchId, $workspaceId); + return $this->sendRequest('POST', $url, $requestBody); + } + + /** + * Get job status + * + * @return array + */ + public function getJobStatus(string $queryJobId): array + { + $url = sprintf('/api/v1/queries/%s', $queryJobId); + return $this->sendRequest('GET', $url); + } + + /** + * Cancel a job + * + * @param array{reason?: string} $requestBody + * @return array + */ + public function cancelJob(string $queryJobId, array $requestBody = []): array + { + $url = sprintf('/api/v1/queries/%s/cancel', $queryJobId); + return $this->sendRequest('POST', $url, $requestBody); + } + + /** + * Get job results + * + * @return array + */ + public function getJobResults(string $queryJobId, string $statementId): array + { + $url = sprintf('/api/v1/queries/%s/%s/results', $queryJobId, $statementId); + return $this->sendRequest('GET', $url); + } + + /** + * Health check + * + * @return array + */ + public function healthCheck(): array + { + return $this->sendRequest('GET', '/health-check'); + } + + /** + * @param array|null $requestBody + * @return array + */ + private function sendRequest(string $method, string $url, ?array $requestBody = null): array + { + $options = []; + + if ($requestBody !== null) { + try { + $options['body'] = json_encode($requestBody, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new ClientException('Failed to encode request body as JSON: ' . $e->getMessage(), 0, $e); + } + } + + try { + $response = $this->client->request($method, $url, $options); + } catch (GuzzleException $e) { + $this->handleGuzzleException($e); + throw new ClientException('Request failed after exception handling'); + } + + return $this->parseResponse($response); + } + + /** + * @return array + */ + private function parseResponse(ResponseInterface $response): array + { + $body = (string) $response->getBody(); + + if (empty($body)) { + return []; + } + + try { + $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new ClientException('Response is not valid JSON: ' . $e->getMessage(), 0, $e); + } + + if (!is_array($data)) { + throw new ClientException('Response is not a JSON object'); + } + + return $data; + } + + /** + * @throws ClientException + */ + private function handleGuzzleException(GuzzleException $e): void + { + if ($e instanceof GuzzleClientException && $e->hasResponse()) { + $response = $e->getResponse(); + $statusCode = $response->getStatusCode(); + $body = (string) $response->getBody(); + + try { + $errorData = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + $errorData = null; + } + + $message = is_array($errorData) && isset($errorData['exception']) && is_string($errorData['exception']) + ? $errorData['exception'] + : $e->getMessage(); + $contextData = is_array($errorData) ? $errorData : null; + throw new ClientException($message, $statusCode, $e, $contextData); + } + + if ($e instanceof ConnectException) { + throw new ClientException('Unable to connect to Query Service API: ' . $e->getMessage(), 0, $e); + } + + throw new ClientException('Query Service API request failed: ' . $e->getMessage(), 0, $e); + } +} diff --git a/src/ClientException.php b/src/ClientException.php new file mode 100644 index 0000000..f4f1b28 --- /dev/null +++ b/src/ClientException.php @@ -0,0 +1,32 @@ +|null + */ + private ?array $contextData; + + /** + * @param array|null $contextData + */ + public function __construct(string $message, int $code = 0, ?Throwable $previous = null, ?array $contextData = null) + { + parent::__construct($message, $code, $previous); + $this->contextData = $contextData; + } + + /** + * @return array|null + */ + public function getContextData(): ?array + { + return $this->contextData; + } +} diff --git a/src/Exception.php b/src/Exception.php new file mode 100644 index 0000000..5fc18b6 --- /dev/null +++ b/src/Exception.php @@ -0,0 +1,11 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('url must be set'); + + new Client([ + 'token' => 'test-token', + ]); + } + + public function testConstructorRequiresToken(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('token must be set'); + + new Client([ + 'url' => 'https://test.keboola.com', + ]); + } + + public function testSubmitQueryJob(): void + { + $mockHandler = new MockHandler([ + new Response(201, [], json_encode(['queryJobId' => 'job-12345']) ?: ''), + ]); + + $client = $this->createClientWithMockHandler($mockHandler); + + $result = $client->submitQueryJob('main', 'workspace-123', [ + 'statements' => ['SELECT * FROM table1'], + 'transactional' => true, + ]); + + $this->assertEquals(['queryJobId' => 'job-12345'], $result); + } + + public function testGetJobStatus(): void + { + $mockHandler = new MockHandler([ + new Response(200, [], json_encode([ + 'queryJobId' => 'job-12345', + 'status' => 'running', + 'statements' => [], + ]) ?: ''), + ]); + + $client = $this->createClientWithMockHandler($mockHandler); + + $result = $client->getJobStatus('job-12345'); + + $this->assertEquals('job-12345', $result['queryJobId']); + $this->assertEquals('running', $result['status']); + } + + public function testCancelJob(): void + { + $mockHandler = new MockHandler([ + new Response(200, [], json_encode(['queryJobId' => 'job-12345']) ?: ''), + ]); + + $client = $this->createClientWithMockHandler($mockHandler); + + $result = $client->cancelJob('job-12345', ['reason' => 'User requested']); + + $this->assertEquals(['queryJobId' => 'job-12345'], $result); + } + + public function testGetJobResults(): void + { + $mockHandler = new MockHandler([ + new Response(200, [], json_encode([ + 'data' => [['id' => 1, 'name' => 'test']], + 'status' => 'completed', + 'rowsAffected' => 1, + ]) ?: ''), + ]); + + $client = $this->createClientWithMockHandler($mockHandler); + + $result = $client->getJobResults('job-12345', 'stmt-67890'); + + $this->assertEquals('completed', $result['status']); + $this->assertEquals(1, $result['rowsAffected']); + assert(is_array($result['data'])); + $this->assertCount(1, $result['data']); + } + + public function testHealthCheck(): void + { + $mockHandler = new MockHandler([ + new Response(200, [], json_encode([ + 'service' => 'query', + 'status' => 'ok', + 'timestamp' => '2024-01-01T00:00:00Z', + 'version' => '1.0.0', + ]) ?: ''), + ]); + + $client = $this->createClientWithMockHandler($mockHandler); + + $result = $client->healthCheck(); + + $this->assertEquals('query', $result['service']); + $this->assertEquals('ok', $result['status']); + } + + public function testHealthCheckWithInvalidToken(): void + { + // Health check should work even with invalid token since no auth is required + $mockHandler = new MockHandler([ + new Response(200, [], json_encode([ + 'service' => 'query', + 'status' => 'ok', + 'timestamp' => '2024-01-01T00:00:00Z', + 'version' => '1.0.0', + ]) ?: ''), + ]); + + // Create client with completely invalid token + $handlerStack = HandlerStack::create($mockHandler); + $client = new Client([ + 'url' => 'https://query.test.keboola.com', + 'token' => 'completely-invalid-token-that-would-fail-auth', + 'handler' => $handlerStack, + ]); + + // Health check should succeed because no token is sent + $result = $client->healthCheck(); + + $this->assertEquals('query', $result['service']); + $this->assertEquals('ok', $result['status']); + } + + private function createClientWithMockHandler(MockHandler $mockHandler): Client + { + $handlerStack = HandlerStack::create($mockHandler); + + return new Client([ + 'url' => 'https://query.test.keboola.com', + 'token' => 'test-token', + 'handler' => $handlerStack, + ]); + } +} diff --git a/tests/Functional/BaseFunctionalTestCase.php b/tests/Functional/BaseFunctionalTestCase.php new file mode 100644 index 0000000..a75ef92 --- /dev/null +++ b/tests/Functional/BaseFunctionalTestCase.php @@ -0,0 +1,187 @@ +validateEnvironmentVariables(); + $this->initializeClients(); + $this->findDefaultBranch(); + $this->createTestWorkspace(); + } + + protected function tearDown(): void + { + if (isset($this->testWorkspaceId)) { + try { + $workspaces = new Workspaces($this->branchAwareStorageClient); + $workspaces->deleteWorkspace((int) $this->testWorkspaceId); + } catch (Throwable $e) { + // Log but don't fail the test if workspace cleanup fails + error_log('Failed to delete test workspace: ' . $e->getMessage()); + } + } + + parent::tearDown(); + } + + private function validateEnvironmentVariables(): void + { + $requiredVars = ['TESTS_STORAGE_API_TOKEN', 'TESTS_QUERY_API_URL', 'TESTS_STORAGE_API_URL']; + + foreach ($requiredVars as $var) { + if (empty($_ENV[$var])) { + throw new RuntimeException( + sprintf('Environment variable %s is required for functional tests', $var), + ); + } + } + } + + private function initializeClients(): void + { + $storageApiToken = $_ENV['TESTS_STORAGE_API_TOKEN']; + $queryApiUrl = $_ENV['TESTS_QUERY_API_URL']; + $storageApiUrl = $_ENV['TESTS_STORAGE_API_URL']; + + $this->queryClient = new Client([ + 'url' => $queryApiUrl, + 'token' => $storageApiToken, + ]); + + // Create Storage API client directly for tests + $this->storageApiClient = new StorageApiClient([ + 'url' => $storageApiUrl, + 'token' => $storageApiToken, + ]); + } + + private function findDefaultBranch(): void + { + $devBranches = new DevBranches($this->storageApiClient); + + // List all branches + $branches = $devBranches->listBranches(); + + // Find default branch + $defaultBranch = null; + foreach ($branches as $branch) { + if (isset($branch['isDefault']) && $branch['isDefault'] === true) { + $defaultBranch = $branch; + break; + } + } + + if ($defaultBranch === null) { + throw new RuntimeException('No default branch found'); + } + + $this->testBranchId = (string) $defaultBranch['id']; + + // Initialize branch-aware storage client + $this->branchAwareStorageClient = $this->storageApiClient->getBranchAwareClient($this->testBranchId); + } + + private function createTestWorkspace(): void + { + // Create a workspace for testing queries + $workspaces = new Workspaces($this->branchAwareStorageClient); + $workspaceData = $workspaces->createWorkspace([ + 'name' => sprintf('query-test-workspace-%d', random_int(1000, 9999)), + 'backend' => 'snowflake', + ], true); + + $this->testWorkspaceId = (string) $workspaceData['id']; + } + + protected function getTestBranchId(): string + { + return $this->testBranchId; + } + + protected function getTestWorkspaceId(): string + { + return $this->testWorkspaceId; + } + + protected function createTestTable(?string $tableName = null): string + { + if ($tableName === null) { + $tableName = 'test_table_' . random_int(1000, 9999); + } + + // Create table and insert test data using Query Service + $createTableSql = sprintf(' + CREATE OR REPLACE TABLE %s ( + id INTEGER PRIMARY KEY, + name STRING, + value INTEGER + )', $tableName); + + $insertDataSql = sprintf(' + INSERT INTO %s (id, name, value) VALUES + (1, \'test1\', 100), + (2, \'test2\', 200), + (3, \'test3\', 300) + ', $tableName); + + // Execute table creation and data insertion + $response = $this->queryClient->submitQueryJob( + $this->getTestBranchId(), + $this->getTestWorkspaceId(), + [ + 'statements' => [$createTableSql, $insertDataSql], + 'transactional' => true, + ], + ); + + // Wait for completion + assert(is_string($response['queryJobId'])); + $this->waitForJobCompletion($response['queryJobId']); + + return $tableName; + } + + /** + * @return array + */ + protected function waitForJobCompletion(string $queryJobId, int $maxWaitSeconds = 30): array + { + $startTime = time(); + $attempt = 1; + + while (time() - $startTime < $maxWaitSeconds) { + $status = $this->queryClient->getJobStatus($queryJobId); + if (in_array($status['status'], ['completed', 'failed', 'canceled'], true)) { + return $status; + } + sleep(1); + $attempt++; + } + + throw new RuntimeException( + sprintf('Job %s did not complete within %d seconds', $queryJobId, $maxWaitSeconds), + ); + } +} diff --git a/tests/Functional/BasicQueryTest.php b/tests/Functional/BasicQueryTest.php new file mode 100644 index 0000000..c464a29 --- /dev/null +++ b/tests/Functional/BasicQueryTest.php @@ -0,0 +1,116 @@ +queryClient->submitQueryJob( + $this->getTestBranchId(), + $this->getTestWorkspaceId(), + [ + 'statements' => ['SELECT CURRENT_TIMESTAMP() AS "current_time"'], + 'transactional' => false, + ], + ); + + $this->assertArrayHasKey('queryJobId', $response); + $queryJobId = $response['queryJobId']; + assert(is_string($queryJobId)); + $this->assertNotEmpty($queryJobId); + + // Wait for job completion + $finalStatus = $this->waitForJobCompletion($queryJobId); + + $this->assertEquals('completed', $finalStatus['status']); + $this->assertEquals($queryJobId, $finalStatus['queryJobId']); + $this->assertArrayHasKey('statements', $finalStatus); + $statements = $finalStatus['statements']; + assert(is_array($statements)); + $this->assertCount(1, $statements); + + $statement = $statements[0]; + assert(is_array($statement)); + $this->assertEquals('completed', $statement['status']); + + // Get job results + $this->assertArrayHasKey('id', $statement); + $results = $this->queryClient->getJobResults($queryJobId, $statement['id']); + + $this->assertArrayHasKey('status', $results); + $this->assertEquals('completed', $results['status']); + + // Verify we got a timestamp result + $this->assertArrayHasKey('data', $results); + $data = $results['data']; + assert(is_array($data)); + $this->assertCount(1, $data); + $row = $data[0]; + assert(is_array($row)); + $this->assertCount(1, $row); + // Query API returns indexed arrays, not associative arrays with column names + assert(isset($row[0]) && is_string($row[0])); + $this->assertNotEmpty($row[0]); + // Verify it's a valid timestamp (numeric string) + $this->assertMatchesRegularExpression('/^\d+\.\d+$/', $row[0]); + } + + public function testSubmitInformationSchemaQuery(): void + { + // Test a query against information_schema to verify database connectivity + $response = $this->queryClient->submitQueryJob( + $this->getTestBranchId(), + $this->getTestWorkspaceId(), + [ + 'statements' => [ + 'SELECT COUNT(*) AS "table_count" FROM information_schema.tables ' . + 'WHERE table_schema = CURRENT_SCHEMA()', + ], + 'transactional' => false, + ], + ); + + $this->assertArrayHasKey('queryJobId', $response); + $queryJobId = $response['queryJobId']; + assert(is_string($queryJobId)); + $this->assertNotEmpty($queryJobId); + + // Wait for job completion + $finalStatus = $this->waitForJobCompletion($queryJobId); + + $this->assertEquals('completed', $finalStatus['status']); + $this->assertEquals($queryJobId, $finalStatus['queryJobId']); + $this->assertArrayHasKey('statements', $finalStatus); + $statements = $finalStatus['statements']; + assert(is_array($statements)); + $this->assertCount(1, $statements); + + $statement = $statements[0]; + assert(is_array($statement)); + $this->assertEquals('completed', $statement['status']); + + // Get job results + $this->assertArrayHasKey('id', $statement); + $results = $this->queryClient->getJobResults($queryJobId, $statement['id']); + + $this->assertArrayHasKey('status', $results); + $this->assertEquals('completed', $results['status']); + + // Verify we got a count result + $this->assertArrayHasKey('data', $results); + $data = $results['data']; + assert(is_array($data)); + $this->assertCount(1, $data); + $row = $data[0]; + assert(is_array($row)); + $this->assertCount(1, $row); + // Query API returns indexed arrays, not associative arrays with column names + assert(isset($row[0])); + $this->assertIsNumeric($row[0]); + $this->assertGreaterThanOrEqual(0, (int) $row[0]); + } +} diff --git a/tests/Functional/QueryServiceFunctionalTest.php b/tests/Functional/QueryServiceFunctionalTest.php new file mode 100644 index 0000000..5957549 --- /dev/null +++ b/tests/Functional/QueryServiceFunctionalTest.php @@ -0,0 +1,317 @@ +queryClient->healthCheck(); + + $this->assertArrayHasKey('service', $result); + $this->assertArrayHasKey('status', $result); + $this->assertArrayHasKey('timestamp', $result); + $this->assertArrayHasKey('version', $result); + + $this->assertEquals('query', $result['service']); + $this->assertEquals('ok', $result['status']); + } + + public function testSubmitAndGetSimpleQuery(): void + { + // Create test table with sample data + $tableName = $this->createTestTable(); + + // Submit a simple SELECT query + $response = $this->queryClient->submitQueryJob( + $this->getTestBranchId(), + $this->getTestWorkspaceId(), + [ + 'statements' => [sprintf('SELECT COUNT(*) as row_count FROM %s', $tableName)], + 'transactional' => false, + ], + ); + + $this->assertArrayHasKey('queryJobId', $response); + $queryJobId = $response['queryJobId']; + assert(is_string($queryJobId)); + $this->assertNotEmpty($queryJobId); + + // Wait for job completion + $finalStatus = $this->waitForJobCompletion($queryJobId); + + $this->assertEquals('completed', $finalStatus['status']); + $this->assertEquals($queryJobId, $finalStatus['queryJobId']); + $this->assertArrayHasKey('statements', $finalStatus); + $statements = $finalStatus['statements']; + assert(is_array($statements)); + $this->assertCount(1, $statements); + + $statement = $statements[0]; + assert(is_array($statement)); + $this->assertEquals('completed', $statement['status']); + + // Get job results + $this->assertArrayHasKey('id', $statement); + $results = $this->queryClient->getJobResults($queryJobId, $statement['id']); + + $this->assertArrayHasKey('data', $results); + $this->assertArrayHasKey('status', $results); + $this->assertEquals('completed', $results['status']); + + // Verify the result contains our count + $this->assertArrayHasKey('data', $results); + $data = $results['data']; + assert(is_array($data)); + $this->assertCount(1, $data); + $row = $data[0]; + assert(is_array($row)); + $this->assertEquals(3, $row[0]); // We inserted 3 rows + } + + public function testSubmitTransactionalQuery(): void + { + // Create test table + $tableName = $this->createTestTable(); + + // Submit transactional queries (INSERT and SELECT) + $response = $this->queryClient->submitQueryJob( + $this->getTestBranchId(), + $this->getTestWorkspaceId(), + [ + 'statements' => [ + sprintf('INSERT INTO %s (id, name, value) VALUES (4, \'test4\', 400)', $tableName), + sprintf('SELECT COUNT(*) as row_count FROM %s', $tableName), + ], + 'transactional' => true, + ], + ); + + $this->assertArrayHasKey('queryJobId', $response); + $queryJobId = $response['queryJobId']; + assert(is_string($queryJobId)); + + // Wait for completion + $finalStatus = $this->waitForJobCompletion($queryJobId); + + $this->assertEquals('completed', $finalStatus['status']); + $this->assertArrayHasKey('statements', $finalStatus); + $statements = $finalStatus['statements']; + assert(is_array($statements)); + $this->assertCount(2, $statements); + + // Check INSERT statement + $insertStatement = $statements[0]; + assert(is_array($insertStatement)); + $this->assertEquals('completed', $insertStatement['status']); + + // Check SELECT statement and its results + $selectStatement = $statements[1]; + assert(is_array($selectStatement)); + $this->assertEquals('completed', $selectStatement['status']); + + $this->assertArrayHasKey('id', $selectStatement); + $results = $this->queryClient->getJobResults($queryJobId, $selectStatement['id']); + $this->assertArrayHasKey('data', $results); + $data = $results['data']; + assert(is_array($data)); + $row = $data[0]; + assert(is_array($row)); + $this->assertEquals(4, $row[0]); // Should be 4 rows now + } + + public function testCancelQueryJob(): void + { + // Create test table + $tableName = $this->createTestTable(); + + // Submit a cross join query that takes some time to process + $response = $this->queryClient->submitQueryJob( + $this->getTestBranchId(), + $this->getTestWorkspaceId(), + [ + 'statements' => [ + sprintf(' + SELECT a.id, b.id as id2, a.name, b.name as name2 + FROM %s a + CROSS JOIN %s b + CROSS JOIN %s c + ORDER BY 1, 2 + ', $tableName, $tableName, $tableName), + ], + 'transactional' => false, + ], + ); + + $this->assertArrayHasKey('queryJobId', $response); + $queryJobId = $response['queryJobId']; + assert(is_string($queryJobId)); + $this->assertNotEmpty($queryJobId); + + // Cancel the job + $cancelResponse = $this->queryClient->cancelJob($queryJobId, [ + 'reason' => 'Test cancellation', + ]); + + $this->assertEquals($queryJobId, $cancelResponse['queryJobId']); + + // Wait for final status + $finalStatus = $this->waitForJobCompletion($queryJobId, 15); + + // Job should be canceled + $this->assertEquals('canceled', $finalStatus['status']); + $this->assertArrayHasKey('cancellationReason', $finalStatus); + $this->assertEquals('Test cancellation', $finalStatus['cancellationReason']); + $this->assertArrayHasKey('canceledAt', $finalStatus); + + // Verify job has statements but don't assert on their status + $this->assertArrayHasKey('statements', $finalStatus); + $statements = $finalStatus['statements']; + assert(is_array($statements)); + $this->assertCount(1, $statements); + } + + public function testQueryJobWithInvalidSQL(): void + { + // Submit query with invalid SQL + $response = $this->queryClient->submitQueryJob( + $this->getTestBranchId(), + $this->getTestWorkspaceId(), + [ + 'statements' => ['SELECT * FROM non_existent_table_12345'], + 'transactional' => false, + ], + ); + + $this->assertArrayHasKey('queryJobId', $response); + $queryJobId = $response['queryJobId']; + assert(is_string($queryJobId)); + + // Wait for job completion + $finalStatus = $this->waitForJobCompletion($queryJobId); + + // Job should fail due to invalid SQL + $this->assertEquals('failed', $finalStatus['status']); + $this->assertArrayHasKey('statements', $finalStatus); + $statements = $finalStatus['statements']; + assert(is_array($statements)); + $this->assertCount(1, $statements); + + // The statement remains in 'waiting' status because the job failed before execution + $statement = $statements[0]; + assert(is_array($statement)); + $this->assertEquals('completed', $statement['status']); + assert(is_string($statement['query'])); + $this->assertEquals('SELECT * FROM non_existent_table_12345', $statement['query']); + } + + public function testQueryJobWithEmptyStatements(): void + { + $this->expectException(ClientException::class); + + $this->queryClient->submitQueryJob( + $this->getTestBranchId(), + $this->getTestWorkspaceId(), + [ + 'statements' => [], + 'transactional' => false, + ], + ); + } + + public function testQueryJobWithInvalidBranch(): void + { + // Submit job with an invalid branch ID + $response = $this->queryClient->submitQueryJob( + 'non-existent-branch-12345', + $this->getTestWorkspaceId(), + [ + 'statements' => ['SELECT 1'], + 'transactional' => false, + ], + ); + + $this->assertArrayHasKey('queryJobId', $response); + $queryJobId = $response['queryJobId']; + assert(is_string($queryJobId)); + + // Wait for job completion + $finalStatus = $this->waitForJobCompletion($queryJobId); + + // Query Service accepts invalid branch IDs and executes successfully + $this->assertEquals('completed', $finalStatus['status']); + $this->assertArrayHasKey('statements', $finalStatus); + $statements = $finalStatus['statements']; + assert(is_array($statements)); + $this->assertCount(1, $statements); + + $statement = $statements[0]; + assert(is_array($statement)); + $this->assertEquals('completed', $statement['status']); + assert(is_string($statement['query'])); + $this->assertEquals('SELECT 1', $statement['query']); + assert(is_int($statement['rowsAffected'])); + $this->assertEquals(0, $statement['rowsAffected']); + } + + public function testQueryJobWithInvalidWorkspace(): void + { + $this->expectException(ClientException::class); + + $this->queryClient->submitQueryJob( + $this->getTestBranchId(), + 'non-existent-workspace-12345', + [ + 'statements' => ['SELECT 1'], + 'transactional' => false, + ], + ); + } + + public function testGetJobStatusForNonExistentJob(): void + { + $this->expectException(ClientException::class); + + $this->queryClient->getJobStatus('non-existent-job-12345'); + } + + public function testGetJobResultsForNonExistentJob(): void + { + $this->expectException(ClientException::class); + + $this->queryClient->getJobResults('non-existent-job-12345', 'non-existent-statement-12345'); + } + + public function testCancelNonExistentJob(): void + { + $this->expectException(ClientException::class); + + $this->queryClient->cancelJob('non-existent-job-12345', ['reason' => 'Test']); + } + + public function testInvalidStorageToken(): void + { + // Create a client with an invalid storage token + $invalidTokenClient = new Client([ + 'url' => $_ENV['TESTS_QUERY_API_URL'], + 'token' => 'invalid-token-12345', + ]); + + $this->expectException(ClientException::class); + $this->expectExceptionMessage('Authentication failed'); + + // Attempt to submit a query job with invalid token + $invalidTokenClient->submitQueryJob( + $this->getTestBranchId(), + $this->getTestWorkspaceId(), + [ + 'statements' => ['SELECT 1'], + ], + ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..ad8e8b3 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,16 @@ +load($envFile); + } +}