diff --git a/app/Console/Commands/SurrealProbeCommand.php b/app/Console/Commands/SurrealProbeCommand.php index cf25c87..a073483 100644 --- a/app/Console/Commands/SurrealProbeCommand.php +++ b/app/Console/Commands/SurrealProbeCommand.php @@ -84,8 +84,7 @@ public function handle(): int ); $createdRecord = is_array($results[0] ?? null) ? (($results[0] ?? [])[0] ?? null) : null; - $selectedRecords = is_array($results[1] ?? null) ? (($results[1] ?? [])[0] ?? null) : null; - $selectedRecord = is_array($selectedRecords) ? ($selectedRecords[0] ?? null) : null; + $selectedRecord = is_array($results[1] ?? null) ? (($results[1] ?? [])[0] ?? null) : null; if (! is_array($createdRecord) || ! is_array($selectedRecord) || ($selectedRecord['id'] ?? null) !== $recordId) { throw new RuntimeException('The SurrealDB probe did not return the expected write/read payload.'); diff --git a/app/Services/Surreal/Query/SurrealQueryBuilder.php b/app/Services/Surreal/Query/SurrealQueryBuilder.php new file mode 100644 index 0000000..861e36e --- /dev/null +++ b/app/Services/Surreal/Query/SurrealQueryBuilder.php @@ -0,0 +1,160 @@ + $columns + * @return Collection + */ + public function get($columns = ['*']): Collection + { + $original = $this->columns; + + $this->columns ??= Arr::wrap($columns); + + $rows = $this->surrealConnection()->selectRecords( + table: (string) $this->from, + columns: $this->resolveColumns($this->columns), + wheres: $this->wheres ?? [], + orders: $this->orders ?? [], + limit: $this->limit, + offset: $this->offset, + ); + + $this->columns = $original; + + return $this->applyAfterQueryCallbacks(new Collection(array_map( + static fn (array $row): stdClass => (object) $row, + $rows, + ))); + } + + public function insert(array $values): bool + { + $records = $this->prepareInsertValues($values); + + foreach ($records as $record) { + $this->surrealConnection()->insertRecord( + table: (string) $this->from, + values: $record, + ); + } + + return true; + } + + public function insertGetId(array $values, $sequence = null): int|string + { + return $this->surrealConnection()->insertRecordAndReturnId( + table: (string) $this->from, + values: $values, + keyName: is_string($sequence) && $sequence !== '' ? $sequence : 'id', + ); + } + + public function update(array $values): int + { + if ($values === []) { + return 0; + } + + return $this->surrealConnection()->updateRecords( + table: (string) $this->from, + values: $values, + wheres: $this->wheres ?? [], + limit: $this->limit, + ); + } + + public function delete($id = null): int + { + $query = $this; + + if ($id !== null) { + $query = clone $this; + $query->where('id', '=', $id); + } + + return $this->surrealConnection()->deleteRecords( + table: (string) $query->from, + wheres: $query->wheres ?? [], + limit: $query->limit, + ); + } + + public function exists(): bool + { + $query = clone $this; + + return $query->limit(1)->get(['id'])->isNotEmpty(); + } + + public function count($columns = '*'): int + { + return $this->get(Arr::wrap($columns))->count(); + } + + /** + * @param array $values + * @return list> + */ + private function prepareInsertValues(array $values): array + { + if ($values === []) { + return []; + } + + if (array_is_list($values) && isset($values[0]) && is_array($values[0])) { + return array_values(array_map( + static function (mixed $record): array { + if (! is_array($record)) { + throw new RuntimeException('Surreal bulk inserts expect each record payload to be an array.'); + } + + /** @var array $record */ + return $record; + }, + $values, + )); + } + + /** @var array $values */ + return [$values]; + } + + /** + * @param array $columns + * @return list + */ + private function resolveColumns(array $columns): array + { + return array_values(array_map(function (mixed $column): string { + if ($column instanceof Expression) { + return (string) $column->getValue($this->grammar); + } + + return (string) $column; + }, $columns)); + } + + private function surrealConnection(): SurrealSchemaConnection + { + $connection = $this->getConnection(); + + if (! $connection instanceof SurrealSchemaConnection) { + throw new RuntimeException('SurrealQueryBuilder requires a SurrealSchemaConnection instance.'); + } + + return $connection; + } +} diff --git a/app/Services/Surreal/Schema/SurrealSchemaConnection.php b/app/Services/Surreal/Schema/SurrealSchemaConnection.php index a983e09..a80e184 100644 --- a/app/Services/Surreal/Schema/SurrealSchemaConnection.php +++ b/app/Services/Surreal/Schema/SurrealSchemaConnection.php @@ -2,18 +2,41 @@ namespace App\Services\Surreal\Schema; +use App\Services\Surreal\Query\SurrealQueryBuilder; use App\Services\Surreal\SurrealCliClient; use App\Services\Surreal\SurrealConnection; use App\Services\Surreal\SurrealHttpClient; use App\Services\Surreal\SurrealRuntimeManager; +use Carbon\CarbonImmutable; use Closure; -use Generator; +use DateTimeInterface; use Illuminate\Database\Connection; +use Illuminate\Database\Query\Grammars\Grammar as QueryGrammar; +use Illuminate\Database\Query\Processors\Processor; use Illuminate\Http\Client\Factory; +use Illuminate\Support\Arr; +use JsonException; use RuntimeException; class SurrealSchemaConnection extends Connection { + private const SEQUENCE_TABLE = '__katra_sequences'; + + /** + * @var list + */ + private const SUPPORTED_WHERE_OPERATORS = [ + '=', + '!=', + '<>', + '<', + '>', + '<=', + '>=', + 'LIKE', + 'NOT LIKE', + ]; + private ?SurrealSchemaBuilder $schemaBuilder = null; public function __construct( @@ -59,9 +82,23 @@ public static function fromConfig(array $config, string $name): self }); } + public function query(): SurrealQueryBuilder + { + return new SurrealQueryBuilder( + $this, + $this->getQueryGrammar(), + $this->getPostProcessor(), + ); + } + public function statement($query, $bindings = []): bool { - return $this->manager->statement((string) $query); + $this->ensureNoBindings($bindings); + $this->runSurrealQuery((string) $query); + + $this->recordsHaveBeenModified(); + + return true; } public function getDriverName(): string @@ -71,27 +108,51 @@ public function getDriverName(): string public function select($query, $bindings = [], $useReadPdo = true, array $fetchUsing = []): array { - throw $this->unsupportedOperation('select queries'); + $this->ensureNoBindings($bindings); + + return array_map( + static fn (array $row): object => (object) $row, + $this->normalizeRecordSet( + Arr::get($this->runSurrealQuery((string) $query), '0', []), + null, + ['*'], + ), + ); } - public function cursor($query, $bindings = [], $useReadPdo = true, array $fetchUsing = []): Generator + public function cursor($query, $bindings = [], $useReadPdo = true, array $fetchUsing = []): \Generator { - throw $this->unsupportedOperation('cursors'); + foreach ($this->select($query, $bindings, $useReadPdo, $fetchUsing) as $record) { + yield $record; + } } public function insert($query, $bindings = []): bool { - throw $this->unsupportedOperation('insert queries'); + return $this->statement($query, $bindings); } public function update($query, $bindings = []): int { - throw $this->unsupportedOperation('update queries'); + return $this->affectingStatement($query, $bindings); } public function delete($query, $bindings = []): int { - throw $this->unsupportedOperation('delete queries'); + return $this->affectingStatement($query, $bindings); + } + + public function affectingStatement($query, $bindings = []): int + { + $this->ensureNoBindings($bindings); + + $rows = $this->normalizeRecordSet( + Arr::get($this->runSurrealQuery((string) $query), '0', []), + ); + + $this->recordsHaveBeenModified($rows !== []); + + return count($rows); } public function transaction(Closure $callback, $attempts = 1): mixed @@ -138,11 +199,159 @@ public function runtimeManager(): SurrealRuntimeManager return $this->runtimeManager; } + /** + * @param list $columns + * @param array> $wheres + * @param array> $orders + * @return list> + */ + public function selectRecords(string $table, array $columns, array $wheres = [], array $orders = [], ?int $limit = null, ?int $offset = null): array + { + $query = sprintf( + 'SELECT %s FROM %s', + $this->compileSelectColumns($columns), + $this->normalizeIdentifier($table), + ); + $whereClause = $this->compileWhereClause($table, $wheres); + + if ($whereClause !== null) { + $query .= ' WHERE '.$whereClause; + } + + $orderClause = $this->compileOrderClause($orders); + + if ($orderClause !== null) { + $query .= ' ORDER BY '.$orderClause; + } + + if ($limit !== null) { + $query .= ' LIMIT '.max(0, $limit); + } + + if ($offset !== null) { + $query .= ' START '.max(0, $offset); + } + + $query .= ';'; + + return $this->normalizeRecordSet( + Arr::get($this->runSurrealQuery($query), '0', []), + $table, + $columns, + ); + } + + /** + * @param array $values + * @return array + */ + public function insertRecord(string $table, array $values): array + { + $key = $values['id'] ?? $this->nextKey($table); + + return $this->createRecord( + table: $table, + key: $key, + values: Arr::except($values, ['id']), + ); + } + + public function insertRecordAndReturnId(string $table, array $values, string $keyName = 'id'): int|string + { + $key = $values[$keyName] ?? $this->nextKey($table); + + $this->createRecord( + table: $table, + key: $key, + values: Arr::except($values, [$keyName]), + ); + + return $key; + } + + /** + * @param array $values + * @param array> $wheres + */ + public function updateRecords(string $table, array $values, array $wheres = [], ?int $limit = null): int + { + if ($values === []) { + return 0; + } + + $recordKey = $this->recordKeyFromWheres($table, $wheres); + + if ($recordKey !== null) { + $query = sprintf( + 'UPDATE %s MERGE %s;', + $this->recordSelector($table, $recordKey), + $this->encodeMap($values), + ); + + return count($this->normalizeRecordSet(Arr::get($this->runSurrealQuery($query), '0', []), $table)); + } + + $whereClause = $this->compileWhereClause($table, $wheres); + + if ($whereClause === null) { + throw new RuntimeException('Surreal updates without a where clause are not supported by this driver yet.'); + } + + $query = sprintf( + 'UPDATE %s WHERE %s MERGE %s%s;', + $this->normalizeIdentifier($table), + $whereClause, + $this->encodeMap($values), + $limit !== null ? ' LIMIT '.max(0, $limit) : '', + ); + + return count($this->normalizeRecordSet(Arr::get($this->runSurrealQuery($query), '0', []), $table)); + } + + /** + * @param array> $wheres + */ + public function deleteRecords(string $table, array $wheres = [], ?int $limit = null): int + { + $recordKey = $this->recordKeyFromWheres($table, $wheres); + + if ($recordKey !== null) { + $query = sprintf('DELETE %s;', $this->recordSelector($table, $recordKey)); + + return count($this->normalizeRecordSet(Arr::get($this->runSurrealQuery($query), '0', []), $table)); + } + + $whereClause = $this->compileWhereClause($table, $wheres); + + if ($whereClause === null) { + throw new RuntimeException('Surreal deletes without a where clause are not supported by this driver yet.'); + } + + $query = sprintf( + 'DELETE %s WHERE %s%s;', + $this->normalizeIdentifier($table), + $whereClause, + $limit !== null ? ' LIMIT '.max(0, $limit) : '', + ); + + return count($this->normalizeRecordSet(Arr::get($this->runSurrealQuery($query), '0', []), $table)); + } + protected function getDefaultSchemaGrammar(): SurrealSchemaGrammar { return new SurrealSchemaGrammar($this); } + protected function getDefaultQueryGrammar(): QueryGrammar + { + return new QueryGrammar($this); + } + + protected function getDefaultPostProcessor(): Processor + { + return new Processor; + } + private function unsupportedOperation(string $operation): RuntimeException { return new RuntimeException(sprintf( @@ -150,4 +359,453 @@ private function unsupportedOperation(string $operation): RuntimeException $operation, )); } + + /** + * @return list + */ + private function runSurrealQuery(string $query): array + { + if (! $this->runtimeManager->ensureReady()) { + throw new RuntimeException('The SurrealDB runtime is not available for query operations.'); + } + + return app(SurrealHttpClient::class)->runQuery( + endpoint: (string) $this->getConfig('endpoint'), + namespace: (string) $this->getConfig('namespace'), + database: (string) $this->getConfig('database'), + username: (string) $this->getConfig('username'), + password: (string) $this->getConfig('password'), + query: $query, + ); + } + + /** + * @param array $bindings + */ + private function ensureNoBindings(array $bindings): void + { + if ($bindings !== []) { + throw new RuntimeException('Parameterized bindings are not supported on the current Surreal raw query path yet.'); + } + } + + /** + * @param list $columns + * @return list> + */ + private function normalizeRecordSet(mixed $statement, ?string $table = null, array $columns = ['*']): array + { + if (! is_array($statement)) { + return []; + } + + $values = array_values($statement); + + if ($values === []) { + return []; + } + + $firstValue = $values[0]; + + if (is_array($firstValue) && $this->isAssociative($firstValue)) { + return array_map( + fn (array $record): array => $this->normalizeRecord($record, $table, $columns), + $values, + ); + } + + if (is_array($firstValue) && ! $this->isAssociative($firstValue)) { + return array_map( + fn (array $record): array => $this->normalizeRecord($record, $table, $columns), + $firstValue, + ); + } + + return []; + } + + /** + * @param array $record + * @param list $columns + * @return array + */ + private function normalizeRecord(array $record, ?string $table, array $columns): array + { + if (isset($record['id']) && is_string($record['id']) && $table !== null) { + $record['id'] = $this->extractRecordKey($table, $record['id']); + } + + if ($columns === ['*']) { + return $record; + } + + return Arr::only($record, $columns); + } + + /** + * @param array> $wheres + */ + private function compileWhereClause(string $table, array $wheres): ?string + { + if ($wheres === []) { + return null; + } + + $segments = []; + + foreach ($wheres as $index => $where) { + $boolean = strtoupper((string) ($where['boolean'] ?? 'and')); + $prefix = $index === 0 ? '' : $boolean.' '; + + $segments[] = $prefix.$this->compileWhereSegment($table, $where); + } + + return implode(' ', $segments); + } + + /** + * @param array $where + */ + private function compileWhereSegment(string $table, array $where): string + { + return match ($where['type'] ?? null) { + 'Basic' => $this->compileBasicWhere($table, $where), + 'Nested' => $this->compileNestedWhere($table, $where), + 'Null' => sprintf('%s = NONE', $this->normalizeColumn((string) $where['column'])), + 'NotNull' => sprintf('%s != NONE', $this->normalizeColumn((string) $where['column'])), + 'In' => $this->compileInWhere($table, $where, false), + 'NotIn' => $this->compileInWhere($table, $where, true), + default => throw new RuntimeException(sprintf( + 'The current Surreal query driver does not support [%s] where clauses yet.', + (string) ($where['type'] ?? 'unknown'), + )), + }; + } + + /** + * @param array $where + */ + private function compileBasicWhere(string $table, array $where): string + { + $column = $this->normalizeColumn((string) $where['column']); + $operator = $this->normalizeOperator((string) ($where['operator'] ?? '=')); + $value = $where['value'] ?? null; + + $encodedValue = $column === 'id' + ? $this->recordSelector($table, $value) + : $this->encodeLiteral($value); + + return sprintf('%s %s %s', $column, $operator, $encodedValue); + } + + /** + * @param list $columns + */ + private function compileSelectColumns(array $columns): string + { + if ($columns === ['*']) { + return '*'; + } + + return implode(', ', array_map(function (string $column): string { + if ($column === '*') { + return '*'; + } + + return $this->normalizeColumn($column); + }, $columns)); + } + + /** + * @param array $where + */ + private function compileNestedWhere(string $table, array $where): string + { + $nestedWheres = $where['query']->wheres ?? []; + $compiled = $this->compileWhereClause($table, $nestedWheres); + + if ($compiled === null) { + return 'true'; + } + + return '('.$compiled.')'; + } + + /** + * @param array $where + */ + private function compileInWhere(string $table, array $where, bool $negated): string + { + $values = array_values(array_filter( + $where['values'] ?? [], + static fn (mixed $value): bool => $value !== null, + )); + + if ($values === []) { + return $negated ? 'true' : 'false'; + } + + $column = $this->normalizeColumn((string) $where['column']); + $comparisonOperator = $negated ? '!=' : '='; + + $segments = array_map(function (mixed $value) use ($column, $comparisonOperator, $table): string { + $encodedValue = $column === 'id' + ? $this->recordSelector($table, $value) + : $this->encodeLiteral($value); + + return sprintf('%s %s %s', $column, $comparisonOperator, $encodedValue); + }, $values); + + return '('.implode($negated ? ' AND ' : ' OR ', $segments).')'; + } + + /** + * @param array> $orders + */ + private function compileOrderClause(array $orders): ?string + { + if ($orders === []) { + return null; + } + + $segments = []; + + foreach ($orders as $order) { + if (($order['type'] ?? 'Basic') !== 'Basic') { + throw new RuntimeException('The current Surreal query driver only supports basic order clauses.'); + } + + $segments[] = sprintf( + '%s %s', + $this->normalizeColumn((string) $order['column']), + strtoupper((string) ($order['direction'] ?? 'asc')), + ); + } + + return implode(', ', $segments); + } + + private function normalizeOperator(string $operator): string + { + $normalized = strtoupper(trim($operator)); + + if (! in_array($normalized, self::SUPPORTED_WHERE_OPERATORS, true)) { + throw new RuntimeException(sprintf( + 'The current Surreal query driver does not support the [%s] operator.', + $operator, + )); + } + + return $normalized; + } + + /** + * @param array> $wheres + */ + private function recordKeyFromWheres(string $table, array $wheres): mixed + { + foreach ($wheres as $where) { + if (($where['type'] ?? null) !== 'Basic') { + continue; + } + + if ($this->normalizeColumn((string) $where['column']) !== 'id') { + continue; + } + + if ((string) ($where['operator'] ?? '=') !== '=') { + continue; + } + + return $this->extractRecordKey($table, (string) $this->normalizeRecordIdentifier($table, $where['value'])); + } + + return null; + } + + /** + * @param array $values + * @return array + */ + private function createRecord(string $table, mixed $key, array $values): array + { + $query = sprintf( + 'CREATE ONLY %s CONTENT %s;', + $this->recordSelector($table, $key), + $this->encodeMap($values), + ); + + return $this->normalizeRecordSet( + Arr::get($this->runSurrealQuery($query), '0', []), + $table, + )[0] ?? throw new RuntimeException(sprintf('Failed to create the Surreal record for table [%s].', $table)); + } + + private function nextKey(string $table): int + { + $result = Arr::get($this->runSurrealQuery(sprintf( + 'UPSERT ONLY %s SET value += 1 RETURN VALUE value;', + $this->recordSelector(self::SEQUENCE_TABLE, $table), + )), '0.0'); + + if (! is_int($result) && ! ctype_digit((string) $result)) { + throw new RuntimeException(sprintf( + 'Unable to generate the next numeric id for table [%s].', + $table, + )); + } + + return (int) $result; + } + + private function normalizeIdentifier(string $identifier): string + { + if (! preg_match('/^[A-Za-z0-9_]+$/', $identifier)) { + throw new RuntimeException(sprintf('The Surreal identifier [%s] contains unsupported characters.', $identifier)); + } + + return $identifier; + } + + private function normalizeColumn(string $column): string + { + $column = str_contains($column, '.') ? (string) last(explode('.', $column)) : $column; + + return $this->normalizeIdentifier($column); + } + + private function normalizeRecordIdentifier(string $table, mixed $value): string + { + if (is_string($value) && str_starts_with($value, $table.':')) { + return $value; + } + + if (is_string($value) && preg_match('/^[A-Za-z0-9_-]+$/', $value)) { + return sprintf('%s:%s', $table, $value); + } + + if (is_int($value) || is_float($value) || (is_string($value) && ctype_digit($value))) { + return sprintf('%s:%s', $table, (string) $value); + } + + throw new RuntimeException(sprintf('The Surreal record id [%s] contains unsupported characters.', (string) $value)); + } + + private function recordSelector(string $table, mixed $value): string + { + $recordIdentifier = $this->normalizeRecordIdentifier($table, $value); + [$recordTable, $recordKey] = explode(':', $recordIdentifier, 2); + + $keyLiteral = ctype_digit($recordKey) + ? $recordKey + : $this->encodeLiteral($recordKey); + + return sprintf( + 'type::record(%s, %s)', + $this->encodeLiteral($recordTable), + $keyLiteral, + ); + } + + private function extractRecordKey(string $table, string $recordId): int|string + { + $normalizedRecordId = preg_replace('/^([A-Za-z0-9_]+):`(.+)`$/', '$1:$2', $recordId) ?? $recordId; + $prefix = $this->normalizeIdentifier($table).':'; + + if (! str_starts_with($normalizedRecordId, $prefix)) { + return $normalizedRecordId; + } + + $key = substr($normalizedRecordId, strlen($prefix)); + + return ctype_digit($key) ? (int) $key : $key; + } + + private function encodeLiteral(mixed $value): string + { + if ($value === null) { + return 'NONE'; + } + + if (is_array($value) && $this->isAssociative($value)) { + return $this->encodeMap($value); + } + + try { + $encoded = json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } catch (JsonException $exception) { + throw new RuntimeException('Unable to encode the Surreal query payload.', previous: $exception); + } + + if (! is_string($encoded)) { + throw new RuntimeException('Unable to encode the Surreal query payload.'); + } + + return $encoded; + } + + /** + * @param array $values + */ + private function encodeMap(array $values): string + { + $segments = []; + + foreach ($values as $key => $value) { + $segments[] = sprintf( + '%s: %s', + $this->normalizeColumn((string) $key), + $this->encodeValue($key, $value), + ); + } + + return '{'.implode(', ', $segments).'}'; + } + + private function encodeValue(string|int $key, mixed $value): string + { + if ($value instanceof DateTimeInterface) { + return $this->encodeDateTimeLiteral( + CarbonImmutable::instance($value)->utc()->format(DATE_ATOM), + ); + } + + if (is_string($value) && is_string($key) && $this->looksLikeDateTimeColumn($key)) { + try { + return $this->encodeDateTimeLiteral( + CarbonImmutable::parse($value, config('app.timezone'))->utc()->format(DATE_ATOM), + ); + } catch (\Throwable) { + // Fall through to the generic JSON literal path when the value is not a real datetime string. + } + } + + if (is_array($value) && $this->isAssociative($value)) { + return $this->encodeMap($value); + } + + return $this->encodeLiteral($value); + } + + private function encodeDateTimeLiteral(string $value): string + { + return sprintf("d'%s'", $value); + } + + private function looksLikeDateTimeColumn(string $column): bool + { + return str_ends_with($column, '_at'); + } + + /** + * @param array $value + */ + private function isAssociative(array $value): bool + { + if ($value === []) { + return false; + } + + return array_keys($value) !== range(0, count($value) - 1); + } } diff --git a/tests/Feature/SurrealEloquentDriverTest.php b/tests/Feature/SurrealEloquentDriverTest.php new file mode 100644 index 0000000..be3d154 --- /dev/null +++ b/tests/Feature/SurrealEloquentDriverTest.php @@ -0,0 +1,211 @@ +isAvailable()) { + $this->markTestSkipped('The `surreal` CLI is not available in this environment.'); + } + + $storagePath = storage_path('app/surrealdb/eloquent-driver-test-'.Str::uuid()); + $originalDefaultConnection = config('database.default'); + $originalMigrationConnection = config('database.migrations.connection'); + + File::deleteDirectory($storagePath); + File::ensureDirectoryExists(dirname($storagePath)); + + try { + $server = retryStartingSurrealEloquentServer($client, $storagePath); + + config()->set('database.default', 'surreal'); + config()->set('database.migrations.connection', null); + config()->set('surreal.host', '127.0.0.1'); + config()->set('surreal.port', $server['port']); + config()->set('surreal.endpoint', $server['endpoint']); + config()->set('surreal.username', 'root'); + config()->set('surreal.password', 'root'); + config()->set('surreal.namespace', 'katra'); + config()->set('surreal.database', 'eloquent_driver_test'); + config()->set('surreal.storage_engine', 'surrealkv'); + config()->set('surreal.storage_path', $storagePath); + config()->set('surreal.runtime', 'local'); + config()->set('surreal.autostart', false); + + app()->forgetInstance(SurrealConnection::class); + app()->forgetInstance(SurrealRuntimeManager::class); + DB::purge('surreal'); + app()->forgetInstance('migration.repository'); + app()->forgetInstance('migrator'); + + $migrateExitCode = Artisan::call('migrate', [ + '--force' => true, + '--realpath' => true, + '--path' => database_path('migrations/0001_01_01_000000_create_users_table.php'), + ]); + + expect($migrateExitCode)->toBe(0); + + $featureMigrateExitCode = Artisan::call('migrate', [ + '--force' => true, + '--realpath' => true, + '--path' => database_path('migrations/2026_03_21_004800_create_features_table.php'), + ]); + + expect($featureMigrateExitCode)->toBe(0); + + $sessionPayload = json_encode([ + 'url' => 'https://katra.test/?workspace=katra-local', + '_flash' => [ + 'old' => [], + 'new' => ['status'], + ], + ], JSON_THROW_ON_ERROR); + + expect(DB::connection('surreal')->table('sessions')->insert([ + 'id' => 'session-with-slashes', + 'user_id' => null, + 'ip_address' => '127.0.0.1', + 'user_agent' => 'Pest Browser', + 'payload' => $sessionPayload, + 'last_activity' => now()->timestamp, + ]))->toBeTrue(); + + $storedSession = DB::connection('surreal')->table('sessions') + ->where('id', 'session-with-slashes') + ->first(); + + expect($storedSession)->not->toBeNull() + ->and($storedSession?->payload)->toBe($sessionPayload) + ->and(data_get($storedSession, 'user_id'))->toBeNull(); + + DB::connection('surreal')->table('features')->insert([ + [ + 'name' => 'ui.desktop.mvp-shell', + 'scope' => 'desktop-ui', + 'value' => 'true', + 'created_at' => now()->toISOString(), + 'updated_at' => now()->toISOString(), + ], + [ + 'name' => 'ui.desktop.workspace-navigation', + 'scope' => 'desktop-ui', + 'value' => 'false', + 'created_at' => now()->toISOString(), + 'updated_at' => now()->toISOString(), + ], + ]); + + $featureRecords = DB::connection('surreal')->table('features') + ->where(fn ($query) => $query->where('name', 'ui.desktop.mvp-shell')->where('scope', 'desktop-ui')) + ->orWhere(fn ($query) => $query->where('name', 'ui.desktop.workspace-navigation')->where('scope', 'desktop-ui')) + ->orderBy('name') + ->get(); + + expect($featureRecords)->toHaveCount(2) + ->and($featureRecords->pluck('name')->all())->toBe([ + 'ui.desktop.mvp-shell', + 'ui.desktop.workspace-navigation', + ]); + + $user = User::query()->create([ + 'name' => 'Derek Bourgeois', + 'email' => 'derek@katra.io', + 'password' => 'password', + ]); + + $secondUser = User::query()->create([ + 'name' => 'Taylor Otwell', + 'email' => 'taylor@katra.io', + 'password' => 'password', + ]); + + expect($user->id)->toBe(1) + ->and($user->exists)->toBeTrue() + ->and($secondUser->id)->toBe(2); + + $queriedUser = User::query()->where('email', 'derek@katra.io')->first(); + + expect($queriedUser)->not->toBeNull() + ->and($queriedUser?->name)->toBe('Derek Bourgeois') + ->and($queriedUser?->id)->toBe(1); + + $foundUser = User::query()->find(1); + + expect($foundUser)->not->toBeNull() + ->and($foundUser?->email)->toBe('derek@katra.io'); + + $user->forceFill(['remember_token' => 'remember-me']); + $user->save(); + + $rememberedUser = User::query()->where('remember_token', 'remember-me')->first(); + + expect($rememberedUser)->not->toBeNull() + ->and($rememberedUser?->id)->toBe(1) + ->and(User::query()->count())->toBe(2) + ->and(User::query()->where('email', 'derek@katra.io')->exists())->toBeTrue(); + + expect($user->delete())->toBeTrue() + ->and(User::query()->find(1))->toBeNull() + ->and(User::query()->find(2))->not->toBeNull(); + } finally { + config()->set('database.default', $originalDefaultConnection); + config()->set('database.migrations.connection', $originalMigrationConnection); + + app()->forgetInstance(SurrealConnection::class); + app()->forgetInstance(SurrealRuntimeManager::class); + DB::purge('surreal'); + app()->forgetInstance('migration.repository'); + app()->forgetInstance('migrator'); + + if (isset($server['process'])) { + $server['process']->stop(1); + } + + File::deleteDirectory($storagePath); + } +}); + +/** + * @return array{endpoint: string, port: int, process: Process} + */ +function retryStartingSurrealEloquentServer(SurrealCliClient $client, string $storagePath, int $attempts = 3): array +{ + $httpClient = app(SurrealHttpClient::class); + $lastException = null; + + for ($attempt = 1; $attempt <= $attempts; $attempt++) { + $port = random_int(10240, 65535); + $endpoint = sprintf('ws://127.0.0.1:%d', $port); + $process = $client->startLocalServer( + bindAddress: sprintf('127.0.0.1:%d', $port), + datastorePath: $storagePath, + username: 'root', + password: 'root', + storageEngine: 'surrealkv', + ); + + if ($httpClient->waitUntilReady($endpoint)) { + return [ + 'endpoint' => $endpoint, + 'port' => $port, + 'process' => $process, + ]; + } + + $process->stop(1); + $lastException = new RuntimeException(sprintf('SurrealDB did not become ready on %s.', $endpoint)); + } + + throw $lastException ?? new RuntimeException('Unable to start the SurrealDB eloquent test runtime.'); +} diff --git a/tests/Unit/SurrealQueryBuilderTest.php b/tests/Unit/SurrealQueryBuilderTest.php new file mode 100644 index 0000000..18c8eff --- /dev/null +++ b/tests/Unit/SurrealQueryBuilderTest.php @@ -0,0 +1,87 @@ +newInstanceWithoutConstructor(); + $prepareInsertValues = new ReflectionMethod(SurrealQueryBuilder::class, 'prepareInsertValues'); + + $prepareInsertValues->setAccessible(true); + + $records = $prepareInsertValues->invoke($builder, [ + 'name' => 'example', + 'meta' => [ + 'channel' => 'desktop', + 'participants' => ['user', 'agent'], + ], + ]); + + expect($records)->toBe([ + [ + 'name' => 'example', + 'meta' => [ + 'channel' => 'desktop', + 'participants' => ['user', 'agent'], + ], + ], + ]); +}); + +test('surreal operators are whitelisted before query compilation', function () { + $connection = (new ReflectionClass(SurrealSchemaConnection::class))->newInstanceWithoutConstructor(); + $normalizeOperator = new ReflectionMethod(SurrealSchemaConnection::class, 'normalizeOperator'); + + $normalizeOperator->setAccessible(true); + + expect($normalizeOperator->invoke($connection, ' like '))->toBe('LIKE'); + + expect(fn () => $normalizeOperator->invoke($connection, '= 1; DELETE users')) + ->toThrow(RuntimeException::class, 'does not support the [= 1; DELETE users] operator'); +}); + +test('surreal select column lists are projected when specific columns are requested', function () { + $connection = (new ReflectionClass(SurrealSchemaConnection::class))->newInstanceWithoutConstructor(); + $compileSelectColumns = new ReflectionMethod(SurrealSchemaConnection::class, 'compileSelectColumns'); + + $compileSelectColumns->setAccessible(true); + + expect($compileSelectColumns->invoke($connection, ['id', 'email']))->toBe('id, email') + ->and($compileSelectColumns->invoke($connection, ['*']))->toBe('*'); +}); + +test('surreal query builder resolves expression columns with the active grammar', function () { + $builder = (new ReflectionClass(SurrealQueryBuilder::class))->newInstanceWithoutConstructor(); + $resolveColumns = new ReflectionMethod(SurrealQueryBuilder::class, 'resolveColumns'); + + $resolveColumns->setAccessible(true); + $builder->grammar = (new ReflectionClass(Grammar::class))->newInstanceWithoutConstructor(); + + $columns = $resolveColumns->invoke($builder, [ + new Expression('count(*) as aggregate'), + 'email', + ]); + + expect($columns)->toBe([ + 'count(*) as aggregate', + 'email', + ]); +}); + +test('surreal datetime encoding normalizes DateTimeInterface values to utc', function () { + $connection = (new ReflectionClass(SurrealSchemaConnection::class))->newInstanceWithoutConstructor(); + $encodeValue = new ReflectionMethod(SurrealSchemaConnection::class, 'encodeValue'); + + $encodeValue->setAccessible(true); + + $encoded = $encodeValue->invoke( + $connection, + 'created_at', + CarbonImmutable::parse('2026-03-24 09:15:00', 'America/New_York'), + ); + + expect($encoded)->toBe("d'2026-03-24T13:15:00+00:00'"); +});