diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 01de21b..1045e77 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,4 +1,4 @@ parameters: treatPhpDocTypesAsCertain: false ignoreErrors: - - '#Variable method call on RichanFongdasen\\Turso\\TursoHttpClient.#' + - '#Variable method call on RichanFongdasen\\Turso\\TursoClient.#' diff --git a/src/Contracts/TursoQuery.php b/src/Contracts/TursoQuery.php new file mode 100644 index 0000000..a613272 --- /dev/null +++ b/src/Contracts/TursoQuery.php @@ -0,0 +1,17 @@ +schemaGrammar = $this->getDefaultSchemaGrammar(); } /** diff --git a/src/Database/TursoPDOStatement.php b/src/Database/TursoPDOStatement.php index 71de993..bed52f2 100644 --- a/src/Database/TursoPDOStatement.php +++ b/src/Database/TursoPDOStatement.php @@ -9,6 +9,7 @@ use PDOException; use PDOStatement; use RichanFongdasen\Turso\Facades\Turso; +use RichanFongdasen\Turso\Http\QueryResponse; /** * Turso PDO Statement. @@ -24,14 +25,13 @@ class TursoPDOStatement extends PDOStatement protected array $bindings = []; - protected ?Collection $responses = null; + protected ?QueryResponse $response = null; public function __construct( protected TursoPDO $pdo, protected string $query, protected array $options = [], ) { - $this->responses = new Collection(); } public function setFetchMode(int $mode, mixed ...$args): bool @@ -88,16 +88,14 @@ public function execute(?array $params = null): bool $this->bindValue($key, $value, $type); }); - $rawResponse = Turso::query($this->query, array_values($this->bindings)); - $this->responses = $this->formatResponse($rawResponse); - - $lastId = (int) data_get($rawResponse, 'result.last_insert_rowid', 0); + $this->response = Turso::query($this->query, array_values($this->bindings)); + $lastId = (int) $this->response->getLastInsertId(); if ($lastId > 0) { $this->pdo->setLastInsertId(value: $lastId); } - $this->affectedRows = (int) data_get($rawResponse, 'result.affected_row_count', 0); + $this->affectedRows = $this->response->getAffectedRows(); return true; } @@ -108,7 +106,7 @@ public function fetch(int $mode = PDO::FETCH_DEFAULT, int $cursorOrientation = P $mode = $this->fetchMode; } - $response = $this->responses?->shift(); + $response = $this->response?->getRows()->shift(); if ($response === null) { return false; @@ -129,7 +127,7 @@ public function fetch(int $mode = PDO::FETCH_DEFAULT, int $cursorOrientation = P public function fetchAll(int $mode = PDO::FETCH_DEFAULT, ...$args): array { - if (! ($this->responses instanceof Collection)) { + if (! ($this->response instanceof QueryResponse)) { return []; } @@ -137,53 +135,24 @@ public function fetchAll(int $mode = PDO::FETCH_DEFAULT, ...$args): array $mode = $this->fetchMode; } + $allRows = $this->response->getRows(); + $response = match ($mode) { - PDO::FETCH_BOTH => $this->responses->map(function (Collection $row) { + PDO::FETCH_BOTH => $allRows->map(function (Collection $row) { return array_merge($row->toArray(), $row->values()->toArray()); })->toArray(), - PDO::FETCH_ASSOC, PDO::FETCH_NAMED => $this->responses->toArray(), - PDO::FETCH_NUM => $this->responses->map(function (Collection $row) { + PDO::FETCH_ASSOC, PDO::FETCH_NAMED => $allRows->toArray(), + PDO::FETCH_NUM => $allRows->map(function (Collection $row) { return $row->values()->toArray(); })->toArray(), - PDO::FETCH_OBJ => $this->responses->map(function (Collection $row) { + PDO::FETCH_OBJ => $allRows->map(function (Collection $row) { return (object) $row->toArray(); })->toArray(), default => throw new PDOException('Unsupported fetch mode.'), }; - $this->responses = new Collection(); - - return $response; - } - - protected function formatResponse(array $originalResponse): Collection - { - $response = new Collection(); - $columns = collect((array) data_get($originalResponse, 'result.cols', [])) - ->keyBy('name') - ->keys() - ->all(); - - $rows = collect((array) data_get($originalResponse, 'result.rows', [])) - ->each(function (array $item) use (&$response, $columns) { - $row = new Collection(); - - collect($item) - ->each(function (array $column, int $index) use (&$row, $columns) { - $value = match ($column['type']) { - 'blob' => base64_decode((string) $column['value'], true), - 'integer' => (int) $column['value'], - 'float' => (float) $column['value'], - 'null' => null, - default => (string) $column['value'], - }; - - $row->put(data_get($columns, $index), $value); - }); - - $response->push($row); - }); + // $this->responses = new Collection(); return $response; } @@ -201,6 +170,6 @@ public function nextRowset(): bool public function rowCount(): int { - return $this->responses?->count() ?? 0; + return $this->response?->getRows()->count() ?? 0; } } diff --git a/src/Facades/Turso.php b/src/Facades/Turso.php index ce5c01a..3df0dc3 100644 --- a/src/Facades/Turso.php +++ b/src/Facades/Turso.php @@ -8,10 +8,10 @@ use RichanFongdasen\Turso\TursoManager; /** - * @see \RichanFongdasen\Turso\TursoHttpClient + * @see \RichanFongdasen\Turso\TursoClient * * @mixin \RichanFongdasen\Turso\TursoManager - * @mixin \RichanFongdasen\Turso\TursoHttpClient + * @mixin \RichanFongdasen\Turso\TursoClient */ class Turso extends Facade { diff --git a/src/Http/QueryResponse.php b/src/Http/QueryResponse.php new file mode 100644 index 0000000..749ae92 --- /dev/null +++ b/src/Http/QueryResponse.php @@ -0,0 +1,135 @@ +query = $query; + $this->rawResponse = $response; + $this->responseType = data_get($response, 'type', 'error'); + + $this->affectedRows = (int) data_get($response, 'response.result.affected_row_count', 0); + $this->lastInsertId = data_get($response, 'response.result.last_insert_rowid'); + $this->replicationIndex = (int) data_get($response, 'response.result.replication_index'); + + $this->columns = $this->extractColumns($response); + $this->rows = $this->extractRows($response); + + $this->checkIfResponseHasError(); + } + + protected function checkIfResponseHasError(): void + { + if ($this->responseType !== 'error') { + return; + } + + $errorCode = (string) data_get($this->rawResponse, 'error.code', 'UNKNOWN_ERROR'); + $errorMessage = (string) data_get($this->rawResponse, 'error.message', 'Error: An unknown error has occurred'); + + $statement = ($this->query instanceof ExecuteQuery) + ? $this->query->getStatement() + : $this->query->getType(); + + throw new TursoQueryException($errorCode, $errorMessage, $statement); + } + + protected function extractColumns(array $response): Collection + { + return collect((array) data_get($response, 'response.result.cols', [])) + ->keyBy('name') + ->keys(); + } + + protected function extractRows(array $response): Collection + { + $rows = new Collection(); + + collect((array) data_get($response, 'response.result.rows', [])) + ->each(function (array $item) use (&$rows) { + $row = new Collection(); + + collect($item) + ->each(function (array $column, int $index) use (&$row) { + $value = match ($column['type']) { + 'blob' => base64_decode((string) $column['value'], true), + 'integer' => (int) $column['value'], + 'float' => (float) $column['value'], + 'null' => null, + default => (string) $column['value'], + }; + + $row->put($this->columns->get($index), $value); + }); + + $rows->push($row); + }); + + return $rows; + } + + public function getAffectedRows(): int + { + return $this->affectedRows; + } + + public function getColumns(): Collection + { + return $this->columns; + } + + public function getLastInsertId(): ?string + { + return $this->lastInsertId; + } + + public function getQuery(): TursoQuery + { + return $this->query; + } + + public function getRawResponse(): array + { + return $this->rawResponse; + } + + public function getReplicationIndex(): int + { + return $this->replicationIndex; + } + + public function getResponseType(): string + { + return $this->responseType; + } + + public function getRows(): Collection + { + return $this->rows; + } +} diff --git a/src/Http/RequestBody.php b/src/Http/RequestBody.php new file mode 100644 index 0000000..08c626b --- /dev/null +++ b/src/Http/RequestBody.php @@ -0,0 +1,114 @@ +baton = $baton; + $this->queries = new Collection(); + } + + public static function create(?string $baton = null): self + { + return new RequestBody($baton); + } + + public function clearQueries(): self + { + $this->queries = new Collection(); + + return $this; + } + + public function getQuery(int $index): TursoQuery + { + if (! $this->queries->has($index)) { + throw new InvalidArgumentException('Can not find the TursoQuery instance with the specified index: ' . $index . '.'); + } + + return $this->queries->get($index); + } + + public function push(TursoQuery $query): self + { + $this->queries->push($query); + + $query->setIndex($this->queries->count() - 1); + + return $this; + } + + public function withCloseRequest(): self + { + $this->shouldClose = true; + + return $this; + } + + public function withoutCloseRequest(): self + { + $this->shouldClose = false; + + return $this; + } + + public function withForeignKeyConstraints(): self + { + // Make sure that the foreign key constraints statement + // is getting executed only once. + if ((string) $this->baton !== '') { + return $this; + } + + $grammar = app(TursoConnection::class)->getSchemaGrammar(); + + if (! ($grammar instanceof TursoSchemaGrammar)) { + throw new ErrorException('The registered schema grammar is not an instance of TursoSchemaGrammar.'); + } + + $statement = (bool) config('database.connections.turso.foreign_key_constraints') + ? $grammar->compileEnableForeignKeyConstraints() + : $grammar->compileDisableForeignKeyConstraints(); + + $this->push(new ExecuteQuery($statement)); + + return $this; + } + + public function toArray(): array + { + $body = []; + + if ((string) $this->baton !== '') { + $body['baton'] = $this->baton; + } + + $body['requests'] = $this->queries->toArray(); + + if ($this->shouldClose) { + $body['requests'][] = (new CloseQuery())->toArray(); + } + + return $body; + } +} diff --git a/src/Http/ResponseBody.php b/src/Http/ResponseBody.php new file mode 100644 index 0000000..7a1def6 --- /dev/null +++ b/src/Http/ResponseBody.php @@ -0,0 +1,76 @@ +rawResponse = $response; + $this->requestBody = $requestBody; + + $this->baseUrl = data_get($response, 'base_url'); + $this->baton = data_get($response, 'baton'); + + $this->queryResponses = $this->extractQueryResponses($response); + } + + protected function extractQueryResponses(array $response): Collection + { + $queryResponses = new Collection(); + + collect((array) data_get($response, 'results', [])) + ->each(function (array $queryResponse, int $index) use ($queryResponses) { + $queryResponses->push(new QueryResponse( + $this->requestBody->getQuery($index), + $queryResponse + )); + }); + + return $queryResponses; + } + + public function getBaseUrl(): ?string + { + return $this->baseUrl; + } + + public function getBaton(): ?string + { + return $this->baton; + } + + public function getQueryResponse(int $index): QueryResponse + { + if (! $this->queryResponses->has($index)) { + throw new InvalidArgumentException('Can not find the QueryResponse instance with the specified index: ' . $index . '.'); + } + + return $this->queryResponses->get($index); + } + + public function getQueryResponses(): Collection + { + return $this->queryResponses; + } + + public function getRawResponse(): array + { + return $this->rawResponse; + } +} diff --git a/src/Queries/CloseQuery.php b/src/Queries/CloseQuery.php new file mode 100644 index 0000000..b3c4827 --- /dev/null +++ b/src/Queries/CloseQuery.php @@ -0,0 +1,17 @@ + self::$type, + ]; + } +} diff --git a/src/Queries/ExecuteQuery.php b/src/Queries/ExecuteQuery.php new file mode 100644 index 0000000..d75d9e1 --- /dev/null +++ b/src/Queries/ExecuteQuery.php @@ -0,0 +1,42 @@ +bindings; + } + + public function getStatement(): string + { + return $this->statement; + } + + public function toArray(): array + { + $arrayValue = [ + 'type' => static::$type, + 'stmt' => [ + 'sql' => $this->statement, + ], + ]; + + if ($this->bindings !== []) { + $arrayValue['stmt']['args'] = $this->bindings; + } + + return $arrayValue; + } +} diff --git a/src/Queries/Query.php b/src/Queries/Query.php new file mode 100644 index 0000000..8ee090b --- /dev/null +++ b/src/Queries/Query.php @@ -0,0 +1,38 @@ +index; + } + + public function getType(): string + { + return static::$type; + } + + public function setIndex(int $index): self + { + $this->index = $index; + + return $this; + } + + public function __toString(): string + { + return (string) json_encode($this->toArray()); + } + + abstract public function toArray(): array; +} diff --git a/src/TursoClient.php b/src/TursoClient.php new file mode 100755 index 0000000..d9cc739 --- /dev/null +++ b/src/TursoClient.php @@ -0,0 +1,154 @@ +config = new Collection($config); + + $this->queryLog = new Collection(); + + $this->enableQueryLog(); + $this->resetHttpClientState(); + } + + public function __destruct() + { + $this->close(); + } + + public function close(): void + { + if ((string) $this->baton === '') { + return; + } + + $body = RequestBody::create($this->baton) + ->withCloseRequest(); + + $this->httpRequest() + ->baseUrl($this->baseUrl) + ->post('/v3/pipeline', $body->toArray()); + + $this->resetHttpClientState(); + } + + protected function createHttpRequest(): PendingRequest + { + $this->resetHttpClientState(); + + $accessToken = $this->config->get('access_token'); + + return Http::withHeaders([ + 'Content-Type' => 'application/json', + ])->connectTimeout(2) + ->timeout(5) + ->withToken($accessToken) + ->acceptJson(); + } + + public function disableQueryLog(): void + { + $this->loggingQueries = false; + } + + public function enableQueryLog(): void + { + $this->loggingQueries = true; + } + + public function freshHttpRequest(): PendingRequest + { + $this->httpRequest = $this->createHttpRequest(); + + return $this->httpRequest; + } + + public function getBaseUrl(): ?string + { + return $this->baseUrl; + } + + public function getBaton(): ?string + { + return $this->baton; + } + + public function getQueryLog(): Collection + { + return $this->queryLog; + } + + public function query(string $statement, array $bindingValues = []): QueryResponse + { + $query = new ExecuteQuery($statement, $bindingValues); + + $requestBody = RequestBody::create($this->baton) + ->withForeignKeyConstraints() + ->push($query); + + $httpResponse = $this->httpRequest() + ->baseUrl($this->baseUrl) + ->post('/v3/pipeline', $requestBody->toArray()); + + if ($httpResponse->failed()) { + $this->resetHttpClientState(); + $httpResponse->throw(); + } + + $responseBody = new ResponseBody($requestBody, $httpResponse->json() ?? []); + + if ($this->loggingQueries) { + $this->queryLog->push([ + 'request' => $requestBody->toArray(), + 'response' => $responseBody->getRawResponse(), + ]); + } + + $this->baton = $responseBody->getBaton(); + $baseUrl = (string) $responseBody->getBaseUrl(); + + if ($baseUrl !== '') { + $this->baseUrl = $baseUrl; + } + + return $responseBody->getQueryResponse($query->getIndex()); + } + + public function httpRequest(): PendingRequest + { + return ($this->httpRequest === null) + ? $this->freshHttpRequest() + : $this->httpRequest; + } + + public function resetHttpClientState(): void + { + $this->baton = null; + $this->baseUrl = (string) $this->config->get('db_url', ''); + } +} diff --git a/src/TursoHttpClient.php b/src/TursoHttpClient.php deleted file mode 100755 index f86f686..0000000 --- a/src/TursoHttpClient.php +++ /dev/null @@ -1,183 +0,0 @@ -config = $config; - - $this->queryLog = new Collection(); - - $this->disableQueryLog(); - $this->resetClientState(); - } - - public function __destruct() - { - if ($this->isOpen) { - $this->close(); - } - } - - public function close(): void - { - $this->request() - ->baseUrl($this->baseUrl) - ->post('/v3/pipeline', $this->createRequestBody('close')); - - $this->resetClientState(); - } - - protected function createRequest(): PendingRequest - { - $accessToken = data_get($this->config, 'access_token'); - - return Http::withHeaders([ - 'Content-Type' => 'application/json', - ])->connectTimeout(2) - ->timeout(5) - ->withToken($accessToken) - ->acceptJson(); - } - - protected function createRequestBody(string $type, ?string $statement = null, array $bindings = []): array - { - $requestBody = []; - - if (($this->baton !== null) && ($this->baton !== '')) { - $requestBody['baton'] = $this->baton; - } - - $requestBody['requests'] = ($type === 'close') - ? [['type' => 'close']] - : [[ - 'type' => 'execute', - 'stmt' => [ - 'sql' => $statement, - ], - ]]; - - if (($type !== 'close') && (count($bindings) > 0)) { - $requestBody['requests'][0]['stmt']['args'] = $bindings; - } - - return $requestBody; - } - - public function disableQueryLog(): void - { - $this->loggingQueries = false; - } - - public function enableQueryLog(): void - { - $this->loggingQueries = true; - } - - public function freshRequest(): PendingRequest - { - $this->resetClientState(); - - $this->request = $this->createRequest(); - - return $this->request; - } - - public function getBaseUrl(): ?string - { - return $this->baseUrl; - } - - public function getBaton(): ?string - { - return $this->baton; - } - - public function getQueryLog(): Collection - { - return $this->queryLog; - } - - public function query(string $statement, array $bindingValues = []): array - { - $response = $this->request() - ->baseUrl($this->baseUrl) - ->post('/v3/pipeline', $this->createRequestBody('execute', $statement, $bindingValues)); - - if ($response->failed()) { - $this->resetClientState(); - $response->throw(); - } - - if (! $this->isOpen) { - $this->isOpen = true; - } - - $jsonResponse = $response->json(); - - if ($this->loggingQueries) { - $this->queryLog->push([ - 'statement' => $statement, - 'bindings' => $bindingValues, - 'response' => $jsonResponse, - ]); - } - - $this->baton = data_get($jsonResponse, 'baton', null); - $baseUrl = (string) data_get($jsonResponse, 'base_url', $this->baseUrl); - - if (($baseUrl !== '') && ($baseUrl !== $this->baseUrl)) { - $this->baseUrl = $baseUrl; - } - - $result = new Collection(data_get($jsonResponse, 'results.0', [])); - - if ($result->get('type') === 'error') { - $this->resetClientState(); - - $errorCode = (string) data_get($result, 'error.code', 'UNKNOWN_ERROR'); - $errorMessage = (string) data_get($result, 'error.message', 'Error: An unknown error has occurred'); - - throw new TursoQueryException($errorCode, $errorMessage, $statement); - } - - return data_get($result, 'response', []); - } - - public function request(): PendingRequest - { - return ($this->request === null) - ? $this->freshRequest() - : $this->request; - } - - public function resetClientState(): void - { - $this->baton = null; - $this->baseUrl = (string) data_get($this->config, 'db_url', ''); - $this->isOpen = false; - } -} diff --git a/src/TursoLaravelServiceProvider.php b/src/TursoLaravelServiceProvider.php index 9771c9f..9fb3956 100644 --- a/src/TursoLaravelServiceProvider.php +++ b/src/TursoLaravelServiceProvider.php @@ -22,7 +22,10 @@ public function boot(): void { parent::boot(); - if (config('database.default') !== 'turso') { + if ( + (config('database.default') !== 'turso') || + ((string) config('database.connections.turso.db_replica') === '') + ) { return; } diff --git a/src/TursoManager.php b/src/TursoManager.php index 031c9d3..4e08f6f 100644 --- a/src/TursoManager.php +++ b/src/TursoManager.php @@ -14,7 +14,7 @@ class TursoManager { - protected TursoHttpClient $client; + protected TursoClient $client; protected Collection $config; @@ -23,7 +23,7 @@ class TursoManager public function __construct(array $config = []) { $this->config = new Collection($config); - $this->client = new TursoHttpClient($config); + $this->client = new TursoClient($config); } public function backgroundSync(): void diff --git a/tests/Feature/ReadReplicaTest.php b/tests/Feature/ReadReplicaTest.php index 6d54bf0..5e63a4a 100644 --- a/tests/Feature/ReadReplicaTest.php +++ b/tests/Feature/ReadReplicaTest.php @@ -23,11 +23,9 @@ })->group('ReadReplicaTest', 'FeatureTest'); test('it will use the primary database connection for data manipulation operation', function () { - Http::fake([ - '*' => Http::response(), - ]); + fakeHttpRequest(); - Turso::freshRequest(); + Turso::freshHttpRequest(); DB::table('users')->insert([ 'name' => 'June Monroe', @@ -36,16 +34,24 @@ Http::assertSent(function (Request $request) { expect($request->url())->toBe('http://127.0.0.1:8080/v3/pipeline') ->and($request->data())->toBe([ - 'requests' => [[ - 'type' => 'execute', - 'stmt' => [ - 'sql' => 'insert into "users" ("name") values (?)', - 'args' => [[ - 'type' => 'text', - 'value' => 'June Monroe', - ]], + 'requests' => [ + [ + 'type' => 'execute', + 'stmt' => [ + 'sql' => 'PRAGMA foreign_keys = ON;', + ], + ], + [ + 'type' => 'execute', + 'stmt' => [ + 'sql' => 'insert into "users" ("name") values (?)', + 'args' => [[ + 'type' => 'text', + 'value' => 'June Monroe', + ]], + ], ], - ]], + ], ]); return true; diff --git a/tests/Pest.php b/tests/Pest.php index afacdef..480db04 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,6 @@ up(); }); } + +function fakeHttpRequest(array $response = []): void +{ + if ($response === []) { + $response = [ + 'results' => [ + [ + 'type' => 'ok', + 'response' => [ + 'result' => [ + 'affected_row_count' => 1, + 'last_insert_rowid' => '1', + 'replication_index' => 0, + ], + ], + ], + [ + 'type' => 'ok', + 'response' => [ + 'result' => [ + 'affected_row_count' => 1, + 'last_insert_rowid' => '1', + 'replication_index' => 0, + ], + ], + ], + ], + ]; + } + + Http::fake([ + '*' => Http::response($response), + ]); +} diff --git a/tests/Unit/TursoSyncCommandTest.php b/tests/Unit/Commands/TursoSyncCommandTest.php similarity index 96% rename from tests/Unit/TursoSyncCommandTest.php rename to tests/Unit/Commands/TursoSyncCommandTest.php index 9f132be..7917290 100644 --- a/tests/Unit/TursoSyncCommandTest.php +++ b/tests/Unit/Commands/TursoSyncCommandTest.php @@ -15,7 +15,7 @@ Artisan::call('turso:sync'); Process::assertRan(function (PendingProcess $process) { - $expectedPath = realpath(__DIR__ . '/../..'); + $expectedPath = realpath(__DIR__ . '/../../..'); expect($process->command)->toBe('/dev/null turso-sync.mjs "http://127.0.0.1:8080" "your-access-token" "/tmp/turso.sqlite"') ->and($process->timeout)->toBe(60) diff --git a/tests/Unit/TursoConnectionTest.php b/tests/Unit/Database/TursoConnectionTest.php similarity index 100% rename from tests/Unit/TursoConnectionTest.php rename to tests/Unit/Database/TursoConnectionTest.php diff --git a/tests/Unit/TursoPDOTest.php b/tests/Unit/Database/TursoPDOTest.php similarity index 100% rename from tests/Unit/TursoPDOTest.php rename to tests/Unit/Database/TursoPDOTest.php diff --git a/tests/Unit/TursoSchemaBuilderTest.php b/tests/Unit/Database/TursoSchemaBuilderTest.php similarity index 100% rename from tests/Unit/TursoSchemaBuilderTest.php rename to tests/Unit/Database/TursoSchemaBuilderTest.php diff --git a/tests/Unit/Http/QueryResponseTest.php b/tests/Unit/Http/QueryResponseTest.php new file mode 100644 index 0000000..d815f6f --- /dev/null +++ b/tests/Unit/Http/QueryResponseTest.php @@ -0,0 +1,138 @@ +query = new ExecuteQuery( + 'SELECT * FROM "users" WHERE "id" = ?', + [ + [ + 'type' => 'integer', + 'value' => 1, + ], + ] + ); + + $this->originalResponse = [ + 'type' => 'ok', + 'response' => [ + 'type' => 'execute', + 'result' => [ + 'cols' => [ + [ + 'name' => 'id', + 'decltype' => 'INTEGER', + ], + [ + 'name' => 'name', + 'decltype' => 'TEXT', + ], + [ + 'name' => 'email', + 'decltype' => 'TEXT', + ], + ], + 'rows' => [ + [ + [ + 'type' => 'integer', + 'value' => '31', + ], + [ + 'type' => 'text', + 'value' => 'John Doe', + ], + [ + 'type' => 'text', + 'value' => 'john.doe@gmail.com', + ], + ], + [ + [ + 'type' => 'integer', + 'value' => '32', + ], + [ + 'type' => 'text', + 'value' => 'Jane Doe', + ], + [ + 'type' => 'text', + 'value' => 'jane.doe@gmail.com', + ], + ], + [ + [ + 'type' => 'integer', + 'value' => '33', + ], + [ + 'type' => 'text', + 'value' => 'June Monroe', + ], + [ + 'type' => 'text', + 'value' => 'june.monroe@gmail.com', + ], + ], + ], + 'affected_row_count' => 3, + 'last_insert_rowid' => '123', + 'replication_index' => 8, + ], + ], + ]; + + $this->response = new QueryResponse($this->query, $this->originalResponse); +}); + +test('it can extract the response data', function () { + expect($this->response->getAffectedRows())->toBe(3) + ->and($this->response->getLastInsertId())->toBe('123') + ->and($this->response->getQuery())->toBe($this->query) + ->and($this->response->getReplicationIndex())->toBe(8) + ->and($this->response->getResponseType())->toBe('ok') + ->and($this->response->getRawResponse())->toBe($this->originalResponse); +})->group('QueryResponseTest', 'UnitTest'); + +test('it can extract columns from the response', function () { + $columns = $this->response->getColumns()->toArray(); + + expect($columns)->toBeArray() + ->toHaveCount(3) + ->toBe(['id', 'name', 'email']); +})->group('QueryResponseTest', 'UnitTest'); + +test('it can extract the rows from the response', function () { + $rows = $this->response->getRows()->toArray(); + + expect($rows)->toBeArray() + ->and($rows)->toHaveCount(3) + ->and($rows[0])->toBe([ + 'id' => 31, + 'name' => 'John Doe', + 'email' => 'john.doe@gmail.com', + ]) + ->and($rows[1])->toBe([ + 'id' => 32, + 'name' => 'Jane Doe', + 'email' => 'jane.doe@gmail.com', + ]) + ->and($rows[2])->toBe([ + 'id' => 33, + 'name' => 'June Monroe', + 'email' => 'june.monroe@gmail.com', + ]); +})->group('QueryResponseTest', 'UnitTest'); + +test('it can raise TursoQueryException if the response contains any error', function () { + $response = new QueryResponse($this->query, [ + 'type' => 'error', + 'error' => [ + 'code' => 'QUERY_ERROR', + 'message' => 'Error: An unknown error has occurred', + ], + ]); +})->throws(TursoQueryException::class)->group('QueryResponseTest', 'UnitTest'); diff --git a/tests/Unit/Http/RequestBodyTest.php b/tests/Unit/Http/RequestBodyTest.php new file mode 100644 index 0000000..9386b95 --- /dev/null +++ b/tests/Unit/Http/RequestBodyTest.php @@ -0,0 +1,144 @@ +baton = null; + $this->request = RequestBody::create($this->baton) + ->withCloseRequest() + ->withForeignKeyConstraints() + ->push(new ExecuteQuery('SELECT * FROM "users"')); +}); + +test('it can convert itself into an array', function () { + expect($this->request->toArray())->toBe([ + 'requests' => [ + [ + 'type' => 'execute', + 'stmt' => [ + 'sql' => 'PRAGMA foreign_keys = ON;', + ], + ], + [ + 'type' => 'execute', + 'stmt' => [ + 'sql' => 'SELECT * FROM "users"', + ], + ], + [ + 'type' => 'close', + ], + ], + ]); +})->group('RequestBodyTest', 'UnitTest'); + +test('it can clear all queries', function () { + $this->request->clearQueries(); + + expect($this->request->toArray())->toBe([ + 'requests' => [ + [ + 'type' => 'close', + ], + ], + ]); +})->group('RequestBodyTest', 'UnitTest'); + +test('it can push a new query', function () { + $this->request->push(new ExecuteQuery('SELECT * FROM "users" WHERE "id" = ?', [ + [ + 'type' => 'integer', + 'value' => 1, + ], + ])); + + expect($this->request->toArray())->toBe([ + 'requests' => [ + [ + 'type' => 'execute', + 'stmt' => [ + 'sql' => 'PRAGMA foreign_keys = ON;', + ], + ], + [ + 'type' => 'execute', + 'stmt' => [ + 'sql' => 'SELECT * FROM "users"', + ], + ], + [ + 'type' => 'execute', + 'stmt' => [ + 'sql' => 'SELECT * FROM "users" WHERE "id" = ?', + 'args' => [ + [ + 'type' => 'integer', + 'value' => 1, + ], + ], + ], + ], + [ + 'type' => 'close', + ], + ], + ]); +})->group('RequestBodyTest', 'UnitTest'); + +test('it can remove the close query from the body', function () { + $this->request->withoutCloseRequest(); + + expect($this->request->toArray())->toBe([ + 'requests' => [ + [ + 'type' => 'execute', + 'stmt' => [ + 'sql' => 'PRAGMA foreign_keys = ON;', + ], + ], + [ + 'type' => 'execute', + 'stmt' => [ + 'sql' => 'SELECT * FROM "users"', + ], + ], + ], + ]); +})->group('RequestBodyTest', 'UnitTest'); + +test('it can convert itself into an array with baton value being set', function () { + $this->request = RequestBody::create('some-baton-string') + ->withCloseRequest() + ->withForeignKeyConstraints() + ->push(new ExecuteQuery('SELECT * FROM "users"')); + + expect($this->request->toArray())->toBe([ + 'baton' => 'some-baton-string', + 'requests' => [ + [ + 'type' => 'execute', + 'stmt' => [ + 'sql' => 'SELECT * FROM "users"', + ], + ], + [ + 'type' => 'close', + ], + ], + ]); +})->group('RequestBodyTest', 'UnitTest'); + +test('it can retrieve a specific TursoQuery instance by the given index', function () { + $query = $this->request->getQuery(1); + + expect($query)->toBeInstanceOf(ExecuteQuery::class) + ->and($query->getType())->toBe('execute') + ->and($query->getIndex())->toBe(1) + ->and($query->getStatement())->toBe('SELECT * FROM "users"') + ->and($query->getBindings())->toBe([]); +})->group('RequestBodyTest', 'UnitTest'); + +test('it raises InvalidArgumentException when the query index is not found', function () { + $this->request->getQuery(8); +})->throws(\InvalidArgumentException::class)->group('RequestBodyTest', 'UnitTest'); diff --git a/tests/Unit/Http/ResponseBodyTest.php b/tests/Unit/Http/ResponseBodyTest.php new file mode 100644 index 0000000..f0ee4de --- /dev/null +++ b/tests/Unit/Http/ResponseBodyTest.php @@ -0,0 +1,102 @@ +request = RequestBody::create('baton') + ->push(new ExecuteQuery('SELECT "id", "name" FROM "users"')) + ->push(new CloseQuery()); + + $this->rawResponse = [ + 'base_url' => 'https:://example-base-url.turso.io', + 'baton' => 'baton-string-example', + 'results' => [ + [ + 'type' => 'ok', + 'response' => [ + 'type' => 'execute', + 'result' => [ + 'cols' => [ + [ + 'name' => 'id', + 'decltype' => 'INTEGER', + ], + [ + 'name' => 'name', + 'decltype' => 'TEXT', + ], + ], + 'rows' => [ + [ + [ + 'type' => 'integer', + 'value' => '1', + ], + [ + 'type' => 'text', + 'value' => 'John Doe', + ], + ], + [ + [ + 'type' => 'integer', + 'value' => '2', + ], + [ + 'type' => 'text', + 'value' => 'Jane Doe', + ], + ], + ], + ], + ], + ], + [ + 'type' => 'ok', + 'response' => [ + 'type' => 'close', + ], + ], + ], + ]; + + $this->response = new ResponseBody($this->request, $this->rawResponse); +}); + +test('it can extract the response data', function () { + expect($this->response->getBaseUrl())->toBe('https:://example-base-url.turso.io') + ->and($this->response->getBaton())->toBe('baton-string-example') + ->and($this->response->getRawResponse())->toBe($this->rawResponse) + ->and($this->response->getQueryResponses())->toHaveCount(2) + ->and($this->response->getQueryResponse(0))->toBeInstanceOf(QueryResponse::class) + ->and($this->response->getQueryResponse(1))->toBeInstanceOf(QueryResponse::class); +})->group('ResponseBodyTest', 'UnitTest'); + +test('it can retrieve the QueryResponse instance by the specified index', function () { + $response = $this->response->getQueryResponse(0); + + expect($response->getAffectedRows())->toBe(0) + ->and($response->getLastInsertId())->toBeNull() + ->and($response->getQuery())->toBeInstanceOf(ExecuteQuery::class) + ->and($response->getReplicationIndex())->toBe(0) + ->and($response->getResponseType())->toBe('ok') + ->and($response->getColumns()->toArray())->toBe(['id', 'name']) + ->and($response->getRows()->toArray())->toBe([ + [ + 'id' => 1, + 'name' => 'John Doe', + ], + [ + 'id' => 2, + 'name' => 'Jane Doe', + ], + ]); +})->group('ResponseBodyTest', 'UnitTest'); + +test('it will raise InvalidArgumentException when the specified index is out of range', function () { + $this->response->getQueryResponse(2); +})->throws(\InvalidArgumentException::class)->group('ResponseBodyTest', 'UnitTest'); diff --git a/tests/Unit/Queries/CloseQueryTest.php b/tests/Unit/Queries/CloseQueryTest.php new file mode 100644 index 0000000..858797c --- /dev/null +++ b/tests/Unit/Queries/CloseQueryTest.php @@ -0,0 +1,31 @@ +query = new CloseQuery(); +}); + +test('it can returns the query type', function () { + expect($this->query->getType())->toBe('close'); +})->group('CloseQueryTest', 'UnitTest'); + +test('it can convert itself into an array', function () { + expect($this->query->toArray())->toBe([ + 'type' => 'close', + ]); +})->group('CloseQueryTest', 'UnitTest'); + +test('it can convert itself into a string', function () { + expect((string) $this->query)->toBe('{"type":"close"}'); +})->group('CloseQueryTest', 'UnitTest'); + +test('it can returns the query index', function () { + expect($this->query->getIndex())->toBe(0); +})->group('CloseQueryTest', 'UnitTest'); + +test('it can set the query index', function () { + $this->query->setIndex(10); + + expect($this->query->getIndex())->toBe(10); +})->group('CloseQueryTest', 'UnitTest'); diff --git a/tests/Unit/Queries/ExecuteQueryTest.php b/tests/Unit/Queries/ExecuteQueryTest.php new file mode 100644 index 0000000..e26f823 --- /dev/null +++ b/tests/Unit/Queries/ExecuteQueryTest.php @@ -0,0 +1,72 @@ +query = new ExecuteQuery( + 'SELECT * FROM "users" WHERE "id" = ?', + [ + [ + 'type' => 'integer', + 'value' => 1, + ], + ] + ); +}); + +test('it can returns the query type', function () { + expect($this->query->getType())->toBe('execute'); +})->group('ExecuteQueryTest', 'UnitTest'); + +test('it can returns the query statement', function () { + expect($this->query->getStatement())->toBe('SELECT * FROM "users" WHERE "id" = ?'); +})->group('ExecuteQueryTest', 'UnitTest'); + +test('it can returns the query bindings', function () { + expect($this->query->getBindings())->toBe([ + [ + 'type' => 'integer', + 'value' => 1, + ], + ]); +})->group('ExecuteQueryTest', 'UnitTest'); + +test('it can convert itself into an array', function () { + expect($this->query->toArray())->toBe([ + 'type' => 'execute', + 'stmt' => [ + 'sql' => 'SELECT * FROM "users" WHERE "id" = ?', + 'args' => [ + [ + 'type' => 'integer', + 'value' => 1, + ], + ], + ], + ]); +})->group('ExecuteQueryTest', 'UnitTest'); + +test('it can convert itself into a string', function () { + expect((string) $this->query)->toBe('{"type":"execute","stmt":{"sql":"SELECT * FROM \"users\" WHERE \"id\" = ?","args":[{"type":"integer","value":1}]}}'); +})->group('ExecuteQueryTest', 'UnitTest'); + +test('it can convert itself into an array without any bindings', function () { + $query = new ExecuteQuery('SELECT * FROM "users"'); + + expect($query->toArray())->toBe([ + 'type' => 'execute', + 'stmt' => [ + 'sql' => 'SELECT * FROM "users"', + ], + ]); +})->group('ExecuteQueryTest', 'UnitTest'); + +test('it can returns the query index', function () { + expect($this->query->getIndex())->toBe(0); +})->group('ExecuteQueryTest', 'UnitTest'); + +test('it can set the query index', function () { + $this->query->setIndex(10); + + expect($this->query->getIndex())->toBe(10); +})->group('ExecuteQueryTest', 'UnitTest'); diff --git a/tests/Unit/TursoClientTest.php b/tests/Unit/TursoClientTest.php new file mode 100644 index 0000000..1bf30d8 --- /dev/null +++ b/tests/Unit/TursoClientTest.php @@ -0,0 +1,141 @@ +toBe('http://127.0.0.1:8080') + ->and(Turso::getBaton())->toBeNull(); +})->group('TursoClient', 'UnitTest'); + +test('it can log queries', function () { + fakeHttpRequest(); + + $statement = 'SELECT * FROM "users" WHERE "id" = ?'; + $bindings = [ + [ + 'type' => 'integer', + 'value' => 1, + ], + ]; + + $expectedLog = [ + 'request' => [ + 'requests' => [ + [ + 'type' => 'execute', + 'stmt' => [ + 'sql' => 'PRAGMA foreign_keys = ON;', + ], + ], + [ + 'type' => 'execute', + 'stmt' => [ + 'sql' => $statement, + 'args' => $bindings, + ], + ], + ], + ], + 'response' => [ + 'results' => [ + [ + 'type' => 'ok', + 'response' => [ + 'result' => [ + 'affected_row_count' => 1, + 'last_insert_rowid' => '1', + 'replication_index' => 0, + ], + ], + ], + [ + 'type' => 'ok', + 'response' => [ + 'result' => [ + 'affected_row_count' => 1, + 'last_insert_rowid' => '1', + 'replication_index' => 0, + ], + ], + ], + ], + ], + ]; + + Turso::enableQueryLog(); + Turso::freshHttpRequest(); + + Turso::query($statement, $bindings); + + expect(Turso::getQueryLog()->count())->toBe(1) + ->and(Turso::getQueryLog()->first())->toBe($expectedLog); +})->group('TursoClient', 'UnitTest'); + +test('it raises exception on any HTTP errors', function () { + Http::fake([ + '*' => Http::response(['message' => 'Internal Server Error'], 500), + ]); + + Turso::freshHttpRequest(); + + Turso::query('SELECT * FROM "users"'); +})->throws(RequestException::class)->group('TursoClient', 'UnitTest'); + +test('it raises TursoQueryException when the query response has any error in it', function () { + Http::fake([ + '*' => Http::response( + [ + 'results' => [ + [ + 'type' => 'error', + 'error' => [ + 'code' => 'QUERY_ERROR', + 'message' => 'Error: An unknown error has occurred', + ], + ], + ], + ], + 200 + ), + ]); + + Turso::query('SELECT * FROM "users"'); +})->throws(TursoQueryException::class)->group('TursoClient', 'UnitTest'); + +test('it can replace the base url with the one that suggested by turso response', function () { + fakeHttpRequest([ + 'base_url' => 'http://base-url-example.turso.io', + 'results' => [ + [ + 'type' => 'ok', + 'response' => [ + 'result' => [ + 'affected_row_count' => 1, + 'last_insert_rowid' => '1', + 'replication_index' => 0, + ], + ], + ], + [ + 'type' => 'ok', + 'response' => [ + 'result' => [ + 'affected_row_count' => 1, + 'last_insert_rowid' => '1', + 'replication_index' => 0, + ], + ], + ], + ], + ]); + + Turso::freshHttpRequest(); + Turso::query('SELECT * FROM "users"'); + + expect(Turso::getBaseUrl())->toBe('http://base-url-example.turso.io'); +})->group('TursoClient', 'UnitTest'); diff --git a/tests/Unit/TursoHttpClientTest.php b/tests/Unit/TursoHttpClientTest.php deleted file mode 100644 index afc1788..0000000 --- a/tests/Unit/TursoHttpClientTest.php +++ /dev/null @@ -1,83 +0,0 @@ -toBe('http://127.0.0.1:8080') - ->and(Turso::getBaton())->toBeNull(); -})->group('TursoClient', 'UnitTest'); - -test('it can log queries', function () { - Http::fake(); - - $query = [ - 'statement' => 'SELECT * FROM "users" WHERE "id" = ?', - 'bindings' => [ - [ - 'type' => 'integer', - 'value' => 1, - ], - ], - 'response' => null, - ]; - - Turso::enableQueryLog(); - Turso::freshRequest(); - - Turso::query($query['statement'], $query['bindings']); - - expect(Turso::getQueryLog()->count())->toBe(1) - ->and(Turso::getQueryLog()->first())->toBe($query); -})->group('TursoClient', 'UnitTest'); - -test('it raises exception on any HTTP errors', function () { - Http::fake([ - '*' => Http::response(['message' => 'Internal Server Error'], 500), - ]); - - Turso::freshRequest(); - - Turso::query('SELECT * FROM "users"'); -})->throws(RequestException::class)->group('TursoClient', 'UnitTest'); - -test('it raises TursoQueryException when the query response has any response in it', function () { - Http::fake([ - '*' => Http::response( - [ - 'results' => [ - [ - 'type' => 'error', - 'error' => [ - 'code' => 'QUERY_ERROR', - 'message' => 'Error: An unknown error has occurred', - ], - ], - ], - ], - 200 - ), - ]); - - Turso::query('SELECT * FROM "users"'); -})->throws(TursoQueryException::class)->group('TursoClient', 'UnitTest'); - -test('it can replace the base url with the one that suggested by turso response', function () { - Http::fake([ - '*' => Http::response( - [ - 'base_url' => 'http://base-url-example.turso.io', - ], - 200 - ), - ]); - - Turso::freshRequest(); - Turso::query('SELECT * FROM "users"'); - - expect(Turso::getBaseUrl())->toBe('http://base-url-example.turso.io'); -})->group('TursoClient', 'UnitTest'); diff --git a/tests/Unit/TursoManagerTest.php b/tests/Unit/TursoManagerTest.php index dd50219..09469f8 100644 --- a/tests/Unit/TursoManagerTest.php +++ b/tests/Unit/TursoManagerTest.php @@ -16,22 +16,30 @@ test('it can disable the read replica database connection', function () { DB::connection('turso')->setReadPdo(new \PDO('sqlite::memory:')); - Http::fake(); + fakeHttpRequest(); Turso::disableReadReplica(); - Turso::resetClientState(); + Turso::resetHttpClientState(); Turso::query('SELECT * FROM sqlite_master'); Http::assertSent(function (Request $request) { expect($request->url())->toBe('http://127.0.0.1:8080/v3/pipeline') ->and($request->data())->toBe([ - 'requests' => [[ - 'type' => 'execute', - 'stmt' => [ - 'sql' => 'SELECT * FROM sqlite_master', + 'requests' => [ + [ + 'type' => 'execute', + 'stmt' => [ + 'sql' => 'PRAGMA foreign_keys = ON;', + ], ], - ]], + [ + 'type' => 'execute', + 'stmt' => [ + 'sql' => 'SELECT * FROM sqlite_master', + ], + ], + ], ]); return true;