From f85f08a1d0d630725cb7dacf8ef4da0e6646376f Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Mon, 23 Mar 2026 19:37:24 -0400 Subject: [PATCH 1/8] feat: add a surreal eloquent query foundation --- .../Surreal/Query/SurrealQueryBuilder.php | 150 +++++ .../Schema/SurrealSchemaConnection.php | 606 +++++++++++++++++- tests/Feature/SurrealEloquentDriverTest.php | 141 ++++ 3 files changed, 889 insertions(+), 8 deletions(-) create mode 100644 app/Services/Surreal/Query/SurrealQueryBuilder.php create mode 100644 tests/Feature/SurrealEloquentDriverTest.php diff --git a/app/Services/Surreal/Query/SurrealQueryBuilder.php b/app/Services/Surreal/Query/SurrealQueryBuilder.php new file mode 100644 index 0000000..340c2ab --- /dev/null +++ b/app/Services/Surreal/Query/SurrealQueryBuilder.php @@ -0,0 +1,150 @@ + $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: array_values(array_map( + static fn (mixed $column): string => (string) $column, + $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 []; + } + + $first = reset($values); + + if ($first !== false && is_array($first)) { + 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]; + } + + 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..fe0d58e 100644 --- a/app/Services/Surreal/Schema/SurrealSchemaConnection.php +++ b/app/Services/Surreal/Schema/SurrealSchemaConnection.php @@ -2,14 +2,20 @@ 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 @@ -59,9 +65,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 +91,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 +182,157 @@ 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 * FROM %s', $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 + { + if (! array_key_exists('id', $values) || $values['id'] === null || $values['id'] === '') { + throw new RuntimeException(sprintf('Surreal record inserts for [%s] require an explicit id value.', $table)); + } + + return $this->createRecord( + table: $table, + key: $values['id'], + 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 +340,404 @@ 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), + '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 = strtoupper((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 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); + } + + /** + * @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 + { + $rows = $this->normalizeRecordSet( + Arr::get($this->runSurrealQuery(sprintf( + 'SELECT * FROM %s ORDER BY id DESC LIMIT 1;', + $this->normalizeIdentifier($table), + )), '0', []), + $table, + ); + + $current = $rows[0]['id'] ?? 0; + + if (! is_int($current) && ! ctype_digit((string) $current)) { + throw new RuntimeException(sprintf( + 'Unable to generate the next numeric id for table [%s] from the current Surreal records.', + $table, + )); + } + + return (int) $current + 1; + } + + 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 (is_array($value) && $this->isAssociative($value)) { + return $this->encodeMap($value); + } + + try { + $encoded = json_encode($value, JSON_THROW_ON_ERROR); + } 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($value->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..701c8d8 --- /dev/null +++ b/tests/Feature/SurrealEloquentDriverTest.php @@ -0,0 +1,141 @@ +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); + + $user = User::query()->create([ + 'name' => 'Derek Bourgeois', + 'email' => 'derek@katra.io', + 'password' => 'password', + ]); + + expect($user->id)->toBe(1) + ->and($user->exists)->toBeTrue(); + + $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(1) + ->and(User::query()->where('email', 'derek@katra.io')->exists())->toBeTrue(); + + expect($user->delete())->toBeTrue() + ->and(User::query()->find(1))->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.'); +} From a482f5a557ce6002458e3d5ebe32548f8d5a4f1c Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Tue, 24 Mar 2026 01:20:02 -0400 Subject: [PATCH 2/8] fix: allow surreal string literals with slashes --- .../Schema/SurrealSchemaConnection.php | 2 +- tests/Feature/SurrealEloquentDriverTest.php | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/app/Services/Surreal/Schema/SurrealSchemaConnection.php b/app/Services/Surreal/Schema/SurrealSchemaConnection.php index fe0d58e..ff339fa 100644 --- a/app/Services/Surreal/Schema/SurrealSchemaConnection.php +++ b/app/Services/Surreal/Schema/SurrealSchemaConnection.php @@ -666,7 +666,7 @@ private function encodeLiteral(mixed $value): string } try { - $encoded = json_encode($value, JSON_THROW_ON_ERROR); + $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); } diff --git a/tests/Feature/SurrealEloquentDriverTest.php b/tests/Feature/SurrealEloquentDriverTest.php index 701c8d8..26bda63 100644 --- a/tests/Feature/SurrealEloquentDriverTest.php +++ b/tests/Feature/SurrealEloquentDriverTest.php @@ -56,6 +56,30 @@ expect($migrateExitCode)->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' => 0, + '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); + $user = User::query()->create([ 'name' => 'Derek Bourgeois', 'email' => 'derek@katra.io', From c90e064b1dadf85949a156e91579282aee43c6fe Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Tue, 24 Mar 2026 01:27:26 -0400 Subject: [PATCH 3/8] fix: encode surreal null values as none --- app/Services/Surreal/Schema/SurrealSchemaConnection.php | 4 ++++ tests/Feature/SurrealEloquentDriverTest.php | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/Services/Surreal/Schema/SurrealSchemaConnection.php b/app/Services/Surreal/Schema/SurrealSchemaConnection.php index ff339fa..88e6014 100644 --- a/app/Services/Surreal/Schema/SurrealSchemaConnection.php +++ b/app/Services/Surreal/Schema/SurrealSchemaConnection.php @@ -661,6 +661,10 @@ private function extractRecordKey(string $table, string $recordId): int|string private function encodeLiteral(mixed $value): string { + if ($value === null) { + return 'NONE'; + } + if (is_array($value) && $this->isAssociative($value)) { return $this->encodeMap($value); } diff --git a/tests/Feature/SurrealEloquentDriverTest.php b/tests/Feature/SurrealEloquentDriverTest.php index 26bda63..c8c86a8 100644 --- a/tests/Feature/SurrealEloquentDriverTest.php +++ b/tests/Feature/SurrealEloquentDriverTest.php @@ -66,7 +66,7 @@ expect(DB::connection('surreal')->table('sessions')->insert([ 'id' => 'session-with-slashes', - 'user_id' => 0, + 'user_id' => null, 'ip_address' => '127.0.0.1', 'user_agent' => 'Pest Browser', 'payload' => $sessionPayload, @@ -78,7 +78,8 @@ ->first(); expect($storedSession)->not->toBeNull() - ->and($storedSession?->payload)->toBe($sessionPayload); + ->and($storedSession?->payload)->toBe($sessionPayload) + ->and(data_get($storedSession, 'user_id'))->toBeNull(); $user = User::query()->create([ 'name' => 'Derek Bourgeois', From 50300c9703d7baa1b9d7f9cf77446a1f45c44c10 Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Tue, 24 Mar 2026 01:30:13 -0400 Subject: [PATCH 4/8] fix: support surreal nested where groups --- .../Schema/SurrealSchemaConnection.php | 16 ++++++++ tests/Feature/SurrealEloquentDriverTest.php | 39 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/app/Services/Surreal/Schema/SurrealSchemaConnection.php b/app/Services/Surreal/Schema/SurrealSchemaConnection.php index 88e6014..d18d0f1 100644 --- a/app/Services/Surreal/Schema/SurrealSchemaConnection.php +++ b/app/Services/Surreal/Schema/SurrealSchemaConnection.php @@ -451,6 +451,7 @@ 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), @@ -478,6 +479,21 @@ private function compileBasicWhere(string $table, array $where): string return sprintf('%s %s %s', $column, $operator, $encodedValue); } + /** + * @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 */ diff --git a/tests/Feature/SurrealEloquentDriverTest.php b/tests/Feature/SurrealEloquentDriverTest.php index c8c86a8..81d91db 100644 --- a/tests/Feature/SurrealEloquentDriverTest.php +++ b/tests/Feature/SurrealEloquentDriverTest.php @@ -56,6 +56,14 @@ 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' => [ @@ -81,6 +89,37 @@ ->and($storedSession?->payload)->toBe($sessionPayload) ->and(data_get($storedSession, 'user_id'))->toBeNull(); + DB::connection('surreal')->table('features')->insert([ + [ + 'id' => 1, + 'name' => 'ui.desktop.mvp-shell', + 'scope' => 'desktop-ui', + 'value' => 'true', + 'created_at' => now()->toISOString(), + 'updated_at' => now()->toISOString(), + ], + [ + 'id' => 2, + '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('id') + ->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', From 5eb17ccfb860745448b73c2a7cc12ac768d720e5 Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Tue, 24 Mar 2026 01:32:33 -0400 Subject: [PATCH 5/8] fix: auto-generate surreal ids for inserts --- app/Services/Surreal/Schema/SurrealSchemaConnection.php | 6 ++---- tests/Feature/SurrealEloquentDriverTest.php | 4 +--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/app/Services/Surreal/Schema/SurrealSchemaConnection.php b/app/Services/Surreal/Schema/SurrealSchemaConnection.php index d18d0f1..569f118 100644 --- a/app/Services/Surreal/Schema/SurrealSchemaConnection.php +++ b/app/Services/Surreal/Schema/SurrealSchemaConnection.php @@ -226,13 +226,11 @@ public function selectRecords(string $table, array $columns, array $wheres = [], */ public function insertRecord(string $table, array $values): array { - if (! array_key_exists('id', $values) || $values['id'] === null || $values['id'] === '') { - throw new RuntimeException(sprintf('Surreal record inserts for [%s] require an explicit id value.', $table)); - } + $key = $values['id'] ?? $this->nextKey($table); return $this->createRecord( table: $table, - key: $values['id'], + key: $key, values: Arr::except($values, ['id']), ); } diff --git a/tests/Feature/SurrealEloquentDriverTest.php b/tests/Feature/SurrealEloquentDriverTest.php index 81d91db..e681cc6 100644 --- a/tests/Feature/SurrealEloquentDriverTest.php +++ b/tests/Feature/SurrealEloquentDriverTest.php @@ -91,7 +91,6 @@ DB::connection('surreal')->table('features')->insert([ [ - 'id' => 1, 'name' => 'ui.desktop.mvp-shell', 'scope' => 'desktop-ui', 'value' => 'true', @@ -99,7 +98,6 @@ 'updated_at' => now()->toISOString(), ], [ - 'id' => 2, 'name' => 'ui.desktop.workspace-navigation', 'scope' => 'desktop-ui', 'value' => 'false', @@ -111,7 +109,7 @@ $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('id') + ->orderBy('name') ->get(); expect($featureRecords)->toHaveCount(2) From 5216b5b3f740d04ec5e7b2f45ed2a0d51534f0fa Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Tue, 24 Mar 2026 02:03:44 -0400 Subject: [PATCH 6/8] fix: harden surreal query driver edges --- .../Surreal/Query/SurrealQueryBuilder.php | 4 +- .../Schema/SurrealSchemaConnection.php | 76 +++++++++++++++---- tests/Feature/SurrealEloquentDriverTest.php | 14 +++- tests/Unit/SurrealQueryBuilderTest.php | 51 +++++++++++++ 4 files changed, 125 insertions(+), 20 deletions(-) create mode 100644 tests/Unit/SurrealQueryBuilderTest.php diff --git a/app/Services/Surreal/Query/SurrealQueryBuilder.php b/app/Services/Surreal/Query/SurrealQueryBuilder.php index 340c2ab..bc5356a 100644 --- a/app/Services/Surreal/Query/SurrealQueryBuilder.php +++ b/app/Services/Surreal/Query/SurrealQueryBuilder.php @@ -117,9 +117,7 @@ private function prepareInsertValues(array $values): array return []; } - $first = reset($values); - - if ($first !== false && is_array($first)) { + 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)) { diff --git a/app/Services/Surreal/Schema/SurrealSchemaConnection.php b/app/Services/Surreal/Schema/SurrealSchemaConnection.php index 569f118..14770a3 100644 --- a/app/Services/Surreal/Schema/SurrealSchemaConnection.php +++ b/app/Services/Surreal/Schema/SurrealSchemaConnection.php @@ -20,6 +20,23 @@ 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( @@ -190,7 +207,11 @@ public function runtimeManager(): SurrealRuntimeManager */ public function selectRecords(string $table, array $columns, array $wheres = [], array $orders = [], ?int $limit = null, ?int $offset = null): array { - $query = sprintf('SELECT * FROM %s', $this->normalizeIdentifier($table)); + $query = sprintf( + 'SELECT %s FROM %s', + $this->compileSelectColumns($columns), + $this->normalizeIdentifier($table), + ); $whereClause = $this->compileWhereClause($table, $wheres); if ($whereClause !== null) { @@ -467,7 +488,7 @@ private function compileWhereSegment(string $table, array $where): string private function compileBasicWhere(string $table, array $where): string { $column = $this->normalizeColumn((string) $where['column']); - $operator = strtoupper((string) ($where['operator'] ?? '=')); + $operator = $this->normalizeOperator((string) ($where['operator'] ?? '=')); $value = $where['value'] ?? null; $encodedValue = $column === 'id' @@ -477,6 +498,24 @@ private function compileBasicWhere(string $table, array $where): string 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 */ @@ -546,6 +585,20 @@ private function compileOrderClause(array $orders): ?string 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 */ @@ -590,24 +643,19 @@ private function createRecord(string $table, mixed $key, array $values): array private function nextKey(string $table): int { - $rows = $this->normalizeRecordSet( - Arr::get($this->runSurrealQuery(sprintf( - 'SELECT * FROM %s ORDER BY id DESC LIMIT 1;', - $this->normalizeIdentifier($table), - )), '0', []), - $table, - ); - - $current = $rows[0]['id'] ?? 0; + $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($current) && ! ctype_digit((string) $current)) { + if (! is_int($result) && ! ctype_digit((string) $result)) { throw new RuntimeException(sprintf( - 'Unable to generate the next numeric id for table [%s] from the current Surreal records.', + 'Unable to generate the next numeric id for table [%s].', $table, )); } - return (int) $current + 1; + return (int) $result; } private function normalizeIdentifier(string $identifier): string diff --git a/tests/Feature/SurrealEloquentDriverTest.php b/tests/Feature/SurrealEloquentDriverTest.php index e681cc6..be3d154 100644 --- a/tests/Feature/SurrealEloquentDriverTest.php +++ b/tests/Feature/SurrealEloquentDriverTest.php @@ -124,8 +124,15 @@ '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($user->exists)->toBeTrue() + ->and($secondUser->id)->toBe(2); $queriedUser = User::query()->where('email', 'derek@katra.io')->first(); @@ -145,11 +152,12 @@ expect($rememberedUser)->not->toBeNull() ->and($rememberedUser?->id)->toBe(1) - ->and(User::query()->count())->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(1))->toBeNull() + ->and(User::query()->find(2))->not->toBeNull(); } finally { config()->set('database.default', $originalDefaultConnection); config()->set('database.migrations.connection', $originalMigrationConnection); diff --git a/tests/Unit/SurrealQueryBuilderTest.php b/tests/Unit/SurrealQueryBuilderTest.php new file mode 100644 index 0000000..5952cea --- /dev/null +++ b/tests/Unit/SurrealQueryBuilderTest.php @@ -0,0 +1,51 @@ +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('*'); +}); From 821ab9571df49502bad7ca4f60ea6136c83f0609 Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Tue, 24 Mar 2026 02:23:28 -0400 Subject: [PATCH 7/8] fix: polish surreal query compilation --- .../Surreal/Query/SurrealQueryBuilder.php | 20 ++++++++--- .../Schema/SurrealSchemaConnection.php | 4 ++- tests/Unit/SurrealQueryBuilderTest.php | 36 +++++++++++++++++++ 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/app/Services/Surreal/Query/SurrealQueryBuilder.php b/app/Services/Surreal/Query/SurrealQueryBuilder.php index bc5356a..861e36e 100644 --- a/app/Services/Surreal/Query/SurrealQueryBuilder.php +++ b/app/Services/Surreal/Query/SurrealQueryBuilder.php @@ -24,10 +24,7 @@ public function get($columns = ['*']): Collection $rows = $this->surrealConnection()->selectRecords( table: (string) $this->from, - columns: array_values(array_map( - static fn (mixed $column): string => (string) $column, - $this->columns, - )), + columns: $this->resolveColumns($this->columns), wheres: $this->wheres ?? [], orders: $this->orders ?? [], limit: $this->limit, @@ -135,6 +132,21 @@ static function (mixed $record): array { 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(); diff --git a/app/Services/Surreal/Schema/SurrealSchemaConnection.php b/app/Services/Surreal/Schema/SurrealSchemaConnection.php index 14770a3..a80e184 100644 --- a/app/Services/Surreal/Schema/SurrealSchemaConnection.php +++ b/app/Services/Surreal/Schema/SurrealSchemaConnection.php @@ -765,7 +765,9 @@ private function encodeMap(array $values): string private function encodeValue(string|int $key, mixed $value): string { if ($value instanceof DateTimeInterface) { - return $this->encodeDateTimeLiteral($value->format(DATE_ATOM)); + return $this->encodeDateTimeLiteral( + CarbonImmutable::instance($value)->utc()->format(DATE_ATOM), + ); } if (is_string($value) && is_string($key) && $this->looksLikeDateTimeColumn($key)) { diff --git a/tests/Unit/SurrealQueryBuilderTest.php b/tests/Unit/SurrealQueryBuilderTest.php index 5952cea..18c8eff 100644 --- a/tests/Unit/SurrealQueryBuilderTest.php +++ b/tests/Unit/SurrealQueryBuilderTest.php @@ -2,6 +2,9 @@ use App\Services\Surreal\Query\SurrealQueryBuilder; use App\Services\Surreal\Schema\SurrealSchemaConnection; +use Carbon\CarbonImmutable; +use Illuminate\Database\Query\Expression; +use Illuminate\Database\Query\Grammars\Grammar; test('single-row inserts can contain array values without being treated as bulk inserts', function () { $builder = (new ReflectionClass(SurrealQueryBuilder::class))->newInstanceWithoutConstructor(); @@ -49,3 +52,36 @@ 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'"); +}); From ac67d4e5f5a675fc7f460ad74b39204649dadb5e Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Tue, 24 Mar 2026 02:26:41 -0400 Subject: [PATCH 8/8] fix: align surreal probe with http result shape --- app/Console/Commands/SurrealProbeCommand.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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.');