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
+
+[](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);
+ }
+}