From d5609fd7ae230c9cd9b75b712187b1c378cb80f1 Mon Sep 17 00:00:00 2001 From: Adro Morelos Date: Thu, 20 Aug 2026 09:47:12 +0200 Subject: [PATCH 01/13] Drop the old client, it can't even boot anymore --- .coveralls.yml | 2 - .travis.yml | 36 --- docs/.gitkeep | 0 extra/Entities/Bridge.php | 69 ----- extra/Entities/Call.php | 47 --- extra/Entities/Entity.php | 54 ---- extra/Entities/EntityInterface.php | 20 -- extra/Entities/Repository/Repository.php | 149 ---------- .../Repository/RepositoryInterface.php | 20 -- extra/Entities/functions.php | 17 -- src/Clients/GuzzleClient.php | 151 ---------- src/Firebase.php | 271 ------------------ src/Interfaces/ClientInterface.php | 72 ----- src/Interfaces/FirebaseInterface.php | 102 ------- tests/Clients/FakeClient.php | 95 ------ tests/Clients/FakeGuzzle.php | 38 --- tests/Entities/BridgeTest.php | 36 --- tests/Entities/CallTest.php | 37 --- tests/Entities/EntityTest.php | 44 --- tests/Entities/FunctionTest.php | 29 -- .../Repository/NoClassUserRepository.php | 11 - .../Repository/NoClinetUserRepository.php | 15 - tests/Entities/Repository/RepositoryTest.php | 105 ------- tests/Entities/Repository/UserRepository.php | 18 -- tests/Entities/User.php | 18 -- tests/FirebaseTest.php | 123 -------- tests/bootstrap.php | 21 -- 27 files changed, 1600 deletions(-) delete mode 100644 .coveralls.yml delete mode 100644 .travis.yml delete mode 100644 docs/.gitkeep delete mode 100644 extra/Entities/Bridge.php delete mode 100644 extra/Entities/Call.php delete mode 100644 extra/Entities/Entity.php delete mode 100644 extra/Entities/EntityInterface.php delete mode 100644 extra/Entities/Repository/Repository.php delete mode 100644 extra/Entities/Repository/RepositoryInterface.php delete mode 100644 extra/Entities/functions.php delete mode 100644 src/Clients/GuzzleClient.php delete mode 100644 src/Firebase.php delete mode 100644 src/Interfaces/ClientInterface.php delete mode 100644 src/Interfaces/FirebaseInterface.php delete mode 100644 tests/Clients/FakeClient.php delete mode 100644 tests/Clients/FakeGuzzle.php delete mode 100644 tests/Entities/BridgeTest.php delete mode 100644 tests/Entities/CallTest.php delete mode 100644 tests/Entities/EntityTest.php delete mode 100644 tests/Entities/FunctionTest.php delete mode 100644 tests/Entities/Repository/NoClassUserRepository.php delete mode 100644 tests/Entities/Repository/NoClinetUserRepository.php delete mode 100644 tests/Entities/Repository/RepositoryTest.php delete mode 100644 tests/Entities/Repository/UserRepository.php delete mode 100644 tests/Entities/User.php delete mode 100644 tests/FirebaseTest.php delete mode 100644 tests/bootstrap.php diff --git a/.coveralls.yml b/.coveralls.yml deleted file mode 100644 index 30b618e..0000000 --- a/.coveralls.yml +++ /dev/null @@ -1,2 +0,0 @@ -coverage_clover: build/logs/clover.xml -json_path: build/logs/coveralls-upload.json diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index fd73350..0000000 --- a/.travis.yml +++ /dev/null @@ -1,36 +0,0 @@ -language: php - -php: - - 5.6 - - 7.0 - - 7.1 - - hhvm - -sudo: required - -install: composer install - -matrix: - allow_failures: - - php: hhvm - -before_script: - # navigate out of module directory to prevent blown stack by recursive module lookup - - wget https://phar.phpunit.de/phpunit-5.6.phar - - chmod +x phpunit-5.6.phar - - sudo mv phpunit-5.6.phar /usr/local/bin/phpunit - - phpunit --version - - - wget http://getcomposer.org/composer.phar - - php composer.phar install - - php composer.phar require php-coveralls/php-coveralls - -script: - - mkdir -p build/logs - - phpunit -c phpunit.xml.dist - - phpunit --coverage-clover build/logs/clover.xml - -after_script: - - if [[ "$TRAVIS_PHP_VERSION" == '5.6' ]]; then travis_retry php vendor/bin/php-coveralls -v; fi - - if [[ "$TRAVIS_PHP_VERSION" == '7.0' ]]; then travis_retry php vendor/bin/php-coveralls -v; fi - - if [[ "$TRAVIS_PHP_VERSION" == '7.1' ]]; then travis_retry php vendor/bin/php-coveralls -v; fi diff --git a/docs/.gitkeep b/docs/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/extra/Entities/Bridge.php b/extra/Entities/Bridge.php deleted file mode 100644 index e530876..0000000 --- a/extra/Entities/Bridge.php +++ /dev/null @@ -1,69 +0,0 @@ - - */ - -namespace PhpFirebase\Entities; - -use ReflectionObject; -use ReflectionProperty; - -class Bridge -{ - /** - * @var object - */ - protected $object; - - /** - * @var array - */ - protected $properties = []; - - /** - * Make non-public members of the given object accessible. - * - * @param object $object.- Object which members we'll make accessible - */ - public function __construct($object) - { - $this->object = $object; - $reflected = new ReflectionObject($this->object); - $this->properties = []; - - $properties = $reflected->getProperties( - ReflectionProperty::IS_PROTECTED | ReflectionProperty::IS_PRIVATE | ReflectionProperty::IS_PUBLIC - ); - - foreach ($properties as $property) { - $property->setAccessible(true); - $this->properties[$property->getName()] = $property; - } - } - - public function getProperties() - { - return $this->properties; - } - - /** - * Returns a property of $this->object. - * - * @param string $name - * - * @return mixed - */ - public function __get($name) - { - // If the property is exposed (with reflection) then we use getValue() - // to access it, else we access it directly - if (isset($this->properties[$name])) { - return $this->properties[$name]->getValue($this->object); - } - } -} diff --git a/extra/Entities/Call.php b/extra/Entities/Call.php deleted file mode 100644 index bccce7c..0000000 --- a/extra/Entities/Call.php +++ /dev/null @@ -1,47 +0,0 @@ - - */ - -namespace PhpFirebase\Entities; - -use BadMethodCallException; -use ReflectionClass; -use ReflectionProperty; - -trait Call -{ - /** - * Get or Set property. - * - * @param string $name Name of the method - * @param array $arguments Arguments - * - * @throws BadMethodCallException If the $name is not a property - */ - public function __call($name, $arguments) - { - if (true !== property_exists($this, $name)) { - throw new BadMethodCallException(sprintf( - 'The metod "%s" does not exist', - $name - )); - } - if ($arguments && count($arguments) == 1) { - $reflect = new ReflectionClass($this); - $props = $reflect->getProperties(ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED); - foreach ($props as $prop) { - if ($prop->getName() == $name) { - $this->{$name} = $arguments[0]; - } - } - } - - return $this->{$name}; - } -} diff --git a/extra/Entities/Entity.php b/extra/Entities/Entity.php deleted file mode 100644 index 804aa9e..0000000 --- a/extra/Entities/Entity.php +++ /dev/null @@ -1,54 +0,0 @@ - - */ - -namespace PhpFirebase\Entities; - -class Entity implements EntityInterface -{ - use Call; - - public function __construct(array $properties) - { - $this->fill($properties); - } - - public function toArray() - { - $bridge = new Bridge($this); - $array = []; - foreach ($bridge->getProperties() as $key => $value) { - $array[$key] = $this->$key; - } - - return json_decode(json_encode($array), true); - } - - public function toJson() - { - return json_encode($this->toArray()); - } - - public static function fromJson($json) - { - $array = json_decode($json, true); - $class = get_called_class(); - - return new $class($array); - } - - protected function fill(array $properties) - { - foreach ($properties as $key => $value) { - if (true == property_exists($this, $key)) { - $this->$key = $value; - } - } - } -} diff --git a/extra/Entities/EntityInterface.php b/extra/Entities/EntityInterface.php deleted file mode 100644 index 26b6b2e..0000000 --- a/extra/Entities/EntityInterface.php +++ /dev/null @@ -1,20 +0,0 @@ - - */ - -namespace PhpFirebase\Entities; - -interface EntityInterface -{ - public function toArray(); - - public function toJson(); - - public static function fromJson($string); -} diff --git a/extra/Entities/Repository/Repository.php b/extra/Entities/Repository/Repository.php deleted file mode 100644 index 6fe65c2..0000000 --- a/extra/Entities/Repository/Repository.php +++ /dev/null @@ -1,149 +0,0 @@ - - */ - -namespace PhpFirebase\Entities\Repository; - -use PhpFirebase\Clients\GuzzleClient; -use PhpFirebase\Entities\Entity; -use PhpFirebase\Entities\EntityInterface; -use PhpFirebase\Firebase; - -abstract class Repository implements RepositoryInterface -{ - protected $class; - - protected $firebase; - - protected $base; - - protected $token; - - protected $model = []; - - protected $endpoint = null; - - protected $query = []; - - public function __construct($base, $token = null, $endpoint = null) - { - if ($base instanceof Firebase) { - $this->firebase = $base; - $this->endpoint = $token; - } else { - $this->base = $base; - $this->token = $token; - - $this->firebase = new Firebase( - $this->base, - $this->token, - new GuzzleClient(['verify' => false]) - ); - - $this->endpoint = $endpoint; - } - - if ($this->class == null) { - $this->class = Entity::class; - } - } - - public function store($entity) - { - if (is_array($entity)) { - foreach ($entity as &$record) { - $id = $record->id() ? $record->id() : guid(); - $record->id($id); - $this->firebase->put($this->endpoint.'/'.$id, $record->toArray()); - $record = $this->find($id); - } - } elseif ($entity instanceof EntityInterface) { - $id = $entity->id() ? $entity->id() : guid(); - $entity->id($id); - $response = $this->firebase->put($this->endpoint.'/'.$id, $entity->toArray()); - $entity = $this->find($id); - } - - return $entity; - } - - public function find($id) - { - $model = (array) $this->firebase->get($this->endpoint.'/'.$id); - $class = $this->class; - $this->model = new $class($model); - - return $this->model; - } - - public function fetch(array $searchCriteria = []) - { - $search = array_merge($this->query, $searchCriteria); - - $records = (array) $this->firebase->get($this->endpoint, $search); - - $entities = []; - - $class = $this->class; - - foreach ($records as $record) { - $record = (array) $record; - $entity = new $class($record); - $entities[$entity->id()] = $entity; - } - - $this->model = $entities; - - return $this->model; - } - - public function get() - { - return $this->model; - } - - public function deleteAll() - { - $this->firebase->delete($this->endpoint); - - return $this; - } - - public function query(array $query = [], $clean = false) - { - if ($clean) { - $this->query = $query; - } else { - $this->query = array_merge($this->query, $query); - } - - return $this; - } - - public function top($limit) - { - $this->query['limitToFirst'] = (int) $limit; - - return $this; - } - - public function tail($limit) - { - $this->query['limitToLast'] = (int) $limit; - - return $this; - } - - public function orderBy($fieldName) - { - $this->query['orderBy'] = json_encode($fieldName); - - return $this; - } -} diff --git a/extra/Entities/Repository/RepositoryInterface.php b/extra/Entities/Repository/RepositoryInterface.php deleted file mode 100644 index c2872e1..0000000 --- a/extra/Entities/Repository/RepositoryInterface.php +++ /dev/null @@ -1,20 +0,0 @@ - - */ - -namespace PhpFirebase\Entities\Repository; - -interface RepositoryInterface -{ - public function store($entity); - - public function find($id); - - public function fetch(array $searchCriteria); -} diff --git a/extra/Entities/functions.php b/extra/Entities/functions.php deleted file mode 100644 index e0ab9cb..0000000 --- a/extra/Entities/functions.php +++ /dev/null @@ -1,17 +0,0 @@ - - */ -function guid() -{ - if (function_exists('com_create_guid') === true) { - return trim(com_create_guid(), '{}'); - } - - return sprintf('%04X%04X-%04X-%04X-%04X-%04X%04X%04X', mt_rand(0, 65535), mt_rand(0, 65535), mt_rand(0, 65535), mt_rand(16384, 20479), mt_rand(32768, 49151), mt_rand(0, 65535), mt_rand(0, 65535), mt_rand(0, 65535)); -} diff --git a/src/Clients/GuzzleClient.php b/src/Clients/GuzzleClient.php deleted file mode 100644 index d7e51a6..0000000 --- a/src/Clients/GuzzleClient.php +++ /dev/null @@ -1,151 +0,0 @@ - - */ - -namespace PhpFirebase\Clients; - -use GuzzleHttp\Client as HttpClient; -use GuzzleHttp\Psr7\Request; -use GuzzleHttp\Psr7\Response; -use PhpFirebase\Interfaces\ClientInterface; -use function GuzzleHttp\Psr7\stream_for; - -/** - * Guzzle Client. - * - * @since 0.1.0 - */ -class GuzzleClient implements ClientInterface -{ - /** - * Guzzle client. - * - * @var \GuzzleHttp\Client - */ - protected $guzzle; - - /** - * Set the the guzzle client. - * - * @param array $options The options to set the defaul - * @param object|null $client Client to make the requests - */ - public function __construct(array $options = [], $client = null) - { - if (!$client) { - $client = new HttpClient($options); - } - - $this->guzzle = $client; - } - - /** - * Create a new GET reuest. - * - * @param string $endpoint The sub endpoint - * @param array $headers Request headers - * - * @return array - */ - public function get($endpoint, $headers = []) - { - $request = new Request('GET', $endpoint, $headers); - - $response = $this->guzzle->send($request); - - return $this->handle($response); - } - - /** - * Create a new POST reuest. - * - * @param string $endpoint The sub endpoint - * @param string|array $data The data to be submited - * @param array $headers Request headers - * - * @return array - */ - public function post($endpoint, $data, $headers = []) - { - $request = new Request('POST', $endpoint, $headers, $data); - - $response = $this->guzzle->send($request); - - return $this->handle($response); - } - - /** - * Create a new PUT reuest. - * - * @param string $endpoint The sub endpoint - * @param string|array $data The data to be submited - * @param array $headers Request headers - * - * @return array - */ - public function put($endpoint, $data, $headers = []) - { - $request = new Request('PUT', $endpoint, $headers, $data); - - $response = $this->guzzle->send($request); - - return $this->handle($response); - } - - /** - * Create a new PATCH reuest. - * - * @param string $endpoint The sub endpoint - * @param string|array $data The data to be submited - * @param array $headers Request headers - * - * @return array - */ - public function patch($endpoint, $data, $headers = []) - { - $request = new Request('PATCH', $endpoint, $headers, $data); - - $response = $this->guzzle->send($request); - - return $this->handle($response); - } - - /** - * Create a new DELETE reuest. - * - * @param string $endpoint The sub endpoint - * @param array $headers Request headers - * - * @return array - */ - public function delete($endpoint, $headers = []) - { - $request = new Request('DELETE', $endpoint, $headers); - - $response = $this->guzzle->send($request); - - return $this->handle($response); - } - - /** - * Handle the response. - * - * @param \GuzzleHttp\Psr7\Response $response The response - * - * @return array - */ - private function handle(Response $response) - { - $stream = stream_for($response->getBody()); - - $data = json_decode($stream->getContents()); - - return $data; - } -} diff --git a/src/Firebase.php b/src/Firebase.php deleted file mode 100644 index a259793..0000000 --- a/src/Firebase.php +++ /dev/null @@ -1,271 +0,0 @@ - - */ - -namespace PhpFirebase; - -use InvalidArgumentException; -use PhpFirebase\Clients\GuzzleClient; -use PhpFirebase\Interfaces\ClientInterface; -use PhpFirebase\Interfaces\FirebaseInterface; - -/** - * Firebase. - * - * @since 0.1.0 - */ -class Firebase implements FirebaseInterface -{ - /** - * Base endpoint. - * - * @var string - */ - protected $base; - - /** - * Token. - * - * @var string - */ - protected $token; - - /** - * Client. - * - * @var \PhpFirebase\Interfaces\ClientInterface - */ - protected $client = null; - - /** - * Response. - * - * @var mixed - */ - protected $response = null; - - /** - * Set the base path for Firebase endpont - * and the token to authenticate. - * - * @param string $base The base endpoint - * @param string $token The token - * @param \PhpFirebase\Interfaces\ClientInterface|null $client Client to make the request - */ - public function __construct($base, $token, ClientInterface $client = null) - { - if (!is_string($base)) { - throw new InvalidArgumentException('Base parameter needs to be string', 1); - } - if (!is_string($token)) { - throw new InvalidArgumentException('Token parameter needs to be string', 1); - } - - $parts = parse_url($base); - - if (!isset($parts['scheme']) || !isset($parts['host'])) { - throw new InvalidArgumentException("The base URL $base is not valid", 1); - } - - $this->base = rtrim($base, '/'); - $this->token = (string) $token; - - if (!$client) { - $client = new GuzzleClient([ - 'base' => $this->base, - 'token' => $this->token, - ]); - } - - $this->setClient($client); - } - - /** - * GET request. - * - * @param string $endpoint The sub endpoint - * @param array $query Query parameters - * - * @return object - */ - public function get($endpoint, $query = []) - { - $endpoint = $this->buildUri($endpoint, $query); - $headers = $this->buildHeaders(); - - $this->response = $this->client->get($endpoint, $headers); - - return $this->response; - } - - /** - * POST request. - * - * @param string $endpoint The sub endpoint - * @param string|array $data The data to be submited - * @param array $query Query parameters - * - * @return object - */ - public function post($endpoint, $data, $query = []) - { - $endpoint = $this->buildUri($endpoint, $query); - $headers = $this->buildHeaders(); - $data = $this->prepareData($data); - - $this->response = $this->client->post($endpoint, $data, $headers); - - return $this->response; - } - - /** - * PUT request. - * - * @param string $endpoint The sub endpoint - * @param string|array $data The data to be submited - * @param array $query Query parameters - * - * @return object - */ - public function put($endpoint, $data, $query = []) - { - $endpoint = $this->buildUri($endpoint, $query); - $headers = $this->buildHeaders(); - $data = $this->prepareData($data); - - $this->response = $this->client->put($endpoint, $data, $headers); - - return $this->response; - } - - /** - * PATCH request. - * - * @param string $endpoint The sub endpoint - * @param string|array $data The data to be submited - * @param array $query Query parameters - * - * @return object - */ - public function patch($endpoint, $data, $query = []) - { - $endpoint = $this->buildUri($endpoint, $query); - $headers = $this->buildHeaders(); - $data = $this->prepareData($data); - - $this->response = $this->client->patch($endpoint, $data, $headers); - - return $this->response; - } - - /** - * DELETE request. - * - * @param string $endpoint The sub endpoint - * @param array $query Query parameters - * - * @return object - */ - public function delete($endpoint, $query = []) - { - $endpoint = $this->buildUri($endpoint, $query); - $headers = $this->buildHeaders(); - - $this->response = $this->client->delete($endpoint, $headers); - - return $this->response; - } - - /** - * Get response. - * - * @return mixed - */ - public function getResponse() - { - return $this->response; - } - - /** - * Get client. - * - * @return \PhpFirebase\Interfaces\ClientInterface - */ - public function getClient() - { - return $this->client; - } - - /** - * Get base endpoint. - * - * @return string - */ - public function getBase() - { - return $this->base; - } - - /** - * Set the client. - * - * @param \PhpFirebase\Interfaces\ClientInterface - */ - protected function setClient(ClientInterface $client) - { - $this->client = $client; - } - - /** - * Convert array|string to json. - * - * @param array $data Data to be converted - * - * @return array - */ - protected function prepareData($data = []) - { - return json_encode($data); - } - - /** - * Create a standard uri based on the end point - * and add the auth token. - * - * @param string $endpoint The sub endpoint - * @param array $options Extra options to be added - * - * @return string - */ - protected function buildUri($endpoint, $options = []) - { - if ($this->token !== '') { - $options['auth'] = $this->token; - } - - return $this->base.'/'.ltrim($endpoint, '/').'.json?'.http_build_query($options, '', '&'); - } - - /** - * Build all headers. - * - * @param array $extraHeaders Extra headers to be added - * - * @return array - */ - protected function buildHeaders($extraHeaders = []) - { - $headers = [ - 'Accept' => 'application/json', - 'Content-Type: application/json', - ]; - - return array_merge($headers, $extraHeaders); - } -} diff --git a/src/Interfaces/ClientInterface.php b/src/Interfaces/ClientInterface.php deleted file mode 100644 index a7ca990..0000000 --- a/src/Interfaces/ClientInterface.php +++ /dev/null @@ -1,72 +0,0 @@ - - */ - -namespace PhpFirebase\Interfaces; - -/** - * Client Interface. - * - * @since 0.1.0 - */ -interface ClientInterface -{ - /** - * GET request. - * - * @param string $endpoint The sub endpoint - * @param array $query Query parameters - * - * @return array - */ - public function get($endpoint, $query = []); - - /** - * POST request. - * - * @param string $endpoint The sub endpoint - * @param string|array $data The data to be submited - * @param array $query Query parameters - * - * @return array - */ - public function post($endpoint, $data, $query = []); - - /** - * PUT request. - * - * @param string $endpoint The sub endpoint - * @param string|array $data The data to be submited - * @param array $query Query parameters - * - * @return array - */ - public function put($endpoint, $data, $query = []); - - /** - * PATCH request. - * - * @param string $endpoint The sub endpoint - * @param string|array $data The data to be submited - * @param array $query Query parameters - * - * @return array - */ - public function patch($endpoint, $data, $query = []); - - /** - * DELETE request. - * - * @param string $endpoint The sub endpoint - * @param array $query Query parameters - * - * @return array - */ - public function delete($endpoint, $query = []); -} diff --git a/src/Interfaces/FirebaseInterface.php b/src/Interfaces/FirebaseInterface.php deleted file mode 100644 index 04145b5..0000000 --- a/src/Interfaces/FirebaseInterface.php +++ /dev/null @@ -1,102 +0,0 @@ - - */ - -namespace PhpFirebase\Interfaces; - -/** - * Base interface. - * - * @since 0.1.0 - */ -interface FirebaseInterface -{ - /** - * Set the base path for Firebase endpont - * and the token to authenticate. - * - * @param string $base The base endpoint - * @param string $token The token - */ - public function __construct($base, $token); - - /** - * GET request. - * - * @param string $endpoint The sub endpoint - * @param array $query Query parameters - * - * @return object - */ - public function get($endpoint, $query = []); - - /** - * POST request. - * - * @param string $endpoint The sub endpoint - * @param string|array $data The data to be submited - * @param array $query Query parameters - * - * @return object - */ - public function post($endpoint, $data, $query = []); - - /** - * PUT request. - * - * @param string $endpoint The sub endpoint - * @param string|array $data The data to be submited - * @param array $query Query parameters - * - * @return object - */ - public function put($endpoint, $data, $query = []); - - /** - * PATCH request. - * - * @param string $endpoint The sub endpoint - * @param string|array $data The data to be submited - * @param array $query Query parameters - * - * @return object - */ - public function patch($endpoint, $data, $query = []); - - /** - * DELETE request. - * - * @param string $endpoint The sub endpoint - * @param array $query Query parameters - * - * @return object - */ - public function delete($endpoint, $query = []); - - /** - * Get response. - * - * @return mixed - */ - public function getResponse(); - - /** - * Get client. - * - * @return \PhpFirebase\Interfaces\ClientInterface - */ - public function getClient(); - - /** - * Get base endpoint. - * - * @return string - */ - public function getBase(); -} diff --git a/tests/Clients/FakeClient.php b/tests/Clients/FakeClient.php deleted file mode 100644 index 1097c2b..0000000 --- a/tests/Clients/FakeClient.php +++ /dev/null @@ -1,95 +0,0 @@ - - */ - -namespace PhpFirebase\Clients; - -use GuzzleHttp\Client; -use GuzzleHttp\Exception\RequestException; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Psr7\Request; -use GuzzleHttp\Psr7\Response; -use PhpFirebase\Interfaces\ClientInterface; -use function GuzzleHttp\Psr7\stream_for; - -class FakeClient implements ClientInterface -{ - protected $mock; - - protected $handler; - - protected $guzzle; - - public function __construct() - { - $this->mock = new MockHandler([ - new Response(200, [], 'Hello Firebase GET'), - new Response(200, [], 'Hello Firebase POST'), - new Response(200, [], 'Hello Firebase PUT'), - new Response(200, [], 'Hello Firebase PATCH'), - new Response(200, [], 'Hello Firebase DELETE'), - new Response(202, ['Content-Length' => 0]), - new RequestException('Error Communicating with Server', new Request('GET', 'test')), - ]); - - $this->handler = HandlerStack::create($this->mock); - - $this->guzzle = new Client(['handler' => $this->handler]); - } - - public function get($endpoint, $headers = []) - { - $request = new Request('GET', $endpoint, $headers); - $response = $this->guzzle->send($request); - - return $this->handle($response); - } - - public function post($endpoint, $data, $headers = []) - { - $request = new Request('POST', $endpoint, $headers, $data); - $response = $this->guzzle->send($request); - - return $this->handle($response); - } - - public function put($endpoint, $data, $headers = []) - { - $request = new Request('PUT', $endpoint, $headers, $data); - $response = $this->guzzle->send($request); - - return $this->handle($response); - } - - public function patch($endpoint, $data, $headers = []) - { - $request = new Request('PATCH', $endpoint, $headers, $data); - $response = $this->guzzle->send($request); - - return $this->handle($response); - } - - public function delete($endpoint, $headers = []) - { - $request = new Request('DELETE', $endpoint, $headers); - $response = $this->guzzle->send($request); - - return $this->handle($response); - } - - private function handle(Response $response) - { - $stream = stream_for($response->getBody()); - - $data = json_decode($stream->getContents()); - - return $data; - } -} diff --git a/tests/Clients/FakeGuzzle.php b/tests/Clients/FakeGuzzle.php deleted file mode 100644 index 8f67656..0000000 --- a/tests/Clients/FakeGuzzle.php +++ /dev/null @@ -1,38 +0,0 @@ - - */ - -namespace PhpFirebase\Clients; - -use GuzzleHttp\Client; -use GuzzleHttp\Exception\RequestException; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Psr7\Request; -use GuzzleHttp\Psr7\Response; - -class FakeGuzzle extends Client -{ - public function __construct(array $config = []) - { - $mock = new MockHandler([ - new Response(200, [], 'Hello Firebase GET'), - new Response(200, [], 'Hello Firebase POST'), - new Response(200, [], 'Hello Firebase PUT'), - new Response(200, [], 'Hello Firebase PATCH'), - new Response(200, [], 'Hello Firebase DELETE'), - new Response(202, ['Content-Length' => 0]), - new RequestException('Error Communicating with Server', new Request('GET', 'test')), - ]); - - $handler = HandlerStack::create($mock); - - parent::__construct(['handler' => $handler]); - } -} diff --git a/tests/Entities/BridgeTest.php b/tests/Entities/BridgeTest.php deleted file mode 100644 index e2eb10d..0000000 --- a/tests/Entities/BridgeTest.php +++ /dev/null @@ -1,36 +0,0 @@ - - */ - -namespace PhpFirebase\Entities; - -use PHPUnit\Framework\TestCase; - -class BridgeTest extends TestCase -{ - public function testConstruct() - { - $u = new User(['id' => 1]); - - $b = new Bridge($u); - - $this->assertInstanceOf(Bridge::class, $b); - } - - public function testGet() - { - $u = new User(['id' => 1, 'name' => 'adro']); - - $b = new Bridge($u); - - $this->assertSame(1, $b->id); - $this->assertSame('adro', $b->name); - $this->assertSame(null, $b->test); - } -} diff --git a/tests/Entities/CallTest.php b/tests/Entities/CallTest.php deleted file mode 100644 index e2cd045..0000000 --- a/tests/Entities/CallTest.php +++ /dev/null @@ -1,37 +0,0 @@ - - */ - -namespace PhpFirebase\Entities; - -use PHPUnit\Framework\TestCase; - -class CallTest extends TestCase -{ - public function testCall() - { - $u = new User(['id' => 1]); - - $u->id(2); - - $this->assertSame(['id' => 2, 'name' => null], $u->toArray()); - - $this->assertSame(2, $u->id()); - } - - public function testCalllException() - { - $this->expectException(\BadMethodCallException::class); - $this->expectExceptionMessage('The metod "test" does not exist'); - - $u = new User(['id' => 1]); - - $u->test(); - } -} diff --git a/tests/Entities/EntityTest.php b/tests/Entities/EntityTest.php deleted file mode 100644 index be34ffe..0000000 --- a/tests/Entities/EntityTest.php +++ /dev/null @@ -1,44 +0,0 @@ - - */ - -namespace PhpFirebase\Entities; - -use PHPUnit\Framework\TestCase; - -class EntityTest extends TestCase -{ - public function testEntityConstructor() - { - $e = new User(['id' => 1]); - - $this->assertInstanceOf(Entity::class, $e); - } - - public function testEntityToArray() - { - $e = new User(['id' => 1]); - - $this->assertSame(['id' => 1, 'name' => null], $e->toArray()); - } - - public function testEntityToJson() - { - $e = new User(['id' => 1]); - - $this->assertSame('{"id":1,"name":null}', $e->toJson()); - } - - public function testEntityFromJson() - { - $e = User::fromJson('{"id":1,"name":null}'); - - $this->assertSame(['id' => 1, 'name' => null], $e->toArray()); - } -} diff --git a/tests/Entities/FunctionTest.php b/tests/Entities/FunctionTest.php deleted file mode 100644 index 8ac8e9b..0000000 --- a/tests/Entities/FunctionTest.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ - -namespace PhpFirebase\Entities; - -use PHPUnit\Framework\TestCase; - -class FunctionTest extends TestCase -{ - public function testGuid() - { - $guid = guid(); - - $true = false; - - if (1 === preg_match('/^\{?[A-Z0-9]{8}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{12}\}?$/', $guid)) { - $true = true; - } - - $this->assertTrue($true); - } -} diff --git a/tests/Entities/Repository/NoClassUserRepository.php b/tests/Entities/Repository/NoClassUserRepository.php deleted file mode 100644 index dde0435..0000000 --- a/tests/Entities/Repository/NoClassUserRepository.php +++ /dev/null @@ -1,11 +0,0 @@ -class = User::class; - - parent::__construct($firebase, '/users'); - } -} diff --git a/tests/Entities/Repository/RepositoryTest.php b/tests/Entities/Repository/RepositoryTest.php deleted file mode 100644 index c5de741..0000000 --- a/tests/Entities/Repository/RepositoryTest.php +++ /dev/null @@ -1,105 +0,0 @@ - - */ - -namespace PhpFirebase\Entities; - -use PhpFirebase\Entities\Repository\NoClassUserRepository; -use PhpFirebase\Entities\Repository\NoClinetUserRepository; -use PhpFirebase\Entities\Repository\UserRepository; -use PhpFirebase\Firebase; -use PHPUnit\Framework\TestCase; - -class RepositoryTest extends TestCase -{ - public function testConstruct() - { - $firebase = $this->createMock(Firebase::class); - $r = new UserRepository(); - - $this->assertInstanceOf(UserRepository::class, $r); - - $r = new NoClassUserRepository($firebase); - - $this->assertInstanceOf(NoClassUserRepository::class, $r); - } - - public function testStore() - { - $firebase = $this->createMock(Firebase::class); - $firebase->method('get') - ->willReturn(['id' => 1, 'name' => null]); - - $repo = new NoClinetUserRepository($firebase); - - $u = new User(['id' => 1]); - - $ur = $repo->store($u); - - $this->assertSame(['id' => 1, 'name' => null], $ur->toArray()); - - $ur = $repo->store([$u]); - - $this->assertSame(['id' => 1, 'name' => null], $ur[0]->toArray()); - } - - public function testFetch() - { - $firebase = $this->createMock(Firebase::class); - $firebase->method('get') - ->willReturn([['id' => 1, 'name' => null]]); - - $repo = new NoClinetUserRepository($firebase); - - $users = $repo->fetch(['id' => 1]); - - $this->assertSame(['id' => 1, 'name' => null], $users[1]->toArray()); - } - - public function testGet() - { - $firebase = $this->createMock(Firebase::class); - $firebase->method('get') - ->willReturn(['id' => 1, 'name' => null]); - - $repo = new NoClinetUserRepository($firebase); - - $u = new User(['id' => 1]); - - $ur = $repo->store($u); - - $this->assertSame(['id' => 1, 'name' => null], $repo->get()->toArray()); - } - - public function testQueryToTailOrderBy() - { - $firebase = $this->createMock(Firebase::class); - - $query = (new NoClinetUserRepository($firebase))->query(['id' => 1]); - - $this->assertInstanceOf(NoClinetUserRepository::class, $query); - - $this->assertInstanceOf(NoClinetUserRepository::class, $query->query(['id' => 1], true)); - - $this->assertInstanceOf(NoClinetUserRepository::class, $query->top(1)); - - $this->assertInstanceOf(NoClinetUserRepository::class, $query->tail(1)); - - $this->assertInstanceOf(NoClinetUserRepository::class, $query->orderBy('id')); - } - - public function testDeleteAll() - { - $firebase = $this->createMock(Firebase::class); - - $repo = new NoClinetUserRepository($firebase); - - $this->assertInstanceOf(NoClinetUserRepository::class, $repo->deleteAll()); - } -} diff --git a/tests/Entities/Repository/UserRepository.php b/tests/Entities/Repository/UserRepository.php deleted file mode 100644 index 4e98e37..0000000 --- a/tests/Entities/Repository/UserRepository.php +++ /dev/null @@ -1,18 +0,0 @@ -class = User::class; - - parent::__construct($base, $token, '/users'); - } -} diff --git a/tests/Entities/User.php b/tests/Entities/User.php deleted file mode 100644 index b5ec1ed..0000000 --- a/tests/Entities/User.php +++ /dev/null @@ -1,18 +0,0 @@ - - */ - -namespace PhpFirebase\Entities; - -class User extends Entity -{ - protected $id; - - public $name; -} diff --git a/tests/FirebaseTest.php b/tests/FirebaseTest.php deleted file mode 100644 index b1abb78..0000000 --- a/tests/FirebaseTest.php +++ /dev/null @@ -1,123 +0,0 @@ - - */ - -namespace PhpFirebase; - -use PhpFirebase\Clients\FakeClient; -use PhpFirebase\Clients\FakeGuzzle; -use PhpFirebase\Clients\GuzzleClient; -use PHPUnit\Framework\TestCase; - -class FirebaseTest extends TestCase -{ - public function testFirebaseBase() - { - $base = 'https://example.com/'; - $token = 's0m3t0k3n'; - - $fb = new Firebase($base, $token); - - $this->assertEquals('https://example.com', $fb->getBase()); - } - - public function testFirebaseClient() - { - $base = 'https://example.com/'; - $token = 's0m3t0k3n'; - $client = new FakeClient(); - - $fb = new Firebase($base, $token, $client); - - $this->assertSame($client, $fb->getClient()); - - $response = $fb->get('/'); - - $this->assertNull($response); - - $response = $fb->post('/', ['key'=>'value']); - - $this->assertNull($response); - - $response = $fb->put('/', ['key'=>'value']); - - $this->assertNull($response); - - $response = $fb->patch('/', ['key'=>'value']); - - $this->assertNull($response); - - $response = $fb->delete('/'); - - $this->assertNull($response); - - $same = $fb->getResponse(); - - $this->assertEquals($response, $same); - } - - public function testBaseException() - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Base parameter needs to be string'); - - $fb = new Firebase([], ''); - } - - public function testUrlException() - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('The base URL firebase is not valid'); - - $fb = new Firebase('firebase', ''); - } - - public function testTokenException() - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Token parameter needs to be string'); - - $fb = new Firebase('https://example.com/', []); - } - - public function testGuzzleClient() - { - $base = 'https://example.com/'; - $token = 's0m3t0k3n'; - $guzzle = new FakeGuzzle(); - - $client = new GuzzleClient([], $guzzle); - - $fb = new Firebase($base, $token, $client); - - $response = $fb->get('/'); - - $this->assertNull($response); - - $response = $fb->post('/', ['key'=>'value']); - - $this->assertNull($response); - - $response = $fb->put('/', ['key'=>'value']); - - $this->assertNull($response); - - $response = $fb->patch('/', ['key'=>'value']); - - $this->assertNull($response); - - $response = $fb->delete('/'); - - $this->assertNull($response); - - $same = $fb->getResponse(); - - $this->assertEquals($response, $same); - } -} diff --git a/tests/bootstrap.php b/tests/bootstrap.php deleted file mode 100644 index 4d9d072..0000000 --- a/tests/bootstrap.php +++ /dev/null @@ -1,21 +0,0 @@ - - */ -function includeIfExists($file) -{ - return file_exists($file) ? include $file : false; -} - -if ((!$loader = includeIfExists(__DIR__.'/../vendor/autoload.php')) && (!$loader = includeIfExists(__DIR__.'/../../../autoload.php'))) { - echo 'You must set up the project dependencies using `composer install`'.PHP_EOL. - 'See https://getcomposer.org/download/ for instructions on installing Composer'.PHP_EOL; - exit(1); -} - -return $loader; From f80038a0b92ce1ac674e3e7459c30d1a13495b87 Mon Sep 17 00:00:00 2001 From: Adro Morelos Date: Thu, 20 Aug 2026 11:23:55 +0200 Subject: [PATCH 02/13] Add Entity and the hydrator --- src/Entity.php | 124 +++++++++++ src/EntityInterface.php | 43 ++++ src/Exception/InvalidEntity.php | 84 ++++++++ src/Exception/PhpFirebaseException.php | 14 ++ src/Hydration/Hydrator.php | 280 +++++++++++++++++++++++++ 5 files changed, 545 insertions(+) create mode 100644 src/Entity.php create mode 100644 src/EntityInterface.php create mode 100644 src/Exception/InvalidEntity.php create mode 100644 src/Exception/PhpFirebaseException.php create mode 100644 src/Hydration/Hydrator.php diff --git a/src/Entity.php b/src/Entity.php new file mode 100644 index 0000000..3b39daf --- /dev/null +++ b/src/Entity.php @@ -0,0 +1,124 @@ +id; + } + + final public function hasId(): bool + { + return $this->id !== null; + } + + final public function withId(string $id): static + { + $clone = clone $this; + $clone->id = $id; + + return $clone; + } + + /** + * @return array + */ + public function toArray(): array + { + $data = $this->toDatabase(); + + if ($this->id !== null) { + $data = ['id' => $this->id] + $data; + } + + return $data; + } + + /** + * @return array + */ + public function toDatabase(): array + { + return (new Hydrator())->extract($this); + } + + /** + * @param array $data + */ + public static function fromArray(array $data, ?string $id = null): static + { + $id ??= isset($data['id']) && is_scalar($data['id']) ? (string) $data['id'] : null; + unset($data['id']); + + $entity = (new Hydrator())->hydrate(static::class, $data); + + return $id !== null ? $entity->withId($id) : $entity; + } + + /** + * @throws InvalidEntity if the JSON does not describe an object + */ + public static function fromJson(string $json, ?string $id = null): static + { + $data = json_decode($json, true, flags: JSON_THROW_ON_ERROR); + + if (!is_array($data)) { + throw new InvalidEntity(sprintf( + 'Expected the JSON to describe a %s object, got %s.', + static::class, + get_debug_type($data), + )); + } + + return static::fromArray($data, $id); + } + + public function toJson(int $flags = 0): string + { + return json_encode($this, $flags | JSON_THROW_ON_ERROR); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/EntityInterface.php b/src/EntityInterface.php new file mode 100644 index 0000000..1128fc7 --- /dev/null +++ b/src/EntityInterface.php @@ -0,0 +1,43 @@ + + */ + public function toArray(): array; + + /** + * The entity as the database stores it: the payload without the id, since + * the id is the key the payload is stored under. + * + * @return array + */ + public function toDatabase(): array; + + /** + * @param array $data + */ + public static function fromArray(array $data, ?string $id = null): static; +} diff --git a/src/Exception/InvalidEntity.php b/src/Exception/InvalidEntity.php new file mode 100644 index 0000000..c4f9653 --- /dev/null +++ b/src/Exception/InvalidEntity.php @@ -0,0 +1,84 @@ + $properties + */ + public static function partiallyConstructed(string $class, array $properties): self + { + return new self(sprintf( + '%s declares a constructor that does not cover %s, so reading a record would silently leave ' + .'%s at their defaults and the next save would overwrite the stored values. Either give the class ' + .'no constructor and let its properties be filled directly, or promote every persisted property ' + .'into the constructor.', + $class, + implode(', ', array_map(static fn (string $p): string => '$'.$p, $properties)), + count($properties) === 1 ? 'it' : 'them', + )); + } + + public static function constructorNotPublic(string $class): self + { + return new self(sprintf( + '%s has a non-public constructor, which leaves no way to build it. Make it public, or remove it ' + .'so its properties can be filled directly.', + $class, + )); + } + + public static function mappingFailed(string $class, MappingError $error): self + { + $reasons = []; + + foreach ($error->messages()->errors() as $message) { + $reasons[] = sprintf('%s: %s', $message->path(), $message->toString()); + } + + return new self( + sprintf('Cannot build a %s from the stored record. %s', $class, implode(' ', $reasons)), + previous: $error, + ); + } + + public static function cannotHydrate(string $class, string $property, string $reason): self + { + return new self(sprintf('Cannot hydrate %s::$%s: %s', $class, $property, $reason)); + } + + public static function notAnObjectMap(string $class, string $path): self + { + return new self(sprintf( + 'Expected the value at "%s" to be a map of %s records, got a scalar.', + $path, + $class, + )); + } +} diff --git a/src/Exception/PhpFirebaseException.php b/src/Exception/PhpFirebaseException.php new file mode 100644 index 0000000..4e28b3b --- /dev/null +++ b/src/Exception/PhpFirebaseException.php @@ -0,0 +1,14 @@ + + */ + private static array $checked = []; + + /** + * @return array + */ + public function extract(object $entity): array + { + return $this->extractObject($entity, 0); + } + + /** + * @return array + */ + private function extractObject(object $object, int $depth): array + { + if ($depth > self::MAX_DEPTH) { + throw new InvalidEntity(sprintf( + 'Giving up %d levels deep in %s. Records this deeply nested are usually a cycle.', + self::MAX_DEPTH, + $object::class, + )); + } + + $data = []; + + foreach ($this->properties($object::class) as $property) { + if (!$property->isInitialized($object)) { + continue; + } + + $value = $property->getValue($object); + + if ($value === null) { + continue; + } + + $data[$property->getName()] = $this->extractValue($value, $depth); + } + + return $data; + } + + /** + * @template T of object + * + * @param class-string $class + * @param array $data + * + * @return T + */ + public function hydrate(string $class, array $data): object + { + $this->guardConstructor($class); + + try { + return self::mapper()->map($class, $data); + } catch (MappingError $error) { + throw InvalidEntity::mappingFailed($class, $error); + } + } + + /** + * Valinor builds an object through its constructor when it has one, and + * fills its properties directly when it does not. Those two rules are both + * fine; what is not fine is a constructor that covers only some of the + * persisted properties, because the rest are then quietly skipped. That + * would lose data on read and overwrite it on the next write, so it is + * refused here instead. + * + * @param class-string $class + */ + private function guardConstructor(string $class): void + { + if (isset(self::$checked[$class])) { + return; + } + + $constructor = (new ReflectionClass($class))->getConstructor(); + + if ($constructor !== null) { + if (!$constructor->isPublic()) { + throw InvalidEntity::constructorNotPublic($class); + } + + $parameters = array_map( + static fn (\ReflectionParameter $parameter): string => $parameter->getName(), + $constructor->getParameters(), + ); + + $uncovered = []; + + foreach ($this->properties($class) as $property) { + if (!in_array($property->getName(), $parameters, true)) { + $uncovered[] = $property->getName(); + } + } + + if ($uncovered !== []) { + throw InvalidEntity::partiallyConstructed($class, $uncovered); + } + } + + self::$checked[$class] = true; + } + + private static function mapper(): TreeMapper + { + // Building a mapper is expensive and the configuration never varies, + // so one is shared for the lifetime of the process. + return self::$mapper ??= (new MapperBuilder()) + // Stored records routinely carry fields an entity no longer + // declares, and lack fields it has since gained. + ->allowSuperfluousKeys() + ->allowUndefinedValues() + // Records come back from a schemaless database, so an untyped + // `array` property is a normal thing to declare rather than an + // error. Adding a `@var list` docblock buys validation back. + ->allowPermissiveTypes() + // JSON round-trips numbers loosely, so an int-backed enum can come + // back as 2.0 and an int property as "36". + ->allowScalarValueCasting() + // Registering a constructor replaces valinor's built-in ones, so + // every form a date arrives in has to be spelled out. Scalar + // casting means an integer may reach the string constructor, so + // both agree on what a bare number means. + ->registerConstructor( + static fn (int $milliseconds): DateTimeImmutable => self::fromMilliseconds($milliseconds), + static fn (string $value): DateTimeImmutable => self::parseDate($value), + ) + ->mapper(); + } + + /** + * Firebase resolves a server timestamp to Unix milliseconds, which is not + * how any date parser reads a bare number by default. + * + * @pure + */ + private static function fromMilliseconds(int $milliseconds): DateTimeImmutable + { + // Deterministic despite the checker's doubt: an "@epoch" string and a + // fixed zone never consult the clock. Valinor requires the constructor + // to be pure, so the annotation has to stay. + /** @phpstan-ignore possiblyImpure.new, possiblyImpure.new */ + return (new DateTimeImmutable('@'.intdiv($milliseconds, 1000)))->setTimezone(new DateTimeZone('UTC')); + } + + /** + * @pure + */ + private static function parseDate(string $value): DateTimeImmutable + { + // A run of digits long enough to be an epoch is treated as one; a + // shorter one is a date like "20240301" and is left to the parser. + if (ctype_digit($value) && strlen($value) >= 12) { + return self::fromMilliseconds((int) $value); + } + + // Absolute date strings only; the value comes from stored data, never + // from a relative expression like "now". + /** @phpstan-ignore possiblyImpure.new */ + return new DateTimeImmutable($value); + } + + /** + * Objects of PHP's own classes have no properties worth storing, and + * reading them would quietly produce an empty record rather than an error. + * Anything declared in userland is fair game. + */ + private function guardStorable(object $value): object + { + if ((new ReflectionClass($value))->isInternal()) { + throw new InvalidEntity(sprintf( + 'Cannot store a %s. Convert it before saving, or implement %s on the object holding it.', + $value::class, + JsonSerializable::class, + )); + } + + return $value; + } + + /** + * @param class-string $class + * + * @return list + */ + private function properties(string $class): array + { + $properties = []; + + foreach ((new ReflectionClass($class))->getProperties(self::PERSISTED_MODIFIERS) as $property) { + if ($property->isStatic()) { + continue; + } + + $properties[] = $property; + } + + return $properties; + } + + private function extractValue(mixed $value, int $depth = 0): mixed + { + return match (true) { + // A sentinel is resolved by the connection, not here, so it travels + // through the mapper untouched. + $value instanceof Sentinel => $value, + $value instanceof BackedEnum => $value->value, + $value instanceof DateTimeInterface => $value->format(DateTimeInterface::ATOM), + $value instanceof EntityInterface => $value->toArray(), + $value instanceof JsonSerializable => $value->jsonSerialize(), + // Any other object is read the same way an entity is, so a plain + // value object round-trips: valinor can already build one on the + // way in, and this is what lets it back out again. + is_object($value) => $this->extractObject($this->guardStorable($value), $depth + 1), + is_array($value) => array_map( + fn (mixed $item): mixed => $this->extractValue($item, $depth + 1), + $value, + ), + is_scalar($value) => $value, + default => throw new InvalidEntity(sprintf( + 'Cannot store a value of type %s.', + get_debug_type($value), + )), + }; + } +} From 63269332c40645c8b3763df45168161340bd4347 Mon Sep 17 00:00:00 2001 From: Adro Morelos Date: Thu, 20 Aug 2026 16:05:31 +0200 Subject: [PATCH 03/13] Tests for the entity layer --- tests/ArbitraryEntityTest.php | 160 +++++++++++++++++++++ tests/EntityTest.php | 210 ++++++++++++++++++++++++++++ tests/Fixtures/Address.php | 14 ++ tests/Fixtures/ArrayConnection.php | 126 +++++++++++++++++ tests/Fixtures/ArrayTransaction.php | 29 ++++ tests/Fixtures/Coupon.php | 22 +++ tests/Fixtures/HalfBuilt.php | 20 +++ tests/Fixtures/Level.php | 11 ++ tests/Fixtures/LineItem.php | 16 +++ tests/Fixtures/LooseBag.php | 16 +++ tests/Fixtures/Money.php | 17 +++ tests/Fixtures/Order.php | 26 ++++ tests/Fixtures/OrderStatus.php | 11 ++ tests/Fixtures/Role.php | 11 ++ tests/Fixtures/User.php | 62 ++++++++ tests/Fixtures/UserRepository.php | 23 +++ 16 files changed, 774 insertions(+) create mode 100644 tests/ArbitraryEntityTest.php create mode 100644 tests/EntityTest.php create mode 100644 tests/Fixtures/Address.php create mode 100644 tests/Fixtures/ArrayConnection.php create mode 100644 tests/Fixtures/ArrayTransaction.php create mode 100644 tests/Fixtures/Coupon.php create mode 100644 tests/Fixtures/HalfBuilt.php create mode 100644 tests/Fixtures/Level.php create mode 100644 tests/Fixtures/LineItem.php create mode 100644 tests/Fixtures/LooseBag.php create mode 100644 tests/Fixtures/Money.php create mode 100644 tests/Fixtures/Order.php create mode 100644 tests/Fixtures/OrderStatus.php create mode 100644 tests/Fixtures/Role.php create mode 100644 tests/Fixtures/User.php create mode 100644 tests/Fixtures/UserRepository.php diff --git a/tests/ArbitraryEntityTest.php b/tests/ArbitraryEntityTest.php new file mode 100644 index 0000000..1a65acc --- /dev/null +++ b/tests/ArbitraryEntityTest.php @@ -0,0 +1,160 @@ + + */ + private function orders(): EntityRepository + { + return new EntityRepository(new ArrayConnection(), 'orders', Order::class); + } + + private function order(): Order + { + $order = new Order(); + $order->reference = 'ORD-1'; + $order->status = OrderStatus::Shipped; + $order->total = new Money(1999, 'EUR'); + $order->placedAt = new DateTimeImmutable('2024-03-01T10:00:00+00:00'); + + $line = new LineItem(); + $line->sku = 'AB-1'; + $line->quantity = 2; + $line->price = new Money(999, 'EUR'); + $order->lines = [$line]; + + return $order; + } + + #[Test] + public function a_plain_value_object_is_stored_as_its_properties(): void + { + $payload = $this->order()->toDatabase(); + + $this->assertSame(['amount' => 1999, 'currency' => 'EUR'], $payload['total']); + } + + #[Test] + public function a_plain_value_object_is_read_back_as_itself(): void + { + $order = Order::fromArray(['total' => ['amount' => 1999, 'currency' => 'EUR']]); + + $this->assertInstanceOf(Money::class, $order->total); + $this->assertSame(1999, $order->total->amount); + $this->assertSame('EUR', $order->total->currency); + } + + #[Test] + public function a_whole_aggregate_survives_a_round_trip(): void + { + $orders = $this->orders(); + + $saved = $orders->save($this->order()); + $read = $orders->get((string) $saved->id()); + + $this->assertSame('ORD-1', $read->reference); + $this->assertSame(OrderStatus::Shipped, $read->status); + $this->assertEquals(new Money(1999, 'EUR'), $read->total); + $this->assertEquals($this->order()->placedAt, $read->placedAt); + $this->assertCount(1, $read->lines); + $this->assertSame('AB-1', $read->lines[0]->sku); + $this->assertEquals(new Money(999, 'EUR'), $read->lines[0]->price); + } + + #[Test] + public function nested_entities_inside_a_list_keep_their_type(): void + { + $orders = $this->orders(); + $read = $orders->get((string) $orders->save($this->order())->id()); + + // The docblock promises LineItem; this checks what actually came back, + // since a mapper that ignored it would hand over plain arrays. + $this->assertSame([LineItem::class], array_unique(array_map(get_debug_type(...), $read->lines))); + } + + #[Test] + public function an_entity_built_entirely_through_its_constructor_round_trips(): void + { + // The DDD-shaped alternative: every persisted property is promoted, so + // the object cannot exist half-built. + $coupons = new EntityRepository(new ArrayConnection(), 'coupons', Coupon::class); + + $saved = $coupons->save(new Coupon('SUMMER', 20, new Money(5000, 'EUR'))); + $read = $coupons->get((string) $saved->id()); + + $this->assertSame('SUMMER', $read->code); + $this->assertSame(20, $read->percentOff); + $this->assertEquals(new Money(5000, 'EUR'), $read->minimumSpend); + } + + #[Test] + public function a_constructor_that_covers_only_some_properties_is_refused(): void + { + // Valinor builds through the constructor when there is one, which would + // leave $note at its default and lose whatever was stored. + $this->expectException(InvalidEntity::class); + $this->expectExceptionMessage('$note'); + + HalfBuilt::fromArray(['title' => 'A title', 'note' => 'A note']); + } + + #[Test] + public function an_object_php_itself_owns_cannot_be_stored(): void + { + // A typed property would have been rejected by PHP already; this is the + // loosely typed case, where the mapper is the only thing checking. + $bag = new LooseBag(); + $bag->anything = new \ArrayObject([1, 2]); + + $this->expectException(InvalidEntity::class); + $this->expectExceptionMessage('Cannot store a ArrayObject'); + + $bag->toDatabase(); + } + + #[Test] + public function a_resource_cannot_be_stored(): void + { + $bag = new LooseBag(); + $bag->anything = fopen('php://memory', 'r'); + + try { + $this->expectException(InvalidEntity::class); + $this->expectExceptionMessage('resource'); + + $bag->toDatabase(); + } finally { + if (is_resource($bag->anything)) { + fclose($bag->anything); + } + } + } +} diff --git a/tests/EntityTest.php b/tests/EntityTest.php new file mode 100644 index 0000000..8b4db2e --- /dev/null +++ b/tests/EntityTest.php @@ -0,0 +1,210 @@ +assertNull($user->id()); + $this->assertFalse($user->hasId()); + } + + #[Test] + public function with_id_returns_a_copy_and_leaves_the_original_alone(): void + { + $user = User::named('Ada', 'Lovelace'); + $identified = $user->withId('user-1'); + + $this->assertSame('user-1', $identified->id()); + $this->assertNull($user->id()); + $this->assertNotSame($user, $identified); + } + + #[Test] + public function the_database_payload_leaves_out_the_id(): void + { + $user = User::named('Ada', 'Lovelace')->withId('user-1'); + + $this->assertArrayNotHasKey('id', $user->toDatabase()); + $this->assertSame('user-1', $user->toArray()['id']); + } + + #[Test] + public function private_properties_are_not_persisted(): void + { + $user = User::named('Ada', 'Lovelace'); + + $this->assertArrayNotHasKey('secret', $user->toDatabase()); + } + + #[Test] + public function protected_properties_are_persisted(): void + { + $user = User::named('Ada', 'Lovelace')->withNote('a note'); + + $this->assertSame('a note', $user->toDatabase()['note']); + $this->assertSame('a note', User::fromArray($user->toDatabase())->note()); + } + + #[Test] + public function null_properties_are_left_out_so_they_do_not_delete_keys(): void + { + $payload = User::named('Ada', 'Lovelace')->toDatabase(); + + $this->assertArrayNotHasKey('age', $payload); + $this->assertArrayNotHasKey('joinedAt', $payload); + } + + #[Test] + public function enums_dates_and_nested_entities_survive_a_round_trip(): void + { + $user = User::named('Ada', 'Lovelace'); + $user->age = 36; + $user->role = Role::Admin; + $user->level = Level::High; + $user->joinedAt = new DateTimeImmutable('2024-03-01T10:00:00+00:00'); + $user->address = new Address(); + $user->address->street = '1 Analytical Way'; + $user->address->city = 'London'; + $user->tags = ['maths', 'engines']; + + $payload = $user->toDatabase(); + + $this->assertSame('admin', $payload['role']); + $this->assertSame(2, $payload['level']); + $this->assertSame('2024-03-01T10:00:00+00:00', $payload['joinedAt']); + $this->assertSame(['street' => '1 Analytical Way', 'city' => 'London'], $payload['address']); + + $restored = User::fromArray($payload); + + $this->assertSame(Role::Admin, $restored->role); + $this->assertSame(Level::High, $restored->level); + $this->assertEquals($user->joinedAt, $restored->joinedAt); + $this->assertSame('London', $restored->address?->city); + $this->assertSame(['maths', 'engines'], $restored->tags); + } + + #[Test] + public function an_int_backed_enum_survives_being_read_back_as_a_float(): void + { + $user = User::fromArray(['firstName' => 'Ada', 'level' => 2.0]); + + $this->assertSame(Level::High, $user->level); + } + + #[Test] + public function a_date_is_read_from_unix_milliseconds(): void + { + $user = User::fromArray(['joinedAt' => 1_709_287_200_000]); + + $this->assertSame('2024-03-01T10:00:00+00:00', $user->joinedAt?->format(DateTimeImmutable::ATOM)); + } + + #[Test] + public function a_date_is_read_from_milliseconds_even_as_a_numeric_string(): void + { + $user = User::fromArray(['joinedAt' => '1709287200000']); + + $this->assertSame('2024-03-01T10:00:00+00:00', $user->joinedAt?->format(DateTimeImmutable::ATOM)); + } + + #[Test] + public function a_short_numeric_date_is_left_to_the_parser(): void + { + $user = User::fromArray(['joinedAt' => '2024-03-01']); + + $this->assertSame('2024-03-01', $user->joinedAt?->format('Y-m-d')); + } + + #[Test] + public function a_loosely_typed_number_is_still_read_into_a_typed_property(): void + { + $user = User::fromArray(['age' => '36']); + + $this->assertSame(36, $user->age); + } + + #[Test] + public function hydration_bypasses_the_constructor_and_ignores_unknown_keys(): void + { + $user = User::fromArray(['firstName' => 'Ada', 'unknown' => 'ignored'], 'user-1'); + + $this->assertSame('Ada', $user->firstName); + $this->assertSame('user-1', $user->id()); + $this->assertSame('not persisted', $user->secret()); + } + + #[Test] + public function the_id_is_read_from_the_payload_when_no_key_is_given(): void + { + $this->assertSame('user-1', User::fromArray(['id' => 'user-1'])->id()); + } + + #[Test] + public function the_key_wins_over_an_id_in_the_payload(): void + { + $this->assertSame('key', User::fromArray(['id' => 'stale'], 'key')->id()); + } + + #[Test] + public function it_round_trips_through_json(): void + { + $user = User::named('Ada', 'Lovelace')->withId('user-1'); + + $restored = User::fromJson($user->toJson()); + + $this->assertSame('user-1', $restored->id()); + $this->assertSame('Lovelace', $restored->lastName); + } + + #[Test] + public function json_encoding_an_entity_uses_its_array_form(): void + { + $user = User::named('Ada', 'Lovelace')->withId('user-1'); + + $this->assertSame($user->toArray(), json_decode($user->toJson(), true)); + } + + #[Test] + public function it_rejects_json_that_is_not_an_object(): void + { + $this->expectException(InvalidEntity::class); + + User::fromJson('"a string"'); + } + + #[Test] + public function it_rejects_an_unknown_enum_case(): void + { + $this->expectException(InvalidEntity::class); + $this->expectExceptionMessage("'wizard'"); + + User::fromArray(['role' => 'wizard']); + } + + #[Test] + public function it_rejects_a_nested_entity_that_is_not_a_record(): void + { + $this->expectException(InvalidEntity::class); + + User::fromArray(['address' => 'not a record']); + } +} diff --git a/tests/Fixtures/Address.php b/tests/Fixtures/Address.php new file mode 100644 index 0000000..a618016 --- /dev/null +++ b/tests/Fixtures/Address.php @@ -0,0 +1,14 @@ +>> */ + private array $data = []; + + private int $nextId = 1; + + public function read(string $collection, string $id): ?array + { + return $this->data[$collection][$id] ?? null; + } + + public function readAll(string $collection, Criteria $criteria): array + { + $records = $this->data[$collection] ?? []; + + foreach ($criteria->conditions as $condition) { + $records = array_filter( + $records, + static fn (array $record): bool => self::matches($record[$condition->field] ?? null, $condition->operator, $condition->value), + ); + } + + foreach (array_reverse($criteria->orderings) as $ordering) { + uksort($records, static function (string $a, string $b) use ($ordering, $records): int { + $left = $ordering->isByKey() ? $a : ($records[$a][$ordering->field] ?? null); + $right = $ordering->isByKey() ? $b : ($records[$b][$ordering->field] ?? null); + + $result = $left <=> $right; + + return $ordering->direction === Direction::Descending ? -$result : $result; + }); + } + + if ($criteria->limit !== null) { + $records = array_slice($records, 0, $criteria->limit, preserve_keys: true); + } + + return $records; + } + + public function write(string $collection, string $id, array $data): void + { + $this->data[$collection][$id] = $data; + } + + public function writeMany(string $collection, array $records): void + { + foreach ($records as $id => $data) { + $this->write($collection, (string) $id, $data); + } + } + + public function create(string $collection, array $data): string + { + $id = sprintf('generated-%d', $this->nextId++); + $this->write($collection, $id, $data); + + return $id; + } + + public function merge(string $collection, string $id, array $data): void + { + $this->data[$collection][$id] = [...$this->data[$collection][$id] ?? [], ...$data]; + } + + public function delete(string $collection, string $id): void + { + unset($this->data[$collection][$id]); + } + + public function truncate(string $collection): void + { + unset($this->data[$collection]); + } + + public function count(string $collection, Criteria $criteria): int + { + return count($this->readAll($collection, $criteria)); + } + + public function transaction(callable $work, int $attempts = 5): mixed + { + // Nothing else can be writing to an in-memory store, so the work is + // simply run once against it. + return $work(new ArrayTransaction($this)); + } + + /** + * @param list|bool|string|int|float $expected + */ + private static function matches(mixed $actual, Operator $operator, array|bool|string|int|float $expected): bool + { + if (is_array($expected)) { + return $operator === Operator::In && in_array($actual, $expected, strict: true); + } + + return match ($operator) { + Operator::Equal => $actual === $expected, + Operator::LessThan => $actual < $expected, + Operator::LessThanOrEqual => $actual <= $expected, + Operator::GreaterThan => $actual > $expected, + Operator::GreaterThanOrEqual => $actual >= $expected, + Operator::Contains => is_array($actual) && in_array($expected, $actual, strict: true), + Operator::In => false, + }; + } +} diff --git a/tests/Fixtures/ArrayTransaction.php b/tests/Fixtures/ArrayTransaction.php new file mode 100644 index 0000000..ec3b70f --- /dev/null +++ b/tests/Fixtures/ArrayTransaction.php @@ -0,0 +1,29 @@ +connection->read($collection, $id); + } + + public function write(string $collection, string $id, array $data): void + { + $this->connection->write($collection, $id, $data); + } + + public function delete(string $collection, string $id): void + { + $this->connection->delete($collection, $id); + } +} diff --git a/tests/Fixtures/Coupon.php b/tests/Fixtures/Coupon.php new file mode 100644 index 0000000..7043594 --- /dev/null +++ b/tests/Fixtures/Coupon.php @@ -0,0 +1,22 @@ + */ + public array $lines = []; + + public ?DateTimeImmutable $placedAt = null; + + public string $reference = ''; +} diff --git a/tests/Fixtures/OrderStatus.php b/tests/Fixtures/OrderStatus.php new file mode 100644 index 0000000..d31d986 --- /dev/null +++ b/tests/Fixtures/OrderStatus.php @@ -0,0 +1,11 @@ + */ + public array $tags = []; + + protected string $note = ''; + + private string $secret = 'not persisted'; + + public static function named(string $firstName, string $lastName): self + { + $user = new self(); + $user->firstName = $firstName; + $user->lastName = $lastName; + + return $user; + } + + public function note(): string + { + return $this->note; + } + + public function withNote(string $note): self + { + $clone = clone $this; + $clone->note = $note; + + return $clone; + } + + public function secret(): string + { + return $this->secret; + } +} diff --git a/tests/Fixtures/UserRepository.php b/tests/Fixtures/UserRepository.php new file mode 100644 index 0000000..31b1a64 --- /dev/null +++ b/tests/Fixtures/UserRepository.php @@ -0,0 +1,23 @@ + + */ +final class UserRepository extends Repository +{ + protected function collection(): string + { + return 'users'; + } + + protected function entityClass(): string + { + return User::class; + } +} From e7d4517f2935a6cc4565effb28e5f4939966d9d6 Mon Sep 17 00:00:00 2001 From: Adro Morelos Date: Thu, 20 Aug 2026 17:41:08 +0200 Subject: [PATCH 04/13] Add the Connection port and a neutral query model --- src/Database/Condition.php | 21 ++++ src/Database/Connection.php | 111 ++++++++++++++++++ src/Database/Criteria.php | 162 ++++++++++++++++++++++++++ src/Database/Direction.php | 11 ++ src/Database/Operator.php | 24 ++++ src/Database/Ordering.php | 27 +++++ src/Database/Transaction.php | 27 +++++ src/Exception/TransactionConflict.php | 36 ++++++ src/Exception/UnsupportedQuery.php | 34 ++++++ 9 files changed, 453 insertions(+) create mode 100644 src/Database/Condition.php create mode 100644 src/Database/Connection.php create mode 100644 src/Database/Criteria.php create mode 100644 src/Database/Direction.php create mode 100644 src/Database/Operator.php create mode 100644 src/Database/Ordering.php create mode 100644 src/Database/Transaction.php create mode 100644 src/Exception/TransactionConflict.php create mode 100644 src/Exception/UnsupportedQuery.php diff --git a/src/Database/Condition.php b/src/Database/Condition.php new file mode 100644 index 0000000..d1f3592 --- /dev/null +++ b/src/Database/Condition.php @@ -0,0 +1,21 @@ +|bool|string|int|float $value + */ + public function __construct( + public string $field, + public Operator $operator, + public array|bool|string|int|float $value, + ) { + } +} diff --git a/src/Database/Connection.php b/src/Database/Connection.php new file mode 100644 index 0000000..dc42179 --- /dev/null +++ b/src/Database/Connection.php @@ -0,0 +1,111 @@ +|null + */ + public function read(string $collection, string $id): ?array; + + /** + * The matching records, keyed by id, in the order the criteria ask for. + * + * @throws UnsupportedQuery + * + * @return array> + */ + public function readAll(string $collection, Criteria $criteria): array; + + /** + * Stores $data under $id, replacing whatever was there. + * + * @param array $data + */ + public function write(string $collection, string $id, array $data): void; + + /** + * Stores several records at once, keyed by id, in as few round trips as + * the backend allows. + * + * Like {@see write()}, each record replaces whatever was stored under its + * id. Whether the batch is atomic is the backend's business. + * + * @param array> $records + */ + public function writeMany(string $collection, array $records): void; + + /** + * Stores $data under a backend-generated id, and returns that id. + * + * @param array $data + */ + public function create(string $collection, array $data): string; + + /** + * Writes only the given fields, leaving the rest of the record alone. + * + * @param array $data + */ + public function merge(string $collection, string $id, array $data): void; + + public function delete(string $collection, string $id): void; + + /** + * Removes every record in the collection. + */ + public function truncate(string $collection): void; + + /** + * The number of matching records. + * + * @throws UnsupportedQuery + */ + public function count(string $collection, Criteria $criteria): int; + + /** + * Runs $work against a consistent snapshot, and commits it only if nothing + * it read has changed in the meantime. + * + * The callable may run more than once: on a conflict the work is discarded + * and repeated against fresh data, so it must be safe to repeat and must + * not do anything outside the transaction that cannot be undone. + * + * How much is guaranteed differs by backend, and the difference matters: + * Firestore commits every write together or none of them, while the + * Realtime Database applies each write on its own, so a transaction that + * touches two records there can leave the first written and the second + * refused. + * + * @template TReturn + * + * @param callable(Transaction): TReturn $work + * @param int<1, max> $attempts how many times to try before giving up + * + * @throws TransactionConflict when every attempt lost to another writer + * + * @return TReturn + */ + public function transaction(callable $work, int $attempts = 5): mixed; +} diff --git a/src/Database/Criteria.php b/src/Database/Criteria.php new file mode 100644 index 0000000..4598617 --- /dev/null +++ b/src/Database/Criteria.php @@ -0,0 +1,162 @@ +withExtra('startAfter', ['ada', 42]); + * + * A connection that understands the key acts on it; a connection that does not + * must refuse the query rather than quietly answering a different one. That is + * what lets someone add a capability in their own connection without forking + * this class, and without a query silently meaning different things on + * different backends. + */ +final readonly class Criteria +{ + /** + * @param list $conditions + * @param list $orderings + * @param array $extras + */ + private function __construct( + public array $conditions = [], + public array $orderings = [], + public ?int $limit = null, + public array $extras = [], + ) { + } + + public static function none(): self + { + return new self(); + } + + /** + * Entities store a backed enum as its value, so one may be compared + * against directly rather than the caller having to unwrap it. + * + * @param list|BackedEnum|bool|string|int|float $value + */ + public function where(string $field, Operator $operator, array|BackedEnum|bool|string|int|float $value): self + { + if (trim($field) === '') { + throw new InvalidArgumentException('Cannot filter on an empty field name.'); + } + + return new self( + [...$this->conditions, new Condition($field, $operator, self::unwrap($value))], + $this->orderings, + $this->limit, + $this->extras, + ); + } + + public function orderBy(string $field, Direction $direction = Direction::Ascending): self + { + if (trim($field) === '') { + throw new InvalidArgumentException('Cannot order by an empty field name.'); + } + + return new self( + $this->conditions, + [...$this->orderings, new Ordering($field, $direction)], + $this->limit, + $this->extras, + ); + } + + public function orderByKey(Direction $direction = Direction::Ascending): self + { + return $this->orderBy(Ordering::KEY, $direction); + } + + public function limit(int $limit): self + { + if ($limit < 1) { + throw new InvalidArgumentException(sprintf('A query limit must be at least 1, got %d.', $limit)); + } + + return new self($this->conditions, $this->orderings, $limit, $this->extras); + } + + /** + * Attaches a backend-specific constraint under $key. + * + * Only a connection that recognises the key can honour it; every other + * connection is required to refuse the query. + */ + public function withExtra(string $key, mixed $value): self + { + if (trim($key) === '') { + throw new InvalidArgumentException('An extra needs a name.'); + } + + return new self($this->conditions, $this->orderings, $this->limit, [...$this->extras, $key => $value]); + } + + /** + * @param list|BackedEnum|bool|string|int|float $value + * + * @return list|bool|string|int|float + */ + private static function unwrap(array|BackedEnum|bool|string|int|float $value): array|bool|string|int|float + { + if ($value instanceof BackedEnum) { + return $value->value; + } + + if (is_array($value)) { + return array_map( + static fn (BackedEnum|bool|string|int|float $item): bool|string|int|float => $item instanceof BackedEnum + ? $item->value + : $item, + $value, + ); + } + + return $value; + } + + public function extra(string $key): mixed + { + return $this->extras[$key] ?? null; + } + + /** + * The extras a connection has not claimed to understand. + * + * @param list $supported + * + * @return list + */ + public function unsupportedExtras(array $supported): array + { + return array_values(array_diff(array_keys($this->extras), $supported)); + } + + /** + * The distinct fields this query filters on. + * + * @return list + */ + public function filteredFields(): array + { + return array_values(array_unique(array_map( + static fn (Condition $condition): string => $condition->field, + $this->conditions, + ))); + } +} diff --git a/src/Database/Direction.php b/src/Database/Direction.php new file mode 100644 index 0000000..9024120 --- /dev/null +++ b/src/Database/Direction.php @@ -0,0 +1,11 @@ +'; + case GreaterThanOrEqual = '>='; + case In = 'in'; + case Contains = 'contains'; +} diff --git a/src/Database/Ordering.php b/src/Database/Ordering.php new file mode 100644 index 0000000..e186e6d --- /dev/null +++ b/src/Database/Ordering.php @@ -0,0 +1,27 @@ +field === self::KEY; + } +} diff --git a/src/Database/Transaction.php b/src/Database/Transaction.php new file mode 100644 index 0000000..1d2cef8 --- /dev/null +++ b/src/Database/Transaction.php @@ -0,0 +1,27 @@ +|null + */ + public function read(string $collection, string $id): ?array; + + /** + * @param array $data + */ + public function write(string $collection, string $id, array $data): void; + + public function delete(string $collection, string $id): void; +} diff --git a/src/Exception/TransactionConflict.php b/src/Exception/TransactionConflict.php new file mode 100644 index 0000000..b93c5b2 --- /dev/null +++ b/src/Exception/TransactionConflict.php @@ -0,0 +1,36 @@ +getMessage(); + + parent::__construct( + sprintf( + 'The transaction still conflicted with another writer after %d %s.%s', + $attempts, + $attempts === 1 ? 'attempt' : 'attempts', + $because === null || trim($because) === '' ? '' : ' The last attempt failed with: '.$because, + ), + previous: $previous, + ); + } +} diff --git a/src/Exception/UnsupportedQuery.php b/src/Exception/UnsupportedQuery.php new file mode 100644 index 0000000..6ae658b --- /dev/null +++ b/src/Exception/UnsupportedQuery.php @@ -0,0 +1,34 @@ + $keys + */ + public static function unknownExtras(string $backend, array $keys): self + { + return self::because($backend, sprintf( + 'it does not understand the %s %s. A connection must refuse an extra it cannot honour, ' + .'rather than answer a different query than the one asked for.', + implode(', ', array_map(static fn (string $k): string => '"'.$k.'"', $keys)), + count($keys) === 1 ? 'extra' : 'extras', + )); + } + + public static function because(string $backend, string $reason): self + { + return new self(sprintf('%s cannot run this query: %s', $backend, $reason)); + } +} From 1c00f8144d031e2f70923c7b62eaab294b8c63fc Mon Sep 17 00:00:00 2001 From: Adro Morelos Date: Fri, 21 Aug 2026 08:52:44 +0200 Subject: [PATCH 05/13] Repository, EntityRepository and the query builder --- src/EntityRepository.php | 58 +++++++ src/Exception/EntityNotFound.php | 15 ++ src/Query.php | 161 ++++++++++++++++++ src/Repository.php | 281 +++++++++++++++++++++++++++++++ src/RepositoryInterface.php | 115 +++++++++++++ 5 files changed, 630 insertions(+) create mode 100644 src/EntityRepository.php create mode 100644 src/Exception/EntityNotFound.php create mode 100644 src/Query.php create mode 100644 src/Repository.php create mode 100644 src/RepositoryInterface.php diff --git a/src/EntityRepository.php b/src/EntityRepository.php new file mode 100644 index 0000000..1bb5057 --- /dev/null +++ b/src/EntityRepository.php @@ -0,0 +1,58 @@ + + */ +final class EntityRepository extends Repository +{ + /** + * @param class-string $entityClass + */ + public function __construct( + Connection $connection, + private readonly string $collection, + private readonly string $entityClass, + ) { + // A runtime guard, because the class name often arrives from config or + // a container rather than from code static analysis has checked. + if (!class_exists($entityClass)) { + throw InvalidEntity::unknownClass($entityClass); + } + + /** @phpstan-ignore function.alreadyNarrowedType */ + if (!is_a($entityClass, EntityInterface::class, true)) { + throw InvalidEntity::notAnEntity($entityClass); + } + + parent::__construct($connection); + } + + protected function collection(): string + { + return $this->collection; + } + + /** + * @return class-string + */ + protected function entityClass(): string + { + return $this->entityClass; + } +} diff --git a/src/Exception/EntityNotFound.php b/src/Exception/EntityNotFound.php new file mode 100644 index 0000000..4771986 --- /dev/null +++ b/src/Exception/EntityNotFound.php @@ -0,0 +1,15 @@ +query()->whereEquals('role', 'admin'); + * + * $firstTen = $admins->limit(10)->fetch(); + * $newest = $admins->orderBy('joinedAt', Direction::Descending)->limit(5)->fetch(); + * + * What a backend can actually run differs: the Realtime Database filters and + * orders on one field only and throws {@see \PhpFirebase\Exception\UnsupportedQuery} + * otherwise, while Firestore wants a composite index for compound queries. + * + * A subclass may add methods of its own; override {@see with()} so the chain + * keeps returning that subclass. The constructor signature is part of the + * contract, because building the next query in a chain relies on it. + * + * @template T of EntityInterface + * + * @phpstan-consistent-constructor + */ +readonly class Query +{ + protected readonly Criteria $criteria; + + /** + * @param class-string $entityClass + */ + public function __construct( + protected readonly Connection $connection, + protected readonly string $collection, + protected readonly string $entityClass, + ?Criteria $criteria = null, + ) { + $this->criteria = $criteria ?? Criteria::none(); + } + + /** + * A backed enum may be passed directly; it is compared as its value. + * + * @param list|BackedEnum|bool|string|int|float $value + * + * @return static + */ + public function where(string $field, Operator $operator, array|BackedEnum|bool|string|int|float $value): static + { + return $this->with($this->criteria->where($field, $operator, $value)); + } + + /** + * @return static + */ + public function whereEquals(string $field, BackedEnum|bool|string|int|float $value): static + { + return $this->where($field, Operator::Equal, $value); + } + + /** + * @return static + */ + public function orderBy(string $field, Direction $direction = Direction::Ascending): static + { + return $this->with($this->criteria->orderBy($field, $direction)); + } + + /** + * @return static + */ + public function orderByKey(Direction $direction = Direction::Ascending): static + { + return $this->with($this->criteria->orderByKey($direction)); + } + + /** + * @return static + */ + public function limit(int $limit): static + { + return $this->with($this->criteria->limit($limit)); + } + + /** + * Runs the query and returns the entities keyed by their id. + * + * @return array + */ + public function fetch(): array + { + $entities = []; + + foreach ($this->connection->readAll($this->collection, $this->criteria) as $id => $record) { + $entities[$id] = ($this->entityClass)::fromArray($record, $id); + } + + return $entities; + } + + /** + * The first matching entity, or null when nothing matched. + * + * @return T|null + */ + public function first(): ?EntityInterface + { + $entities = $this->limit(1)->fetch(); + + return array_shift($entities); + } + + public function count(): int + { + return $this->connection->count($this->collection, $this->criteria); + } + + /** + * The criteria built so far, for inspection or for handing to a connection + * directly. + */ + public function criteria(): Criteria + { + return $this->criteria; + } + + /** + * Builds the next query in a chain. A subclass adding methods of its own + * overrides this so the chain keeps returning its type. + * + * @return static + */ + protected function with(Criteria $criteria): static + { + return new static($this->connection, $this->collection, $this->entityClass, $criteria); + } + + /** + * @return Connection the connection this query runs against + */ + protected function connection(): Connection + { + return $this->connection; + } + + protected function collection(): string + { + return $this->collection; + } +} diff --git a/src/Repository.php b/src/Repository.php new file mode 100644 index 0000000..1c3ab68 --- /dev/null +++ b/src/Repository.php @@ -0,0 +1,281 @@ + + */ +abstract class Repository implements RepositoryInterface +{ + public function __construct(protected readonly Connection $connection) + { + } + + /** + * The collection these entities live in: a Firestore collection, or a + * Realtime Database path holding a map of records. + */ + abstract protected function collection(): string; + + /** + * @return class-string + */ + abstract protected function entityClass(): string; + + /** + * @return T|null + */ + public function find(string $id): ?EntityInterface + { + $record = $this->connection->read($this->collection(), $this->guardId($id)); + + return $record === null ? null : ($this->entityClass())::fromArray($record, $id); + } + + /** + * @return T + */ + public function get(string $id): EntityInterface + { + return $this->find($id) + ?? throw EntityNotFound::withId($this->entityClass(), $this->collection(), $id); + } + + /** + * @return array + */ + public function all(): array + { + return $this->query()->fetch(); + } + + /** + * @param T $entity + * + * @return T + */ + public function save(EntityInterface $entity): EntityInterface + { + $this->guardEntityClass($entity); + + $data = $entity->toDatabase(); + $id = $entity->id(); + + if ($id !== null) { + $this->connection->write($this->collection(), $this->guardId($id), $data); + + return $entity; + } + + return $entity->withId($this->connection->create($this->collection(), $data)); + } + + /** + * Stores several entities, in as few round trips as the backend allows. + * + * Entities that already have an id are written together in one batch. + * Entities without one are created individually, because only the backend + * can allocate their ids. + * + * @param iterable $entities + * + * @return list + */ + public function saveMany(iterable $entities): array + { + $saved = []; + $batch = []; + + foreach ($entities as $entity) { + $this->guardEntityClass($entity); + + $id = $entity->id(); + + if ($id === null) { + $saved[] = $entity->withId($this->connection->create($this->collection(), $entity->toDatabase())); + + continue; + } + + $batch[$this->guardId($id)] = $entity->toDatabase(); + $saved[] = $entity; + } + + if ($batch !== []) { + $this->connection->writeMany($this->collection(), $batch); + } + + return $saved; + } + + /** + * Writes only the given fields, leaving the rest of the record untouched. + * + * @param array $values + */ + public function update(EntityInterface|string $entity, array $values): void + { + $this->connection->merge($this->collection(), $this->idOf($entity), $values); + } + + public function delete(EntityInterface|string $entity): void + { + $this->connection->delete($this->collection(), $this->idOf($entity)); + } + + public function deleteAll(): void + { + $this->connection->truncate($this->collection()); + } + + /** + * Override this to hand out a Query subclass carrying methods of your own; + * the narrower return type is allowed and is what callers will see. + * + * @return Query + */ + public function query(): Query + { + return new Query($this->connection, $this->collection(), $this->entityClass()); + } + + public function count(): int + { + return $this->query()->count(); + } + + /** + * Runs $work against a consistent snapshot of the collection. + * + * The callable may run more than once, because a conflict with another + * writer discards the work and repeats it against fresh data. Keep it free + * of side effects that cannot be repeated - send the email after it + * returns, not inside it. + * + * $orders->transaction(function (EntityTransaction $orders) use ($id): void { + * $order = $orders->get($id); + * $order->status = OrderStatus::Shipped; + * $orders->save($order); + * }); + * + * @template TReturn + * + * @param callable(EntityTransaction): TReturn $work + * @param int<1, max> $attempts + * + * @throws TransactionConflict when every attempt lost to another writer + * + * @return TReturn + */ + public function transaction(callable $work, int $attempts = 5): mixed + { + return $this->connection->transaction( + fn (Transaction $transaction): mixed => $work( + new EntityTransaction($transaction, $this->collection(), $this->entityClass()), + ), + $attempts, + ); + } + + /** + * Reads one entity, hands it to $change, and writes back whatever comes + * out - safely against other writers. + * + * This is the read-modify-write that transactions exist for, and the shape + * most uses of them take: + * + * $accounts->modify('ada', function (Account $account): Account { + * $account->balance -= 10; + * + * return $account; + * }); + * + * @param callable(T): T $change + * + * @throws EntityNotFound if there is nothing stored under $id + * @throws TransactionConflict when every attempt lost to another writer + * + * @return T the entity as it was written + */ + public function modify(string $id, callable $change, int $attempts = 5): EntityInterface + { + return $this->transaction( + function (EntityTransaction $entities) use ($id, $change): EntityInterface { + $changed = $change($entities->get($id)); + + $entities->save($changed); + + return $changed; + }, + $attempts, + ); + } + + private function idOf(EntityInterface|string $entity): string + { + if (is_string($entity)) { + return $this->guardId($entity); + } + + $this->guardEntityClass($entity); + + return $this->guardId($entity->id() ?? throw InvalidEntity::missingId($entity::class)); + } + + private function guardEntityClass(EntityInterface $entity): void + { + $expected = $this->entityClass(); + + if (!$entity instanceof $expected) { + throw InvalidEntity::unexpectedClass($expected, $entity::class); + } + } + + /** + * Rejects the ids no backend accepts. Each connection additionally applies + * its own rules, which differ: a dot is fine in a Firestore document id and + * illegal in a Realtime Database key. + */ + private function guardId(string $id): string + { + if ($id === '' || str_contains($id, '/')) { + throw new InvalidEntity(sprintf( + 'The id "%s" is not usable: it must not be empty or contain a slash.', + $id, + )); + } + + return $id; + } +} diff --git a/src/RepositoryInterface.php b/src/RepositoryInterface.php new file mode 100644 index 0000000..15095d1 --- /dev/null +++ b/src/RepositoryInterface.php @@ -0,0 +1,115 @@ + + */ + public function all(): array; + + /** + * Stores the entity and returns it carrying its id. + * + * An entity without an id gets one from the backend. An entity with an id + * overwrites whatever is stored under it. + * + * @param T $entity + * + * @return T + */ + public function save(EntityInterface $entity): EntityInterface; + + /** + * @param iterable $entities + * + * @return list + */ + public function saveMany(iterable $entities): array; + + /** + */ + public function delete(EntityInterface|string $entity): void; + + /** + * Removes every entity under the path. + * + */ + public function deleteAll(): void; + + /** + * Starts a query over the collection. + * + * @return Query + */ + public function query(): Query; + + /** + * Writes only the given fields of one entity, leaving the rest alone. + * + * @param array $values + */ + public function update(EntityInterface|string $entity, array $values): void; + + /** + * The number of entities in the collection. + */ + public function count(): int; + + /** + * Runs $work against a consistent snapshot, retrying if another writer got + * there first. The callable may therefore run more than once. + * + * @template TReturn + * + * @param callable(EntityTransaction): TReturn $work + * @param int<1, max> $attempts + * + * @throws TransactionConflict + * + * @return TReturn + */ + public function transaction(callable $work, int $attempts = 5): mixed; + + /** + * Read one entity, change it, write it back, safely against other writers. + * + * @param callable(T): T $change + * @param int<1, max> $attempts + * + * @throws EntityNotFound + * @throws TransactionConflict + * + * @return T + */ + public function modify(string $id, callable $change, int $attempts = 5): EntityInterface; +} From db066f7103caa7ab16d581a08e9e7f1d16a3071b Mon Sep 17 00:00:00 2001 From: Adro Morelos Date: Fri, 21 Aug 2026 10:16:20 +0200 Subject: [PATCH 06/13] Realtime Database connection Filtering and ordering are one field only here, so anything else throws instead of quietly answering a different question. --- src/Database/RealtimeDatabaseConnection.php | 323 ++++++++++++++++++++ src/Database/RealtimeQueryPlan.php | 24 ++ src/Database/RealtimeTransaction.php | 65 ++++ src/Database/RealtimeWrite.php | 64 ++++ 4 files changed, 476 insertions(+) create mode 100644 src/Database/RealtimeDatabaseConnection.php create mode 100644 src/Database/RealtimeQueryPlan.php create mode 100644 src/Database/RealtimeTransaction.php create mode 100644 src/Database/RealtimeWrite.php diff --git a/src/Database/RealtimeDatabaseConnection.php b/src/Database/RealtimeDatabaseConnection.php new file mode 100644 index 0000000..c36cdd8 --- /dev/null +++ b/src/Database/RealtimeDatabaseConnection.php @@ -0,0 +1,323 @@ +child($collection, $id)->getValue(); + + if ($value === null) { + return null; + } + + if (!is_array($value)) { + throw InvalidEntity::cannotHydrate($collection, $id, sprintf('expected a record, got %s.', get_debug_type($value))); + } + + /** @var array $value */ + return $value; + } + + public function readAll(string $collection, Criteria $criteria): array + { + $plan = $this->plan($criteria); + $value = $this->apply($this->reference($collection), $plan)->getValue(); + + if ($value === null) { + return []; + } + + if (!is_array($value)) { + throw InvalidEntity::notAnObjectMap($collection, $collection); + } + + $records = []; + + foreach ($value as $key => $record) { + $key = (string) $key; + + if (!is_array($record)) { + throw InvalidEntity::cannotHydrate($collection, $key, sprintf('expected a record, got %s.', get_debug_type($record))); + } + + /** @var array $record */ + $records[$key] = $record; + } + + // The database always answers in ascending order, so a descending + // query is served by reading the matching window and flipping it. + return $plan->descending ? array_reverse($records, preserve_keys: true) : $records; + } + + public function write(string $collection, string $id, array $data): void + { + $this->set($this->child($collection, $id), RealtimeWrite::resolve($data)); + } + + public function writeMany(string $collection, array $records): void + { + if ($records === []) { + return; + } + + foreach (array_keys($records) as $id) { + $this->guardKey($id); + } + + // A multi-path update writes every record in one request. Each key is + // a path relative to the collection, so this replaces those children + // and leaves the rest of the collection alone. + $this->reference($collection)->update(RealtimeWrite::resolveAll($records)); + } + + public function create(string $collection, array $data): string + { + $key = $this->reference($collection)->push(RealtimeWrite::resolve($data)->values)->getKey(); + + if ($key === null) { + // Unreachable in practice: a pushed reference always has a key. + throw new UnsupportedQuery('The database did not return a key for the pushed record.'); + } + + return $key; + } + + public function merge(string $collection, string $id, array $data): void + { + $this->child($collection, $id)->update(RealtimeWrite::resolve($data)->values); + } + + /** + * The one place a value is handed to the database for a single location. + * + * It takes a {@see RealtimeWrite} rather than an array, which is what makes + * skipping sentinel resolution impossible: the only way to have one is to + * have resolved. + * + * @internal for {@see RealtimeTransaction} + */ + public function set(Reference $reference, RealtimeWrite $write): void + { + $reference->set($write->values); + } + + + public function delete(string $collection, string $id): void + { + $this->child($collection, $id)->remove(); + } + + public function truncate(string $collection): void + { + $this->reference($collection)->remove(); + } + + public function count(string $collection, Criteria $criteria): int + { + // The Realtime Database has no count operation, so this reads the + // matching records. Keep a counter of your own for large collections. + return count($this->readAll($collection, $criteria)); + } + + public function transaction(callable $work, int $attempts = 5): mixed + { + $failure = null; + + for ($attempt = 1; $attempt <= $attempts; ++$attempt) { + try { + return $this->database->runTransaction( + fn (DatabaseTransaction $transaction): mixed => $work(new RealtimeTransaction($transaction, $this)), + ); + } catch (TransactionFailed $e) { + // kreait reports every failed write inside a transaction this + // way, including ones that have nothing to do with contention. + // Only a refused precondition means someone else got there + // first; anything else will fail again just as surely on the + // next attempt, so it is raised as itself. + if (!$e->getPrevious() instanceof PreconditionFailed) { + throw $e; + } + + $failure = $e; + } + } + + throw new TransactionConflict($attempts, $failure); + } + + /** + * The underlying reference, for anything this class does not cover. + */ + public function reference(string $collection): Reference + { + return $this->database->getReference($collection); + } + + /** + * @internal for {@see RealtimeTransaction} + */ + public function child(string $collection, string $id): Reference + { + return $this->reference($collection)->getChild($this->guardKey($id)); + } + + private function guardKey(string $id): string + { + if ($id === '' || strpbrk($id, self::ILLEGAL_KEY_CHARACTERS) !== false) { + throw new InvalidEntity(sprintf( + 'The id "%s" is not a usable Realtime Database key: it must not be empty or contain any of %s', + $id, + self::ILLEGAL_KEY_CHARACTERS, + )); + } + + return $id; + } + + /** + * Works out the single ordering and set of filters the query needs, and + * rejects it if the Realtime Database cannot express it. + */ + private function plan(Criteria $criteria): RealtimeQueryPlan + { + $unsupported = $criteria->unsupportedExtras($this->supportedExtras()); + + if ($unsupported !== []) { + throw UnsupportedQuery::unknownExtras(self::BACKEND, $unsupported); + } + + if (count($criteria->orderings) > 1) { + throw UnsupportedQuery::because(self::BACKEND, 'it can order by one field only.'); + } + + $fields = $criteria->filteredFields(); + + if (count($fields) > 1) { + throw UnsupportedQuery::because(self::BACKEND, sprintf( + 'it can filter on one field only, and this query filters on %s.', + implode(', ', $fields), + )); + } + + foreach ($criteria->conditions as $condition) { + if ($condition->operator === Operator::In || $condition->operator === Operator::Contains) { + throw UnsupportedQuery::because(self::BACKEND, sprintf( + 'it has no "%s" operator.', + $condition->operator->value, + )); + } + } + + $ordering = $criteria->orderings[0] ?? null; + $filterField = $fields[0] ?? null; + + if ($ordering !== null && $filterField !== null) { + $orderingField = $ordering->isByKey() ? Ordering::KEY : $ordering->field; + + if ($orderingField !== $filterField) { + throw UnsupportedQuery::because(self::BACKEND, sprintf( + 'it must order by the field it filters on, but this query orders by "%s" and filters on "%s".', + $orderingField, + $filterField, + )); + } + } + + // Filtering implies an ordering on the same field, so one is derived + // when the caller only asked to filter. + $ordering ??= $filterField !== null ? new Ordering($filterField) : null; + + // The database refuses a limit with nothing to order by: "orderBy must + // be defined when other query parameters are defined". Taking the first + // n of a keyed collection means in key order, so that is supplied + // rather than handing the caller back an error they can only fix one + // way. + if ($ordering === null && $criteria->limit !== null) { + $ordering = new Ordering(Ordering::KEY); + } + + return new RealtimeQueryPlan( + ordering: $ordering, + conditions: $criteria->conditions, + limit: $criteria->limit, + descending: $ordering !== null && $ordering->direction === Direction::Descending, + ); + } + + /** + * The extra keys this connection knows how to honour. Override in a + * subclass that adds one. + * + * @return list + */ + protected function supportedExtras(): array + { + return []; + } + + private function apply(Reference $reference, RealtimeQueryPlan $plan): Reference|DatabaseQuery + { + $query = $reference; + $ordering = $plan->ordering; + + if ($ordering !== null) { + $query = $ordering->isByKey() ? $query->orderByKey() : $query->orderByChild($ordering->field); + } + + foreach ($plan->conditions as $condition) { + if (is_array($condition->value)) { + throw UnsupportedQuery::because(self::BACKEND, 'it cannot compare against a list of values.'); + } + + $query = match ($condition->operator) { + Operator::Equal => $query->equalTo($condition->value), + Operator::GreaterThanOrEqual => $query->startAt($condition->value), + Operator::GreaterThan => $query->startAfter($condition->value), + Operator::LessThanOrEqual => $query->endAt($condition->value), + Operator::LessThan => $query->endBefore($condition->value), + default => throw UnsupportedQuery::because(self::BACKEND, sprintf('it has no "%s" operator.', $condition->operator->value)), + }; + } + + if ($plan->limit !== null) { + // Taking the last N of an ascending result is how the database + // expresses "the first N of the descending one". + $query = $plan->descending ? $query->limitToLast($plan->limit) : $query->limitToFirst($plan->limit); + } + + return $query; + } +} diff --git a/src/Database/RealtimeQueryPlan.php b/src/Database/RealtimeQueryPlan.php new file mode 100644 index 0000000..2fdb19b --- /dev/null +++ b/src/Database/RealtimeQueryPlan.php @@ -0,0 +1,24 @@ + $conditions + */ + public function __construct( + public ?Ordering $ordering, + public array $conditions, + public ?int $limit, + public bool $descending, + ) { + } +} diff --git a/src/Database/RealtimeTransaction.php b/src/Database/RealtimeTransaction.php new file mode 100644 index 0000000..64a888e --- /dev/null +++ b/src/Database/RealtimeTransaction.php @@ -0,0 +1,65 @@ +transaction->snapshot($this->connection->child($collection, $id))->getValue(); + + if ($value === null) { + return null; + } + + if (!is_array($value)) { + throw InvalidEntity::cannotHydrate($collection, $id, sprintf('expected a record, got %s.', get_debug_type($value))); + } + + /** @var array $value */ + return $value; + } + + public function write(string $collection, string $id, array $data): void + { + // A RealtimeWrite cannot exist without having been resolved, so a + // sentinel written inside a transaction means what it does everywhere + // else, and cannot be forgotten here. + $this->transaction->set( + $this->connection->child($collection, $id), + RealtimeWrite::resolve($data)->values, + ); + } + + public function delete(string $collection, string $id): void + { + $this->transaction->remove($this->connection->child($collection, $id)); + } +} diff --git a/src/Database/RealtimeWrite.php b/src/Database/RealtimeWrite.php new file mode 100644 index 0000000..1fac5c0 --- /dev/null +++ b/src/Database/RealtimeWrite.php @@ -0,0 +1,64 @@ + $values + */ + private function __construct(public array $values) + { + } + + /** + * Turns sentinels into what the Realtime Database understands: its own + * server-value marker for the clock, and null for a removal, since the + * database deletes any key written as null. + * + * @param array $data + */ + public static function resolve(array $data): self + { + foreach ($data as $name => $value) { + if ($value === Sentinel::ServerTimestamp) { + $data[$name] = Database::SERVER_TIMESTAMP; + } elseif ($value === Sentinel::Remove) { + $data[$name] = null; + } + } + + return new self($data); + } + + /** + * @param array> $records + * + * @return array> + */ + public static function resolveAll(array $records): array + { + return array_map(static fn (array $record): array => self::resolve($record)->values, $records); + } +} From 865c17ed61b859b09e7f4d7ee16f584a4c983203 Mon Sep 17 00:00:00 2001 From: Adro Morelos Date: Fri, 21 Aug 2026 11:58:03 +0200 Subject: [PATCH 07/13] Tests for the repository, queries and rtdb --- tests/FakeDatabase.php | 139 ++++++++ tests/QueryTest.php | 164 +++++++++ tests/RealtimeDatabaseConnectionTest.php | 407 +++++++++++++++++++++++ tests/RepositoryTest.php | 233 +++++++++++++ 4 files changed, 943 insertions(+) create mode 100644 tests/FakeDatabase.php create mode 100644 tests/QueryTest.php create mode 100644 tests/RealtimeDatabaseConnectionTest.php create mode 100644 tests/RepositoryTest.php diff --git a/tests/FakeDatabase.php b/tests/FakeDatabase.php new file mode 100644 index 0000000..b325103 --- /dev/null +++ b/tests/FakeDatabase.php @@ -0,0 +1,139 @@ + */ + private array $requests = []; + + public function __construct() + { + $this->handler = new MockHandler(); + + $stack = HandlerStack::create($this->handler); + // The real Factory installs this, and it is what appends the ".json" + // suffix every Realtime Database URL needs. + $stack->push(FirebaseMiddleware::ensureJsonSuffix()); + $stack->push($this->recordRequests()); + + $apiClient = new ApiClient( + new Client(['handler' => $stack]), + UrlBuilder::create(self::URI), + new DatabaseApiExceptionConverter(new ErrorResponseParser()), + ); + + $this->database = new Database(new Uri(self::URI), $apiClient); + } + + public function database(): Contract\Database + { + return $this->database; + } + + /** + * Queues the next response body, which is sent back as JSON. + */ + public function willRespondWith(mixed $body, int $status = 200): self + { + $this->handler->append(new Response($status, ['Content-Type' => 'application/json'], json_encode($body, JSON_THROW_ON_ERROR))); + + return $this; + } + + /** + * A read inside a transaction, which the database answers with a version + * tag that the matching write is then checked against. + */ + public function willRespondWithETag(mixed $body, string $etag): self + { + $this->handler->append(new Response( + 200, + ['Content-Type' => 'application/json', 'ETag' => $etag], + json_encode($body, JSON_THROW_ON_ERROR), + )); + + return $this; + } + + public function requestAt(int $index): RequestInterface + { + return $this->requests[$index] + ?? throw new LogicException(sprintf('No request was made at index %d.', $index)); + } + + public function lastRequest(): RequestInterface + { + return $this->requests[count($this->requests) - 1] + ?? throw new LogicException('No request was made.'); + } + + public function requestCount(): int + { + return count($this->requests); + } + + /** + * The query parameters of the last request. + * + * @return array + */ + public function lastQuery(): array + { + parse_str($this->lastRequest()->getUri()->getQuery(), $query); + + /** @var array $query */ + return $query; + } + + public function lastBody(): mixed + { + return json_decode((string) $this->lastRequest()->getBody(), true); + } + + /** + * Guzzle ships a history middleware, but it fills a container passed by + * reference; recording straight into the property keeps the types honest. + * + * @return Closure(callable): Closure + */ + private function recordRequests(): Closure + { + return fn (callable $handler): Closure => function (RequestInterface $request, array $options) use ($handler) { + $this->requests[] = $request; + + return $handler($request, $options); + }; + } +} diff --git a/tests/QueryTest.php b/tests/QueryTest.php new file mode 100644 index 0000000..2257d82 --- /dev/null +++ b/tests/QueryTest.php @@ -0,0 +1,164 @@ +users = new UserRepository(new ArrayConnection()); + + foreach ([['Ada', 'Lovelace', 36], ['Grace', 'Hopper', 85], ['Alan', 'Turing', 41]] as [$first, $last, $age]) { + $user = User::named($first, $last); + $user->age = $age; + $this->users->save($user->withId(strtolower($first))); + } + } + + #[Test] + public function it_filters_by_equality(): void + { + $found = $this->users->query()->whereEquals('lastName', 'Hopper')->fetch(); + + $this->assertSame(['grace'], array_keys($found)); + } + + #[Test] + public function a_backed_enum_may_be_compared_directly(): void + { + // Entities store an enum as its value, so filtering by the case itself + // is what a caller naturally writes. + $found = $this->users->query()->whereEquals('role', Role::Member)->fetch(); + + $this->assertSame(['ada', 'grace', 'alan'], array_keys($found)); + $this->assertSame( + 'member', + $this->users->query()->whereEquals('role', Role::Member)->criteria()->conditions[0]->value, + ); + } + + #[Test] + public function a_list_of_enums_is_unwrapped_too(): void + { + $criteria = $this->users->query() + ->where('role', Operator::In, [Role::Admin, Role::Member]) + ->criteria(); + + $this->assertSame(['admin', 'member'], $criteria->conditions[0]->value); + } + + #[Test] + public function it_filters_by_range(): void + { + $found = $this->users->query()->where('age', Operator::GreaterThan, 40)->fetch(); + + $this->assertSame(['grace', 'alan'], array_keys($found)); + } + + #[Test] + public function it_orders_ascending_and_descending(): void + { + $this->assertSame( + ['ada', 'alan', 'grace'], + array_keys($this->users->query()->orderBy('age')->fetch()), + ); + + $this->assertSame( + ['grace', 'alan', 'ada'], + array_keys($this->users->query()->orderBy('age', Direction::Descending)->fetch()), + ); + } + + #[Test] + public function it_orders_by_key(): void + { + $this->assertSame( + ['ada', 'alan', 'grace'], + array_keys($this->users->query()->orderByKey()->fetch()), + ); + } + + #[Test] + public function it_limits_the_results(): void + { + $found = $this->users->query()->orderBy('age')->limit(2)->fetch(); + + $this->assertSame(['ada', 'alan'], array_keys($found)); + } + + #[Test] + public function each_builder_call_returns_a_new_query(): void + { + $base = $this->users->query()->orderBy('age'); + $branch = $base->limit(1); + + $this->assertNotSame($base, $branch); + $this->assertCount(3, $base->fetch()); + $this->assertCount(1, $branch->fetch()); + } + + #[Test] + public function it_hydrates_entities_carrying_their_id(): void + { + $found = $this->users->query()->whereEquals('firstName', 'Ada')->fetch(); + + $this->assertSame('ada', $found['ada']->id()); + } + + #[Test] + public function first_returns_one_entity_or_null(): void + { + $this->assertSame('Hopper', $this->users->query()->orderBy('age', Direction::Descending)->first()?->lastName); + $this->assertNull($this->users->query()->whereEquals('lastName', 'Nobody')->first()); + } + + #[Test] + public function it_counts_matching_entities(): void + { + $this->assertSame(3, $this->users->query()->count()); + $this->assertSame(2, $this->users->query()->where('age', Operator::GreaterThan, 40)->count()); + } + + #[Test] + public function it_exposes_the_criteria_it_built(): void + { + $criteria = $this->users->query()->whereEquals('role', 'admin')->orderBy('age')->limit(5)->criteria(); + + $this->assertSame(['role'], $criteria->filteredFields()); + $this->assertSame(5, $criteria->limit); + $this->assertCount(1, $criteria->orderings); + } + + #[Test] + public function it_rejects_a_limit_below_one(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->users->query()->limit(0); + } + + #[Test] + public function it_rejects_an_empty_field_name(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->users->query()->whereEquals(' ', 'x'); + } +} diff --git a/tests/RealtimeDatabaseConnectionTest.php b/tests/RealtimeDatabaseConnectionTest.php new file mode 100644 index 0000000..dc6a71a --- /dev/null +++ b/tests/RealtimeDatabaseConnectionTest.php @@ -0,0 +1,407 @@ +firebase = new FakeDatabase(); + $this->connection = new RealtimeDatabaseConnection($this->firebase->database()); + $this->users = new UserRepository($this->connection); + } + + #[Test] + public function reading_an_entity_is_a_get_on_its_key(): void + { + $this->firebase->willRespondWith(['firstName' => 'Ada', 'lastName' => 'Lovelace']); + + $user = $this->users->find('user-1'); + + $this->assertSame('GET', $this->firebase->lastRequest()->getMethod()); + $this->assertSame('/users/user-1.json', $this->firebase->lastRequest()->getUri()->getPath()); + $this->assertNotNull($user); + $this->assertSame('Ada', $user->firstName); + $this->assertSame('user-1', $user->id()); + } + + #[Test] + public function saving_a_new_entity_posts_to_the_collection(): void + { + $this->firebase->willRespondWith(['name' => '-NxGeneratedKey']); + + $saved = $this->users->save(User::named('Ada', 'Lovelace')); + + $this->assertSame('POST', $this->firebase->lastRequest()->getMethod()); + $this->assertSame('/users.json', $this->firebase->lastRequest()->getUri()->getPath()); + $this->assertSame('-NxGeneratedKey', $saved->id()); + } + + #[Test] + public function saving_an_identified_entity_puts_to_its_key_without_the_id(): void + { + $this->firebase->willRespondWith(null); + + $this->users->save(User::named('Ada', 'Lovelace')->withId('user-1')); + + $this->assertSame('PUT', $this->firebase->lastRequest()->getMethod()); + $this->assertSame('/users/user-1.json', $this->firebase->lastRequest()->getUri()->getPath()); + $this->assertSame( + ['firstName' => 'Ada', 'lastName' => 'Lovelace', 'role' => 'member', 'tags' => [], 'note' => ''], + $this->firebase->lastBody(), + ); + } + + #[Test] + public function saving_identified_entities_takes_a_single_multi_path_update(): void + { + $this->firebase->willRespondWith(null); + + $this->users->saveMany([ + User::named('Ada', 'Lovelace')->withId('user-1'), + User::named('Grace', 'Hopper')->withId('user-2'), + ]); + + $this->assertSame(1, $this->firebase->requestCount()); + $this->assertSame('PATCH', $this->firebase->lastRequest()->getMethod()); + $this->assertSame('/users.json', $this->firebase->lastRequest()->getUri()->getPath()); + + $body = $this->firebase->lastBody(); + $this->assertSame(['user-1', 'user-2'], array_keys($body)); + $this->assertSame('Grace', $body['user-2']['firstName']); + } + + #[Test] + public function a_batch_still_rejects_a_key_the_database_would_refuse(): void + { + $this->expectException(InvalidEntity::class); + + $this->users->saveMany([User::named('Ada', 'Lovelace')->withId('a.dotted.key')]); + } + + #[Test] + public function a_transaction_reads_with_a_version_tag_and_writes_it_back(): void + { + $this->firebase + ->willRespondWithETag(['firstName' => 'Ada', 'age' => 36], 'etag-1') + ->willRespondWith(null); + + $this->users->modify('user-1', static function (User $user): User { + ++$user->age; + + return $user; + }); + + // The read asks for a tag, and the write is conditional on it. + $this->assertSame('true', $this->firebase->requestAt(0)->getHeaderLine('X-Firebase-ETag')); + $this->assertSame('PUT', $this->firebase->requestAt(1)->getMethod()); + $this->assertSame('etag-1', $this->firebase->requestAt(1)->getHeaderLine('if-match')); + $this->assertSame(37, $this->firebase->lastBody()['age']); + } + + #[Test] + public function a_transaction_that_loses_the_race_is_retried(): void + { + $this->firebase + // Someone else writes between our read and our write. + ->willRespondWithETag(['age' => 36], 'etag-1') + ->willRespondWith(['error' => 'Precondition Failed'], 412) + // The retry reads the newer value and writes it back successfully. + ->willRespondWithETag(['age' => 40], 'etag-2') + ->willRespondWith(null); + + $result = $this->users->modify('user-1', static function (User $user): User { + ++$user->age; + + return $user; + }); + + $this->assertSame(41, $result->age, 'The retry should have started from the newer value.'); + $this->assertSame('etag-2', $this->firebase->lastRequest()->getHeaderLine('if-match')); + } + + #[Test] + public function a_sentinel_written_inside_a_transaction_is_resolved(): void + { + // Transaction writes go straight to the underlying client, so they need + // the same sentinel handling as every other write path. + $this->firebase + ->willRespondWithETag(['age' => 36], 'etag-1') + ->willRespondWith(null); + + $this->users->modify('user-1', static function (User $user): User { + $user->updatedAt = Sentinel::ServerTimestamp; + + return $user; + }); + + $this->assertSame(['.sv' => 'timestamp'], $this->firebase->lastBody()['updatedAt']); + } + + #[Test] + public function a_failure_that_is_not_a_conflict_is_not_retried(): void + { + // kreait reports every failed write inside a transaction as a failed + // transaction. Retrying one that will never succeed wastes five round + // trips and then reports contention that never happened. + $this->firebase + ->willRespondWithETag(['age' => 36], 'etag-1') + ->willRespondWith(['error' => 'Permission denied'], 401); + + try { + $this->users->modify('user-1', static fn (User $user): User => $user); + $this->fail('The failure should have been raised.'); + } catch (\Throwable $e) { + $this->assertNotInstanceOf(TransactionConflict::class, $e); + } + + // One read and one write, not five of each. + $this->assertSame(2, $this->firebase->requestCount()); + } + + #[Test] + public function giving_up_says_what_the_last_attempt_actually_failed_with(): void + { + for ($i = 0; $i < 2; ++$i) { + $this->firebase + ->willRespondWithETag(['age' => 36], 'etag-'.$i) + ->willRespondWith(['error' => 'Precondition Failed'], 412); + } + + try { + $this->users->modify('user-1', static fn (User $user): User => $user, attempts: 2); + $this->fail('The transaction should have given up.'); + } catch (TransactionConflict $e) { + $this->assertStringContainsString('after 2 attempts', $e->getMessage()); + $this->assertStringContainsString('The last attempt failed with', $e->getMessage()); + } + } + + #[Test] + public function a_transaction_that_keeps_losing_eventually_gives_up(): void + { + for ($i = 0; $i < 3; ++$i) { + $this->firebase + ->willRespondWithETag(['age' => 36], 'etag-'.$i) + ->willRespondWith(['error' => 'Precondition Failed'], 412); + } + + $this->expectException(TransactionConflict::class); + $this->expectExceptionMessage('after 3 attempts'); + + $this->users->modify('user-1', static fn (User $user): User => $user, attempts: 3); + } + + #[Test] + public function updating_selected_fields_is_a_patch(): void + { + $this->firebase->willRespondWith(null); + + $this->users->update('user-1', ['lastName' => 'King']); + + $this->assertSame('PATCH', $this->firebase->lastRequest()->getMethod()); + $this->assertSame(['lastName' => 'King'], $this->firebase->lastBody()); + } + + #[Test] + public function deleting_is_a_delete_on_the_key_or_the_collection(): void + { + $this->firebase->willRespondWith(null)->willRespondWith(null); + + $this->users->delete('user-1'); + $this->assertSame('DELETE', $this->firebase->lastRequest()->getMethod()); + $this->assertSame('/users/user-1.json', $this->firebase->lastRequest()->getUri()->getPath()); + + $this->users->deleteAll(); + $this->assertSame('/users.json', $this->firebase->lastRequest()->getUri()->getPath()); + } + + #[Test] + public function an_equality_filter_becomes_order_by_child_and_equal_to(): void + { + $this->firebase->willRespondWith([]); + + $this->users->query()->whereEquals('role', 'admin')->fetch(); + + $this->assertSame(['orderBy' => '"role"', 'equalTo' => '"admin"'], $this->firebase->lastQuery()); + } + + #[Test] + public function range_filters_become_start_at_and_end_at(): void + { + $this->firebase->willRespondWith([]); + + $this->users->query() + ->where('age', Operator::GreaterThanOrEqual, 18) + ->where('age', Operator::LessThan, 65) + ->fetch(); + + $query = $this->firebase->lastQuery(); + $this->assertSame('"age"', $query['orderBy']); + $this->assertSame('18', $query['startAt']); + $this->assertSame('65', $query['endBefore']); + } + + #[Test] + public function a_limit_without_an_ordering_is_given_one(): void + { + // The database rejects a limit on its own: "orderBy must be defined + // when other query parameters are defined". Taking the first n of a + // keyed collection means in key order. + $this->firebase->willRespondWith([]); + + $this->users->query()->limit(5)->fetch(); + + $query = $this->firebase->lastQuery(); + + $this->assertSame('"$key"', $query['orderBy']); + $this->assertSame('5', $query['limitToFirst']); + } + + #[Test] + public function an_unfiltered_unlimited_read_asks_for_no_ordering(): void + { + $this->firebase->willRespondWith([]); + + $this->connection->readAll('users', Criteria::none()); + + $this->assertSame([], $this->firebase->lastQuery()); + } + + #[Test] + public function ordering_by_key_uses_the_key_token(): void + { + $this->firebase->willRespondWith([]); + + $this->users->query()->orderByKey()->fetch(); + + $this->assertSame('"$key"', $this->firebase->lastQuery()['orderBy']); + } + + #[Test] + public function an_ascending_limit_asks_for_the_first_n(): void + { + $this->firebase->willRespondWith([]); + + $this->users->query()->orderBy('age')->limit(10)->fetch(); + + $this->assertSame('10', $this->firebase->lastQuery()['limitToFirst']); + } + + #[Test] + public function a_descending_limit_asks_for_the_last_n_and_flips_the_result(): void + { + // The database only sorts ascending, so the newest three are the last + // three, handed back in reverse. + $this->firebase->willRespondWith([ + 'user-1' => ['firstName' => 'Ada', 'age' => 36], + 'user-2' => ['firstName' => 'Alan', 'age' => 41], + 'user-3' => ['firstName' => 'Grace', 'age' => 85], + ]); + + $found = $this->users->query()->orderBy('age', Direction::Descending)->limit(3)->fetch(); + + $this->assertSame('3', $this->firebase->lastQuery()['limitToLast']); + $this->assertSame(['user-3', 'user-2', 'user-1'], array_keys($found)); + } + + #[Test] + public function filtering_without_ordering_derives_the_ordering(): void + { + $this->firebase->willRespondWith([]); + + $this->connection->readAll('users', Criteria::none()->where('role', Operator::Equal, 'admin')); + + $this->assertSame('"role"', $this->firebase->lastQuery()['orderBy']); + } + + #[Test] + public function it_refuses_to_filter_on_two_fields(): void + { + $this->expectException(UnsupportedQuery::class); + $this->expectExceptionMessage('one field only'); + + $this->users->query()->whereEquals('role', 'admin')->whereEquals('age', 36)->fetch(); + } + + #[Test] + public function it_refuses_to_order_by_a_field_it_does_not_filter_on(): void + { + $this->expectException(UnsupportedQuery::class); + $this->expectExceptionMessage('must order by the field it filters on'); + + $this->users->query()->whereEquals('role', 'admin')->orderBy('age')->fetch(); + } + + #[Test] + public function it_refuses_more_than_one_ordering(): void + { + $this->expectException(UnsupportedQuery::class); + $this->expectExceptionMessage('order by one field only'); + + $this->users->query()->orderBy('age')->orderBy('lastName')->fetch(); + } + + #[Test] + public function it_refuses_operators_the_database_does_not_have(): void + { + $this->expectException(UnsupportedQuery::class); + $this->expectExceptionMessage('no "in" operator'); + + $this->users->query()->where('role', Operator::In, ['admin', 'member'])->fetch(); + } + + #[Test] + public function it_rejects_a_key_the_database_would_refuse(): void + { + $this->expectException(InvalidEntity::class); + $this->expectExceptionMessage('not a usable Realtime Database key'); + + $this->connection->read('users', 'a.dotted.key'); + } + + #[Test] + public function an_empty_collection_reads_as_an_empty_array(): void + { + $this->firebase->willRespondWith(null); + + $this->assertSame([], $this->users->all()); + $this->assertSame(0, $this->firebase->lastQuery() === [] ? 0 : 1); + } + + #[Test] + public function it_exposes_the_underlying_reference_as_an_escape_hatch(): void + { + $this->assertSame( + FakeDatabase::URI.'/users', + (string) $this->connection->reference('users')->getUri(), + ); + } +} diff --git a/tests/RepositoryTest.php b/tests/RepositoryTest.php new file mode 100644 index 0000000..269d933 --- /dev/null +++ b/tests/RepositoryTest.php @@ -0,0 +1,233 @@ +connection = new ArrayConnection(); + $this->users = new UserRepository($this->connection); + } + + #[Test] + public function saving_a_new_entity_gives_it_an_id_from_the_backend(): void + { + $saved = $this->users->save(User::named('Ada', 'Lovelace')); + + $this->assertSame('generated-1', $saved->id()); + $this->assertSame('Ada', $this->users->get('generated-1')->firstName); + } + + #[Test] + public function saving_leaves_the_original_entity_untouched(): void + { + $user = User::named('Ada', 'Lovelace'); + + $this->assertNotSame($user, $this->users->save($user)); + $this->assertNull($user->id()); + } + + #[Test] + public function saving_an_identified_entity_overwrites_it(): void + { + $this->users->save(User::named('Ada', 'Lovelace')->withId('user-1')); + $this->users->save(User::named('Grace', 'Hopper')->withId('user-1')); + + $this->assertSame('Grace', $this->users->get('user-1')->firstName); + $this->assertSame(1, $this->users->count()); + } + + #[Test] + public function the_stored_record_never_contains_the_id(): void + { + $this->users->save(User::named('Ada', 'Lovelace')->withId('user-1')); + + $this->assertNotNull($record = $this->connection->read('users', 'user-1')); + $this->assertArrayNotHasKey('id', $record); + } + + #[Test] + public function the_id_is_restored_from_the_key_on_the_way_back(): void + { + $this->users->save(User::named('Ada', 'Lovelace')->withId('user-1')); + + $this->assertSame('user-1', $this->users->get('user-1')->id()); + } + + #[Test] + public function find_returns_null_when_there_is_nothing(): void + { + $this->assertNull($this->users->find('nope')); + } + + #[Test] + public function get_throws_when_the_entity_is_missing(): void + { + $this->expectException(EntityNotFound::class); + $this->expectExceptionMessage('users/nope'); + + $this->users->get('nope'); + } + + #[Test] + public function it_saves_many_entities(): void + { + $saved = $this->users->saveMany([User::named('Ada', 'Lovelace'), User::named('Grace', 'Hopper')]); + + $this->assertSame(['generated-1', 'generated-2'], array_map(static fn (User $u): ?string => $u->id(), $saved)); + $this->assertCount(2, $this->users->all()); + } + + #[Test] + public function save_many_keeps_the_order_it_was_given_even_from_a_generator(): void + { + $entities = (static function (): iterable { + // A generator whose keys all collide, which must not lose entries. + yield User::named('Ada', 'Lovelace')->withId('user-1'); + yield User::named('Grace', 'Hopper'); + yield User::named('Alan', 'Turing')->withId('user-3'); + })(); + + $saved = $this->users->saveMany($entities); + + $this->assertSame( + ['Ada', 'Grace', 'Alan'], + array_map(static fn (User $u): string => $u->firstName, $saved), + ); + $this->assertSame(['user-1', 'generated-1', 'user-3'], array_map(static fn (User $u): ?string => $u->id(), $saved)); + } + + #[Test] + public function it_reads_the_whole_collection_keyed_by_id(): void + { + $this->users->save(User::named('Ada', 'Lovelace')->withId('user-1')); + $this->users->save(User::named('Grace', 'Hopper')->withId('user-2')); + + $users = $this->users->all(); + + $this->assertSame(['user-1', 'user-2'], array_keys($users)); + $this->assertSame('Hopper', $users['user-2']->lastName); + } + + #[Test] + public function an_empty_collection_reads_as_an_empty_array(): void + { + $this->assertSame([], $this->users->all()); + $this->assertSame(0, $this->users->count()); + } + + #[Test] + public function it_updates_selected_fields_only(): void + { + $this->users->save(User::named('Ada', 'Lovelace')->withId('user-1')); + + $this->users->update('user-1', ['lastName' => 'King']); + + $user = $this->users->get('user-1'); + $this->assertSame('King', $user->lastName); + $this->assertSame('Ada', $user->firstName); + } + + #[Test] + public function it_deletes_by_entity_and_by_id(): void + { + $ada = $this->users->save(User::named('Ada', 'Lovelace')); + $this->users->save(User::named('Grace', 'Hopper')->withId('user-2')); + + $this->users->delete($ada); + $this->users->delete('user-2'); + + $this->assertSame([], $this->users->all()); + } + + #[Test] + public function it_deletes_the_whole_collection(): void + { + $this->users->saveMany([User::named('Ada', 'Lovelace'), User::named('Grace', 'Hopper')]); + + $this->users->deleteAll(); + + $this->assertSame([], $this->users->all()); + } + + #[Test] + public function it_refuses_to_delete_an_entity_without_an_id(): void + { + $this->expectException(InvalidEntity::class); + $this->expectExceptionMessage('has no id'); + + $this->users->delete(User::named('Ada', 'Lovelace')); + } + + #[Test] + public function it_refuses_an_entity_of_the_wrong_class(): void + { + $this->expectException(InvalidEntity::class); + $this->expectExceptionMessage(User::class); + + // Deliberately the wrong type: this asserts the runtime guard, which + // is what protects callers who do not run static analysis. + /** @phpstan-ignore argument.type */ + $this->users->save(new Address()); + } + + #[Test] + public function it_rejects_an_id_that_would_escape_the_collection(): void + { + $this->expectException(InvalidEntity::class); + $this->expectExceptionMessage('must not be empty or contain a slash'); + + $this->users->find('../admins/root'); + } + + #[Test] + public function it_rejects_an_empty_id(): void + { + $this->expectException(InvalidEntity::class); + + $this->users->find(''); + } + + #[Test] + public function the_ready_made_repository_needs_no_subclass(): void + { + $users = new EntityRepository($this->connection, 'users', User::class); + + $saved = $users->save(User::named('Ada', 'Lovelace')); + + $this->assertSame('Ada', $users->get((string) $saved->id())->firstName); + } + + #[Test] + public function the_ready_made_repository_refuses_a_class_that_is_not_an_entity(): void + { + $this->expectException(InvalidEntity::class); + + /** @phpstan-ignore argument.type */ + new EntityRepository($this->connection, 'users', \stdClass::class); + } +} From 677f83f44099659b727dcb5c9b3182459e68b0e4 Mon Sep 17 00:00:00 2001 From: Adro Morelos Date: Fri, 21 Aug 2026 15:34:17 +0200 Subject: [PATCH 08/13] Firestore over the REST API google/cloud-firestore needs ext-grpc, which a library can't ask for. Plain HTTP works fine and runs anywhere. --- .../Firestore/FirestoreConnection.php | 513 ++++++++++++++++++ .../Firestore/FirestoreConnectionFactory.php | 108 ++++ .../Firestore/FirestoreTransaction.php | 51 ++ src/Database/Firestore/FirestoreWrite.php | 94 ++++ src/Database/Firestore/QueryTranslator.php | 101 ++++ src/Database/Firestore/ValueEncoder.php | 155 ++++++ src/Exception/BackendError.php | 107 ++++ tests/FakeHttpClient.php | 109 ++++ .../FirestoreConnectionFactoryTest.php | 118 ++++ tests/Firestore/FirestoreConnectionTest.php | 513 ++++++++++++++++++ tests/Firestore/ValueEncoderTest.php | 102 ++++ 11 files changed, 1971 insertions(+) create mode 100644 src/Database/Firestore/FirestoreConnection.php create mode 100644 src/Database/Firestore/FirestoreConnectionFactory.php create mode 100644 src/Database/Firestore/FirestoreTransaction.php create mode 100644 src/Database/Firestore/FirestoreWrite.php create mode 100644 src/Database/Firestore/QueryTranslator.php create mode 100644 src/Database/Firestore/ValueEncoder.php create mode 100644 src/Exception/BackendError.php create mode 100644 tests/FakeHttpClient.php create mode 100644 tests/Firestore/FirestoreConnectionFactoryTest.php create mode 100644 tests/Firestore/FirestoreConnectionTest.php create mode 100644 tests/Firestore/ValueEncoderTest.php diff --git a/src/Database/Firestore/FirestoreConnection.php b/src/Database/Firestore/FirestoreConnection.php new file mode 100644 index 0000000..dc59d1b --- /dev/null +++ b/src/Database/Firestore/FirestoreConnection.php @@ -0,0 +1,513 @@ +documentsPath = sprintf('projects/%s/databases/%s/documents', $projectId, $databaseId); + } + + public function read(string $collection, string $id): ?array + { + $path = $this->documentPath($collection, $id); + $response = $this->send('GET', $path); + + // A document that is not there is an answer, not a failure. + if ($response->getStatusCode() === 404) { + return null; + } + + return $this->fieldsOf($this->decode($response, 'GET', $path)); + } + + public function readAll(string $collection, Criteria $criteria): array + { + $this->guardExtras($criteria); + + $response = $this->request('POST', $this->queryPath($collection).':runQuery', [ + 'structuredQuery' => $this->translator->translate($this->collectionId($collection), $criteria), + ]); + + $records = []; + + foreach ($response as $result) { + // A result carries either a document or only a read timestamp, + // which is how Firestore reports "nothing matched". + if (!is_array($result) || !isset($result['document']) || !is_array($result['document'])) { + continue; + } + + $document = $result['document']; + $id = $this->idOf($document); + + if ($id !== null) { + $records[$id] = $this->fieldsOf($document); + } + } + + return $records; + } + + public function write(string $collection, string $id, array $data): void + { + $write = FirestoreWrite::encode($data, $this->encoder); + + // A server-side transform is only expressible in a commit, so a write + // carrying one takes that route instead of the plain document endpoint. + if ($write->needsCommit()) { + $this->commit([$this->documentWrite($collection, $id, $write)]); + + return; + } + + // A patch with no update mask replaces the document, creating it if it + // is not there yet, so removed fields need nothing more than absence. + $this->request('PATCH', $this->documentPath($collection, $id), ['fields' => $write->jsonFields()]); + } + + public function writeMany(string $collection, array $records): void + { + if ($records === []) { + return; + } + + $writes = []; + + foreach ($records as $id => $data) { + $writes[] = $this->documentWrite($collection, (string) $id, FirestoreWrite::encode($data, $this->encoder)); + } + + $this->commit($writes); + } + + public function transaction(callable $work, int $attempts = 5): mixed + { + for ($attempt = 1; $attempt <= $attempts; ++$attempt) { + $id = $this->beginTransaction(); + $transaction = new FirestoreTransaction($this, $id); + + try { + $result = $work($transaction); + } catch (\Throwable $e) { + $this->rollback($id); + + throw $e; + } + + $response = $this->send('POST', $this->documentsPath.':commit', [ + 'writes' => $transaction->writes(), + 'transaction' => $id, + ]); + + // ABORTED means something this transaction read has changed, which + // is the one failure worth simply trying again. + if ($response->getStatusCode() === 409) { + continue; + } + + $this->decode($response, 'POST', $this->documentsPath.':commit'); + + return $result; + } + + throw new TransactionConflict($attempts); + } + + /** + * @internal for {@see FirestoreTransaction} + * + * @return array|null + */ + public function readInTransaction(string $collection, string $id, string $transaction): ?array + { + $path = $this->documentPath($collection, $id).'?transaction='.urlencode($transaction); + $response = $this->send('GET', $path); + + if ($response->getStatusCode() === 404) { + return null; + } + + return $this->fieldsOf($this->decode($response, 'GET', $path)); + } + + /** + * The one place a Firestore write body is built. It takes a + * {@see FirestoreWrite}, which cannot exist without having been encoded, + * so no write path can skip sentinel handling. + * + * @return array + */ + public function documentWrite(string $collection, string $id, FirestoreWrite $write, bool $merge = false): array + { + $body = ['update' => [ + 'name' => $this->documentName($collection, $id), + 'fields' => $write->jsonFields(), + ]]; + + if ($write->transforms !== []) { + $body['updateTransforms'] = $write->transforms; + } + + if ($merge) { + // Every field the caller mentioned, including the removed ones, so + // that anything else on the document survives. + $body['updateMask'] = ['fieldPaths' => $write->touched]; + } + + return $body; + } + + /** + * @internal for {@see FirestoreTransaction} + * + * @param array $data + * + * @return array + */ + public function updateWrite(string $collection, string $id, array $data): array + { + return $this->documentWrite($collection, $id, FirestoreWrite::encode($data, $this->encoder)); + } + + /** + * @internal for {@see FirestoreTransaction} + * + * @return array + */ + public function deleteWrite(string $collection, string $id): array + { + return ['delete' => $this->documentName($collection, $id)]; + } + + public function create(string $collection, array $data): string + { + $write = FirestoreWrite::encode($data, $this->encoder); + + // The endpoint that allocates an id cannot apply a field transform, so + // a record carrying one gets an id here instead and is written through + // a commit. Firestore's own client SDKs generate ids the same way. + if ($write->needsCommit()) { + $id = self::generateId(); + + $this->commit([$this->documentWrite($collection, $id, $write)]); + + return $id; + } + + $created = $this->request('POST', $this->documentsPath.'/'.$collection, [ + 'fields' => $write->jsonFields(), + ]); + + return $this->idOf($created) + ?? throw new InvalidEntity('Firestore did not return a name for the created document.'); + } + + /** + * A document id shaped like the ones Firestore allocates: twenty + * characters of upper case, lower case and digits. + */ + private static function generateId(): string + { + $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + $id = ''; + + for ($i = 0; $i < 20; ++$i) { + $id .= $alphabet[random_int(0, 61)]; + } + + return $id; + } + + public function merge(string $collection, string $id, array $data): void + { + $write = FirestoreWrite::encode($data, $this->encoder); + + if ($write->needsCommit()) { + $this->commit([$this->documentWrite($collection, $id, $write, merge: true)]); + + return; + } + + // The update mask is what makes this a merge rather than a replace; + // without it the fields left out would be deleted. A field named in the + // mask but absent from the values is removed, which is how a removal + // sentinel takes effect. + $mask = implode('&', array_map( + static fn (string $field): string => 'updateMask.fieldPaths='.urlencode($field), + $write->touched, + )); + + $path = $this->documentPath($collection, $id).($mask === '' ? '' : '?'.$mask); + + $this->request('PATCH', $path, ['fields' => $write->jsonFields()]); + } + + public function delete(string $collection, string $id): void + { + $this->request('DELETE', $this->documentPath($collection, $id)); + } + + public function truncate(string $collection): void + { + // Firestore has no truncate: a collection is only the documents in it. + // So this reads the ids and deletes them in one commit. A collection + // too large to read in one response is better emptied from a batched + // job than from a single request. + $ids = array_keys($this->readAll($collection, Criteria::none())); + + if ($ids === []) { + return; + } + + $this->commit(array_map( + fn (string $id): array => ['delete' => $this->documentName($collection, $id)], + $ids, + )); + } + + public function count(string $collection, Criteria $criteria): int + { + $this->guardExtras($criteria); + + $response = $this->request('POST', $this->queryPath($collection).':runAggregationQuery', [ + 'structuredAggregationQuery' => [ + 'structuredQuery' => $this->translator->translate($this->collectionId($collection), $criteria), + 'aggregations' => [['count' => (object) [], 'alias' => 'count']], + ], + ]); + + foreach ($response as $result) { + $value = $result['result']['aggregateFields']['count'] ?? null; + + if (is_array($value)) { + $count = $this->encoder->decode($value); + + if (is_int($count)) { + return $count; + } + } + } + + return 0; + } + + /** + * The extra keys this connection knows how to honour. Override in a + * subclass that adds one - a cursor, for instance - and read it back off + * the criteria in an overridden {@see QueryTranslator}. + * + * @return list + */ + protected function supportedExtras(): array + { + return []; + } + + private function guardExtras(Criteria $criteria): void + { + $unsupported = $criteria->unsupportedExtras($this->supportedExtras()); + + if ($unsupported !== []) { + throw UnsupportedQuery::unknownExtras('Firestore', $unsupported); + } + } + + private function beginTransaction(): string + { + $response = $this->request('POST', $this->documentsPath.':beginTransaction', [ + 'options' => ['readWrite' => (object) []], + ]); + + $id = $response['transaction'] ?? null; + + if (!is_string($id) || $id === '') { + throw BackendError::unexpectedResponse('beginTransaction returned no transaction id.'); + } + + return $id; + } + + /** + * Abandons a transaction so the server stops holding its locks. A failure + * here is not worth reporting: the transaction is already being abandoned, + * and Firestore expires it on its own. + */ + private function rollback(string $id): void + { + try { + $this->send('POST', $this->documentsPath.':rollback', ['transaction' => $id]); + } catch (\Throwable) { + } + } + + /** + * Writes several documents in one request. Firestore commits a batch + * atomically, so either all of them land or none do. + * + * @param list> $writes + */ + private function commit(array $writes): void + { + $this->request('POST', $this->documentsPath.':commit', ['writes' => $writes]); + } + + /** + * Where a query is addressed. + * + * A subcollection like "users/ada/orders" is queried against its parent + * document, with only the last segment naming the collection. Firestore + * rejects a collectionId containing a slash, so the two have to be split. + */ + private function queryPath(string $collection): string + { + $position = strrpos($collection, '/'); + + return $position === false + ? $this->documentsPath + : $this->documentsPath.'/'.substr($collection, 0, $position); + } + + /** + * The last segment of a collection path, which is the collection's own id. + */ + private function collectionId(string $collection): string + { + $position = strrpos($collection, '/'); + + return $position === false ? $collection : substr($collection, $position + 1); + } + + private function documentPath(string $collection, string $id): string + { + return sprintf('%s/%s/%s', $this->documentsPath, $collection, rawurlencode($id)); + } + + /** + * The resource name of a document, as it appears inside a request body + * rather than in a URL, so it is not escaped. + */ + private function documentName(string $collection, string $id): string + { + return sprintf('%s/%s/%s', $this->documentsPath, $collection, $id); + } + + /** + * @param array|null $body + * + * @return array + */ + private function request(string $method, string $path, ?array $body = null): array + { + return $this->decode($this->send($method, $path, $body), $method, $path); + } + + /** + * @param array|null $body + */ + private function send(string $method, string $path, ?array $body = null): ResponseInterface + { + $request = $this->requestFactory + ->createRequest($method, self::BASE_URI.$path) + ->withHeader('Accept', 'application/json'); + + if ($body !== null) { + $request = $request + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream(json_encode($body, JSON_THROW_ON_ERROR))); + } + + return $this->client->sendRequest($request); + } + + /** + * @return array + */ + private function decode(ResponseInterface $response, string $method, string $path): array + { + if ($response->getStatusCode() >= 400) { + throw BackendError::fromResponse($method, self::BASE_URI.$path, $response); + } + + $body = (string) $response->getBody(); + + if (trim($body) === '') { + return []; + } + + $decoded = json_decode($body, true, flags: JSON_THROW_ON_ERROR); + + return is_array($decoded) ? $decoded : []; + } + + /** + * @param array $document + * + * @return array + */ + private function fieldsOf(array $document): array + { + $fields = $document['fields'] ?? []; + + if (!is_array($fields)) { + return []; + } + + /** @var array $fields */ + return $this->encoder->decodeFields($fields); + } + + /** + * The document id is the last segment of its resource name. + * + * @param array $document + */ + private function idOf(array $document): ?string + { + $name = $document['name'] ?? null; + + if (!is_string($name) || $name === '') { + return null; + } + + $position = strrpos($name, '/'); + + return $position === false ? $name : substr($name, $position + 1); + } +} diff --git a/src/Database/Firestore/FirestoreConnectionFactory.php b/src/Database/Firestore/FirestoreConnectionFactory.php new file mode 100644 index 0000000..30cce83 --- /dev/null +++ b/src/Database/Firestore/FirestoreConnectionFactory.php @@ -0,0 +1,108 @@ + $key */ + $projectId ??= is_string($key['project_id'] ?? null) ? $key['project_id'] : null; + + if ($projectId === null) { + throw new InvalidArgumentException('No project id was given and the service account file has no "project_id".'); + } + + return self::fromCredentials( + CredentialsLoader::makeCredentials(self::SCOPE, $key), + $projectId, + $databaseId, + ); + } + + public static function fromCredentials( + FetchAuthTokenInterface $credentials, + string $projectId, + string $databaseId = '(default)', + ): FirestoreConnection { + $stack = HandlerStack::create(); + $stack->push(new AuthTokenMiddleware($credentials)); + + $factory = new HttpFactory(); + + return new FirestoreConnection( + new Client(['handler' => $stack, 'auth' => 'google_auth', 'http_errors' => false]), + $factory, + $factory, + $projectId, + $databaseId, + ); + } +} diff --git a/src/Database/Firestore/FirestoreTransaction.php b/src/Database/Firestore/FirestoreTransaction.php new file mode 100644 index 0000000..9f4555c --- /dev/null +++ b/src/Database/Firestore/FirestoreTransaction.php @@ -0,0 +1,51 @@ +> */ + private array $writes = []; + + public function __construct( + private readonly FirestoreConnection $connection, + private readonly string $id, + ) { + } + + public function read(string $collection, string $id): ?array + { + return $this->connection->readInTransaction($collection, $id, $this->id); + } + + public function write(string $collection, string $id, array $data): void + { + $this->writes[] = $this->connection->updateWrite($collection, $id, $data); + } + + public function delete(string $collection, string $id): void + { + $this->writes[] = $this->connection->deleteWrite($collection, $id); + } + + /** + * @return list> + */ + public function writes(): array + { + return $this->writes; + } +} diff --git a/src/Database/Firestore/FirestoreWrite.php b/src/Database/Firestore/FirestoreWrite.php new file mode 100644 index 0000000..2a08ab6 --- /dev/null +++ b/src/Database/Firestore/FirestoreWrite.php @@ -0,0 +1,94 @@ +> $fields + * @param list> $transforms + * @param list $removed + * @param list $touched every field the caller mentioned, removals included + */ + private function __construct( + public array $fields, + public array $transforms, + public array $removed, + public array $touched, + ) { + } + + /** + * @param array $data + */ + public static function encode(array $data, ValueEncoder $encoder): self + { + $fields = []; + $transforms = []; + $removed = []; + + foreach ($data as $name => $value) { + if ($value === Sentinel::ServerTimestamp) { + $transforms[] = ['fieldPath' => $name, 'setToServerValue' => 'REQUEST_TIME']; + + continue; + } + + if ($value === Sentinel::Remove) { + $removed[] = $name; + + continue; + } + + $fields[$name] = $encoder->encode($value); + } + + return new self($fields, $transforms, $removed, array_map(strval(...), array_keys($data))); + } + + /** + * Whether this write needs the commit endpoint. A transform cannot be + * expressed against the plain document endpoint. + */ + public function needsCommit(): bool + { + return $this->transforms !== []; + } + + /** + * The values, in a form that survives JSON encoding. + * + * PHP encodes an empty array as `[]`, and Firestore rejects that where it + * expects a map: "Cannot bind a list to map for field 'fields'". Every + * field being a sentinel is an ordinary thing to write - touching only a + * timestamp, or removing a single field - so this is the normal case, not + * an edge one. + * + * @return array>|object + */ + public function jsonFields(): array|object + { + return $this->fields === [] ? new \stdClass() : $this->fields; + } +} diff --git a/src/Database/Firestore/QueryTranslator.php b/src/Database/Firestore/QueryTranslator.php new file mode 100644 index 0000000..252b213 --- /dev/null +++ b/src/Database/Firestore/QueryTranslator.php @@ -0,0 +1,101 @@ + + */ + public function translate(string $collection, Criteria $criteria): array + { + $query = ['from' => [['collectionId' => $collection]]]; + + $filters = array_map($this->fieldFilter(...), $criteria->conditions); + + if (count($filters) === 1) { + $query['where'] = $filters[0]; + } elseif (count($filters) > 1) { + $query['where'] = ['compositeFilter' => ['op' => 'AND', 'filters' => $filters]]; + } + + if ($criteria->orderings !== []) { + $query['orderBy'] = array_map($this->order(...), $criteria->orderings); + } + + if ($criteria->limit !== null) { + $query['limit'] = $criteria->limit; + } + + return $query; + } + + /** + * @return array + */ + private function fieldFilter(Condition $condition): array + { + return [ + 'fieldFilter' => [ + 'field' => ['fieldPath' => $this->fieldPath($condition->field)], + 'op' => $this->operator($condition->operator), + 'value' => $this->encoder->encode($condition->value), + ], + ]; + } + + /** + * @return array + */ + private function order(Ordering $ordering): array + { + return [ + 'field' => ['fieldPath' => $this->fieldPath($ordering->field)], + 'direction' => $ordering->direction === Direction::Descending ? 'DESCENDING' : 'ASCENDING', + ]; + } + + private function fieldPath(string $field): string + { + return $field === Ordering::KEY ? self::DOCUMENT_ID : $field; + } + + private function operator(Operator $operator): string + { + return match ($operator) { + Operator::Equal => 'EQUAL', + Operator::LessThan => 'LESS_THAN', + Operator::LessThanOrEqual => 'LESS_THAN_OR_EQUAL', + Operator::GreaterThan => 'GREATER_THAN', + Operator::GreaterThanOrEqual => 'GREATER_THAN_OR_EQUAL', + Operator::In => 'IN', + Operator::Contains => 'ARRAY_CONTAINS', + }; + } +} diff --git a/src/Database/Firestore/ValueEncoder.php b/src/Database/Firestore/ValueEncoder.php new file mode 100644 index 0000000..93820df --- /dev/null +++ b/src/Database/Firestore/ValueEncoder.php @@ -0,0 +1,155 @@ + 'Ada', 'age' => 36]` is stored as: + * + * ['name' => ['stringValue' => 'Ada'], 'age' => ['integerValue' => '36']] + * + * @internal + */ +class ValueEncoder +{ + /** + * @param array $fields + * + * @return array> + */ + public function encodeFields(array $fields): array + { + $encoded = []; + + foreach ($fields as $name => $value) { + if ($value instanceof Sentinel) { + throw new InvalidEntity(sprintf( + 'The %s sentinel cannot be encoded as a value; it has to be resolved by the connection.', + $value->name, + )); + } + + $encoded[$name] = $this->encode($value); + } + + return $encoded; + } + + + /** + * @param array $fields + * + * @return array + */ + public function decodeFields(array $fields): array + { + $decoded = []; + + foreach ($fields as $name => $value) { + $decoded[$name] = is_array($value) ? $this->decode($value) : null; + } + + return $decoded; + } + + /** + * @return array + */ + public function encode(mixed $value): array + { + return match (true) { + $value === null => ['nullValue' => null], + is_bool($value) => ['booleanValue' => $value], + // Firestore takes and returns integers as strings. + is_int($value) => ['integerValue' => (string) $value], + is_float($value) => ['doubleValue' => $value], + is_string($value) => ['stringValue' => $value], + is_array($value) => $this->encodeArray($value), + default => throw new InvalidEntity(sprintf( + 'Cannot store a value of type %s in Firestore.', + get_debug_type($value), + )), + }; + } + + /** + * @param array $value + */ + public function decode(array $value): mixed + { + // A Firestore value is a single-key map naming its type. + return match (true) { + array_key_exists('nullValue', $value) => null, + array_key_exists('booleanValue', $value) => (bool) $value['booleanValue'], + array_key_exists('integerValue', $value) => (int) $value['integerValue'], + array_key_exists('doubleValue', $value) => (float) $value['doubleValue'], + array_key_exists('timestampValue', $value) => (string) $value['timestampValue'], + array_key_exists('stringValue', $value) => (string) $value['stringValue'], + array_key_exists('bytesValue', $value) => (string) $value['bytesValue'], + array_key_exists('referenceValue', $value) => (string) $value['referenceValue'], + array_key_exists('geoPointValue', $value) => $value['geoPointValue'], + array_key_exists('arrayValue', $value) => $this->decodeArray($value['arrayValue']), + array_key_exists('mapValue', $value) => $this->decodeMap($value['mapValue']), + default => null, + }; + } + + /** + * @param array $value + * + * @return array + */ + private function encodeArray(array $value): array + { + // A list becomes an arrayValue and anything else a mapValue, which is + // also why an empty array is stored as an empty list. + if (array_is_list($value)) { + return ['arrayValue' => ['values' => array_map($this->encode(...), $value)]]; + } + + /** @var array $value */ + return ['mapValue' => ['fields' => $this->encodeFields($value)]]; + } + + /** + * @return list + */ + private function decodeArray(mixed $arrayValue): array + { + if (!is_array($arrayValue) || !isset($arrayValue['values']) || !is_array($arrayValue['values'])) { + // Firestore omits "values" entirely for an empty array. + return []; + } + + $values = []; + + foreach ($arrayValue['values'] as $value) { + $values[] = is_array($value) ? $this->decode($value) : null; + } + + return $values; + } + + /** + * @return array + */ + private function decodeMap(mixed $mapValue): array + { + if (!is_array($mapValue) || !isset($mapValue['fields']) || !is_array($mapValue['fields'])) { + return []; + } + + /** @var array $fields */ + $fields = $mapValue['fields']; + + return $this->decodeFields($fields); + } +} diff --git a/src/Exception/BackendError.php b/src/Exception/BackendError.php new file mode 100644 index 0000000..23a8aba --- /dev/null +++ b/src/Exception/BackendError.php @@ -0,0 +1,107 @@ +getStatusCode(); + $error = self::errorFrom((string) $response->getBody()); + + $reason = $error['message'] ?? $response->getReasonPhrase(); + $googleStatus = $error['status'] ?? ''; + + return new self( + sprintf('%s %s failed with %d: %s', $method, $url, $status, $reason), + $status, + $reason, + $googleStatus, + ); + } + + /** + * Digs the useful part out of a Google error body. + * + * Most endpoints answer with `{"error": {...}}`. The streaming ones - + * `:runQuery` and `:runAggregationQuery` - answer with a JSON *array* of + * results, and report a failure as an array holding that same object. + * Missing the second shape loses the most valuable message these APIs + * produce: a missing index comes back with a link that creates it. + * + * @return array{message?: string, status?: string} + */ + private static function errorFrom(string $body): array + { + if (trim($body) === '') { + return []; + } + + $decoded = json_decode($body, true); + + if (!is_array($decoded)) { + return []; + } + + // Unwrap the streaming form, which puts the error in the first entry. + if (!array_key_exists('error', $decoded)) { + $first = $decoded[0] ?? null; + $decoded = is_array($first) ? $first : []; + } + + $error = $decoded['error'] ?? null; + + if (is_string($error)) { + return ['message' => $error]; + } + + if (!is_array($error)) { + return []; + } + + $found = []; + + if (isset($error['message']) && is_string($error['message']) && $error['message'] !== '') { + $found['message'] = $error['message']; + } + + if (isset($error['status']) && is_string($error['status'])) { + $found['status'] = $error['status']; + } + + return $found; + } +} diff --git a/tests/FakeHttpClient.php b/tests/FakeHttpClient.php new file mode 100644 index 0000000..4331ccb --- /dev/null +++ b/tests/FakeHttpClient.php @@ -0,0 +1,109 @@ + */ + private array $requests = []; + + public function __construct() + { + $this->handler = new MockHandler(); + + $stack = HandlerStack::create($this->handler); + $stack->push($this->recordRequests()); + + // http_errors off, because a PSR-18 client returns 4xx and 5xx rather + // than throwing, and the connection under test relies on that. + $this->client = new Client(['handler' => $stack, 'http_errors' => false]); + } + + public function client(): Client + { + return $this->client; + } + + public function factory(): HttpFactory + { + return new HttpFactory(); + } + + public function willRespondWith(mixed $body, int $status = 200): self + { + $this->handler->append(new Response($status, ['Content-Type' => 'application/json'], json_encode($body, JSON_THROW_ON_ERROR))); + + return $this; + } + + public function requestAt(int $index): RequestInterface + { + return $this->requests[$index] + ?? throw new LogicException(sprintf('No request was made at index %d.', $index)); + } + + public function lastRequest(): RequestInterface + { + return $this->requests[count($this->requests) - 1] + ?? throw new LogicException('No request was made.'); + } + + public function requestCount(): int + { + return count($this->requests); + } + + public function lastPath(): string + { + return $this->lastRequest()->getUri()->getPath(); + } + + public function lastQueryString(): string + { + return $this->lastRequest()->getUri()->getQuery(); + } + + /** + * @return array + */ + public function lastBody(): array + { + $decoded = json_decode((string) $this->lastRequest()->getBody(), true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * @return Closure(callable): Closure + */ + private function recordRequests(): Closure + { + return fn (callable $handler): Closure => function (RequestInterface $request, array $options) use ($handler) { + $this->requests[] = $request; + + return $handler($request, $options); + }; + } +} diff --git a/tests/Firestore/FirestoreConnectionFactoryTest.php b/tests/Firestore/FirestoreConnectionFactoryTest.php new file mode 100644 index 0000000..ab0ac36 --- /dev/null +++ b/tests/Firestore/FirestoreConnectionFactoryTest.php @@ -0,0 +1,118 @@ + */ + private array $files = []; + + protected function tearDown(): void + { + foreach ($this->files as $file) { + @unlink($file); + } + + $this->files = []; + } + + private function writeKeyFile(string $contents): string + { + $path = tempnam(sys_get_temp_dir(), 'php-firebase-key'); + + self::assertIsString($path); + file_put_contents($path, $contents); + + $this->files[] = $path; + + return $path; + } + + /** + * A structurally valid service account. The key is a throwaway generated + * for this test and authenticates nothing. + */ + private function serviceAccount(bool $withProjectId = true): string + { + $key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + self::assertNotFalse($key); + openssl_pkey_export($key, $privateKey); + + $account = [ + 'type' => 'service_account', + 'client_email' => 'test@test-project.iam.gserviceaccount.com', + 'private_key' => $privateKey, + ]; + + if ($withProjectId) { + $account['project_id'] = 'test-project'; + } + + return json_encode($account, JSON_THROW_ON_ERROR); + } + + #[Test] + public function it_builds_a_connection_from_a_service_account_file(): void + { + $connection = FirestoreConnectionFactory::fromServiceAccount($this->writeKeyFile($this->serviceAccount())); + + $this->assertInstanceOf(FirestoreConnection::class, $connection); + } + + #[Test] + public function an_explicit_project_id_is_accepted_when_the_file_has_none(): void + { + $connection = FirestoreConnectionFactory::fromServiceAccount( + $this->writeKeyFile($this->serviceAccount(withProjectId: false)), + 'other-project', + ); + + $this->assertInstanceOf(FirestoreConnection::class, $connection); + } + + #[Test] + public function it_reports_a_missing_file_clearly(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('cannot be read'); + + FirestoreConnectionFactory::fromServiceAccount('/no/such/service-account.json'); + } + + #[Test] + public function it_reports_a_file_that_is_not_json(): void + { + $this->expectException(JsonException::class); + + FirestoreConnectionFactory::fromServiceAccount($this->writeKeyFile('not json at all')); + } + + #[Test] + public function it_reports_a_file_that_is_not_a_json_object(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('not a JSON object'); + + FirestoreConnectionFactory::fromServiceAccount($this->writeKeyFile('"a string"')); + } + + #[Test] + public function it_insists_on_a_project_id_from_somewhere(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('project_id'); + + FirestoreConnectionFactory::fromServiceAccount($this->writeKeyFile($this->serviceAccount(withProjectId: false))); + } +} diff --git a/tests/Firestore/FirestoreConnectionTest.php b/tests/Firestore/FirestoreConnectionTest.php new file mode 100644 index 0000000..e7e3855 --- /dev/null +++ b/tests/Firestore/FirestoreConnectionTest.php @@ -0,0 +1,513 @@ +http = new FakeHttpClient(); + $this->connection = new FirestoreConnection( + $this->http->client(), + $this->http->factory(), + $this->http->factory(), + 'test-project', + ); + $this->users = new UserRepository($this->connection); + } + + /** + * @param array $fields + * + * @return array + */ + private function document(string $id, array $fields): array + { + return [ + 'name' => 'projects/test-project/databases/(default)/documents/users/'.$id, + 'fields' => $fields, + ]; + } + + #[Test] + public function reading_a_document_is_a_get_on_its_path(): void + { + $this->http->willRespondWith($this->document('user-1', [ + 'firstName' => ['stringValue' => 'Ada'], + 'age' => ['integerValue' => '36'], + ])); + + $user = $this->users->find('user-1'); + + $this->assertSame('GET', $this->http->lastRequest()->getMethod()); + $this->assertSame(self::DOCUMENTS.'/users/user-1', $this->http->lastPath()); + $this->assertNotNull($user); + $this->assertSame('Ada', $user->firstName); + $this->assertSame(36, $user->age); + $this->assertSame('user-1', $user->id()); + } + + #[Test] + public function a_missing_document_reads_as_null(): void + { + $this->http->willRespondWith(['error' => ['status' => 'NOT_FOUND']], 404); + + $this->assertNull($this->users->find('nope')); + } + + #[Test] + public function a_refused_request_carries_the_status_and_what_the_api_said(): void + { + $this->http->willRespondWith([ + 'error' => ['message' => 'The query requires an index. You can create it here: https://example.test/idx'], + ], 400); + + try { + $this->users->all(); + $this->fail('Expected the request to be reported as failed.'); + } catch (BackendError $e) { + $this->assertSame(400, $e->status); + $this->assertStringContainsString('requires an index', $e->reason); + $this->assertStringContainsString('400', $e->getMessage()); + } + } + + #[Test] + public function it_reads_the_error_out_of_a_streaming_response(): void + { + // runQuery streams, so Firestore reports a failure as an array holding + // the error rather than as the error itself. Missing this shape loses + // the message, which for a missing index carries the link that fixes it. + $this->http->willRespondWith([[ + 'error' => [ + 'code' => 400, + 'message' => 'The query requires an index. You can create it here: https://example.test/create', + 'status' => 'FAILED_PRECONDITION', + ], + ]], 400); + + try { + $this->users->all(); + $this->fail('Expected the request to be reported as failed.'); + } catch (BackendError $e) { + $this->assertStringContainsString('requires an index', $e->reason); + $this->assertStringContainsString('https://example.test/create', $e->getMessage()); + $this->assertSame('FAILED_PRECONDITION', $e->googleStatus); + } + } + + #[Test] + public function it_falls_back_to_the_status_line_when_there_is_no_error_body(): void + { + $this->http->willRespondWith('', 503); + + try { + $this->users->find('user-1'); + $this->fail('Expected the request to be reported as failed.'); + } catch (BackendError $e) { + $this->assertSame(503, $e->status); + $this->assertSame('', $e->googleStatus); + } + } + + #[Test] + public function a_permission_failure_is_not_mistaken_for_a_missing_document(): void + { + $this->http->willRespondWith(['error' => ['message' => 'Missing or insufficient permissions.']], 403); + + $this->expectException(BackendError::class); + + $this->users->find('user-1'); + } + + #[Test] + public function creating_a_document_posts_to_the_collection(): void + { + $this->http->willRespondWith($this->document('generated-id', [])); + + $saved = $this->users->save(User::named('Ada', 'Lovelace')); + + $this->assertSame('POST', $this->http->lastRequest()->getMethod()); + $this->assertSame(self::DOCUMENTS.'/users', $this->http->lastPath()); + $this->assertSame('generated-id', $saved->id()); + $this->assertSame(['stringValue' => 'Ada'], $this->http->lastBody()['fields']['firstName']); + } + + #[Test] + public function saving_an_identified_entity_patches_its_path(): void + { + $this->http->willRespondWith($this->document('user-1', [])); + + $this->users->save(User::named('Ada', 'Lovelace')->withId('user-1')); + + $this->assertSame('PATCH', $this->http->lastRequest()->getMethod()); + $this->assertSame(self::DOCUMENTS.'/users/user-1', $this->http->lastPath()); + // No update mask, so this replaces the document rather than merging. + $this->assertSame('', $this->http->lastQueryString()); + } + + #[Test] + public function the_written_document_never_contains_the_id(): void + { + $this->http->willRespondWith($this->document('user-1', [])); + + $this->users->save(User::named('Ada', 'Lovelace')->withId('user-1')); + + $this->assertArrayNotHasKey('id', $this->http->lastBody()['fields']); + } + + #[Test] + public function merging_sends_an_update_mask_naming_only_those_fields(): void + { + $this->http->willRespondWith($this->document('user-1', [])); + + $this->users->update('user-1', ['lastName' => 'King', 'age' => 37]); + + $this->assertSame('PATCH', $this->http->lastRequest()->getMethod()); + $this->assertSame( + 'updateMask.fieldPaths=lastName&updateMask.fieldPaths=age', + urldecode($this->http->lastQueryString()), + ); + } + + #[Test] + public function deleting_a_document_is_a_delete_on_its_path(): void + { + $this->http->willRespondWith([]); + + $this->users->delete('user-1'); + + $this->assertSame('DELETE', $this->http->lastRequest()->getMethod()); + $this->assertSame(self::DOCUMENTS.'/users/user-1', $this->http->lastPath()); + } + + #[Test] + public function querying_posts_a_structured_query(): void + { + $this->http->willRespondWith([ + ['document' => $this->document('user-1', ['firstName' => ['stringValue' => 'Ada']])], + ['document' => $this->document('user-2', ['firstName' => ['stringValue' => 'Grace']])], + ]); + + $found = $this->users->query()->whereEquals('role', 'admin')->fetch(); + + $this->assertSame('POST', $this->http->lastRequest()->getMethod()); + $this->assertSame(self::DOCUMENTS.':runQuery', $this->http->lastPath()); + + $query = $this->http->lastBody()['structuredQuery']; + $this->assertSame([['collectionId' => 'users']], $query['from']); + $this->assertSame([ + 'field' => ['fieldPath' => 'role'], + 'op' => 'EQUAL', + 'value' => ['stringValue' => 'admin'], + ], $query['where']['fieldFilter']); + + $this->assertSame(['user-1', 'user-2'], array_keys($found)); + $this->assertSame('Grace', $found['user-2']->firstName); + } + + #[Test] + public function two_filters_become_a_composite_and(): void + { + // The query Firestore runs happily and the Realtime Database refuses. + $this->http->willRespondWith([]); + + $this->users->query() + ->whereEquals('role', 'admin') + ->where('age', Operator::GreaterThan, 30) + ->fetch(); + + $where = $this->http->lastBody()['structuredQuery']['where']; + + $this->assertSame('AND', $where['compositeFilter']['op']); + $this->assertCount(2, $where['compositeFilter']['filters']); + } + + #[Test] + public function ordering_and_limits_are_sent_as_given(): void + { + $this->http->willRespondWith([]); + + $this->users->query()->orderBy('age', Direction::Descending)->limit(5)->fetch(); + + $query = $this->http->lastBody()['structuredQuery']; + + $this->assertSame([['field' => ['fieldPath' => 'age'], 'direction' => 'DESCENDING']], $query['orderBy']); + $this->assertSame(5, $query['limit']); + } + + #[Test] + public function ordering_by_key_uses_the_document_name_field(): void + { + $this->http->willRespondWith([]); + + $this->users->query()->orderByKey()->fetch(); + + $this->assertSame( + '__name__', + $this->http->lastBody()['structuredQuery']['orderBy'][0]['field']['fieldPath'], + ); + } + + #[Test] + public function results_without_a_document_are_skipped(): void + { + // Firestore reports "nothing matched" with a read timestamp only. + $this->http->willRespondWith([['readTime' => '2024-03-01T10:00:00Z']]); + + $this->assertSame([], $this->users->all()); + } + + #[Test] + public function a_transaction_begins_reads_and_commits_together(): void + { + $this->http + ->willRespondWith(['transaction' => 'TX1']) + ->willRespondWith($this->document('user-1', ['firstName' => ['stringValue' => 'Ada']])) + ->willRespondWith(['writeResults' => []]); + + $this->users->transaction(static function (EntityTransaction $users): void { + $user = $users->get('user-1'); + $user->firstName = 'Ada Byron'; + $users->save($user); + }); + + $requests = array_map( + fn (int $i): string => $this->http->requestAt($i)->getUri()->getPath(), + [0, 1, 2], + ); + + $this->assertSame([ + self::DOCUMENTS.':beginTransaction', + self::DOCUMENTS.'/users/user-1', + self::DOCUMENTS.':commit', + ], $requests); + + // The read is tagged with the transaction, and the commit carries it. + $this->assertSame('transaction=TX1', $this->http->requestAt(1)->getUri()->getQuery()); + + $body = $this->http->lastBody(); + $this->assertSame('TX1', $body['transaction']); + $this->assertSame(['stringValue' => 'Ada Byron'], $body['writes'][0]['update']['fields']['firstName']); + } + + #[Test] + public function a_conflicting_commit_is_retried_with_fresh_data(): void + { + $this->http + // First attempt: someone else commits first, so ours is aborted. + ->willRespondWith(['transaction' => 'TX1']) + ->willRespondWith($this->document('user-1', ['age' => ['integerValue' => '36']])) + ->willRespondWith(['error' => ['message' => 'ABORTED']], 409) + // Second attempt sees the newer value and succeeds. + ->willRespondWith(['transaction' => 'TX2']) + ->willRespondWith($this->document('user-1', ['age' => ['integerValue' => '40']])) + ->willRespondWith(['writeResults' => []]); + + $attempts = 0; + + $result = $this->users->modify('user-1', static function (User $user) use (&$attempts): User { + ++$attempts; + ++$user->age; + + return $user; + }); + + $this->assertSame(2, $attempts, 'The work should have been repeated after the conflict.'); + $this->assertSame(41, $result->age, 'The retry should have started from the newer value.'); + $this->assertSame('TX2', $this->http->lastBody()['transaction']); + } + + #[Test] + public function a_transaction_that_keeps_conflicting_eventually_gives_up(): void + { + for ($i = 0; $i < 3; ++$i) { + $this->http + ->willRespondWith(['transaction' => 'TX'.$i]) + ->willRespondWith($this->document('user-1', [])) + ->willRespondWith(['error' => ['message' => 'ABORTED']], 409); + } + + $this->expectException(TransactionConflict::class); + $this->expectExceptionMessage('after 3 attempts'); + + $this->users->modify('user-1', static fn (User $user): User => $user, attempts: 3); + } + + #[Test] + public function a_failure_inside_the_work_rolls_the_transaction_back(): void + { + $this->http + ->willRespondWith(['transaction' => 'TX1']) + ->willRespondWith($this->document('user-1', [])) + ->willRespondWith([]); + + $caught = null; + + try { + $this->users->transaction(static function (EntityTransaction $users): void { + $users->get('user-1'); + + throw new \DomainException('nope'); + }); + } catch (\Throwable $e) { + $caught = $e; + } + + $this->assertInstanceOf(\DomainException::class, $caught); + $this->assertSame(self::DOCUMENTS.':rollback', $this->http->lastPath()); + $this->assertSame('TX1', $this->http->lastBody()['transaction']); + } + + #[Test] + public function a_subcollection_document_is_addressed_by_its_full_path(): void + { + $this->http->willRespondWith([ + 'name' => 'projects/test-project/databases/(default)/documents/users/ada/orders/order-1', + 'fields' => ['reference' => ['stringValue' => 'ORD-1']], + ]); + + $record = $this->connection->read('users/ada/orders', 'order-1'); + + $this->assertSame(self::DOCUMENTS.'/users/ada/orders/order-1', $this->http->lastPath()); + $this->assertSame(['reference' => 'ORD-1'], $record); + } + + #[Test] + public function a_subcollection_is_queried_against_its_parent_document(): void + { + // Firestore rejects a collectionId containing a slash: the parent + // belongs in the request path and only the leaf names the collection. + $this->http->willRespondWith([]); + + $this->connection->readAll('users/ada/orders', Criteria::none()); + + $this->assertSame(self::DOCUMENTS.'/users/ada:runQuery', $this->http->lastPath()); + $this->assertSame( + [['collectionId' => 'orders']], + $this->http->lastBody()['structuredQuery']['from'], + ); + } + + #[Test] + public function counting_a_subcollection_is_addressed_the_same_way(): void + { + $this->http->willRespondWith([ + ['result' => ['aggregateFields' => ['count' => ['integerValue' => '2']]]], + ]); + + $count = $this->connection->count('users/ada/orders', Criteria::none()); + + $this->assertSame(self::DOCUMENTS.'/users/ada:runAggregationQuery', $this->http->lastPath()); + $this->assertSame(2, $count); + } + + #[Test] + public function counting_uses_an_aggregation_query(): void + { + $this->http->willRespondWith([ + ['result' => ['aggregateFields' => ['count' => ['integerValue' => '7']]]], + ]); + + $count = $this->users->count(); + + $this->assertSame(self::DOCUMENTS.':runAggregationQuery', $this->http->lastPath()); + $this->assertSame( + [['count' => [], 'alias' => 'count']], + $this->http->lastBody()['structuredAggregationQuery']['aggregations'], + ); + $this->assertSame(7, $count); + } + + #[Test] + public function truncating_reads_the_collection_then_deletes_it_in_one_commit(): void + { + $this->http + ->willRespondWith([ + ['document' => $this->document('user-1', [])], + ['document' => $this->document('user-2', [])], + ]) + ->willRespondWith([]); + + $this->users->deleteAll(); + + // One read plus one commit, however many documents there were. + $this->assertSame(2, $this->http->requestCount()); + $this->assertSame(self::DOCUMENTS.':commit', $this->http->lastPath()); + $this->assertSame([ + ['delete' => 'projects/test-project/databases/(default)/documents/users/user-1'], + ['delete' => 'projects/test-project/databases/(default)/documents/users/user-2'], + ], $this->http->lastBody()['writes']); + } + + #[Test] + public function truncating_an_empty_collection_commits_nothing(): void + { + $this->http->willRespondWith([]); + + $this->users->deleteAll(); + + $this->assertSame(1, $this->http->requestCount()); + } + + #[Test] + public function saving_identified_entities_takes_a_single_commit(): void + { + $this->http->willRespondWith([]); + + $this->users->saveMany([ + User::named('Ada', 'Lovelace')->withId('user-1'), + User::named('Grace', 'Hopper')->withId('user-2'), + ]); + + $this->assertSame(1, $this->http->requestCount()); + $this->assertSame(self::DOCUMENTS.':commit', $this->http->lastPath()); + + $writes = $this->http->lastBody()['writes']; + $this->assertCount(2, $writes); + $this->assertSame( + 'projects/test-project/databases/(default)/documents/users/user-1', + $writes[0]['update']['name'], + ); + $this->assertSame(['stringValue' => 'Grace'], $writes[1]['update']['fields']['firstName']); + } + + #[Test] + public function an_id_with_awkward_characters_is_escaped_in_the_path(): void + { + $this->http->willRespondWith($this->document('a b', [])); + + $this->connection->read('users', 'a b'); + + $this->assertSame(self::DOCUMENTS.'/users/a%20b', $this->http->lastRequest()->getUri()->getPath()); + } +} diff --git a/tests/Firestore/ValueEncoderTest.php b/tests/Firestore/ValueEncoderTest.php new file mode 100644 index 0000000..0747131 --- /dev/null +++ b/tests/Firestore/ValueEncoderTest.php @@ -0,0 +1,102 @@ +encoder = new ValueEncoder(); + } + + #[Test] + public function it_tags_each_scalar_with_its_type(): void + { + $this->assertSame(['nullValue' => null], $this->encoder->encode(null)); + $this->assertSame(['booleanValue' => true], $this->encoder->encode(true)); + $this->assertSame(['stringValue' => 'Ada'], $this->encoder->encode('Ada')); + $this->assertSame(['doubleValue' => 1.5], $this->encoder->encode(1.5)); + } + + #[Test] + public function integers_travel_as_strings(): void + { + // Firestore does this so 64-bit values survive JSON intact. + $this->assertSame(['integerValue' => '36'], $this->encoder->encode(36)); + $this->assertSame(36, $this->encoder->decode(['integerValue' => '36'])); + } + + #[Test] + public function a_large_integer_survives_the_round_trip(): void + { + $large = PHP_INT_MAX; + + $this->assertSame($large, $this->encoder->decode($this->encoder->encode($large))); + } + + #[Test] + public function a_list_becomes_an_array_value(): void + { + $this->assertSame( + ['arrayValue' => ['values' => [['stringValue' => 'a'], ['stringValue' => 'b']]]], + $this->encoder->encode(['a', 'b']), + ); + } + + #[Test] + public function a_map_becomes_a_map_value(): void + { + $this->assertSame( + ['mapValue' => ['fields' => ['city' => ['stringValue' => 'London']]]], + $this->encoder->encode(['city' => 'London']), + ); + } + + #[Test] + public function an_empty_array_is_stored_as_an_empty_list(): void + { + $this->assertSame(['arrayValue' => ['values' => []]], $this->encoder->encode([])); + $this->assertSame([], $this->encoder->decode(['arrayValue' => ['values' => []]])); + } + + #[Test] + public function firestore_omits_values_for_an_empty_array(): void + { + $this->assertSame([], $this->encoder->decode(['arrayValue' => []])); + } + + #[Test] + public function a_timestamp_is_read_as_its_string_form(): void + { + // Which is what the entity hydrator then turns into a date. + $this->assertSame( + '2024-03-01T10:00:00Z', + $this->encoder->decode(['timestampValue' => '2024-03-01T10:00:00Z']), + ); + } + + #[Test] + public function nested_records_round_trip(): void + { + $record = [ + 'firstName' => 'Ada', + 'age' => 36, + 'tags' => ['maths', 'engines'], + 'address' => ['city' => 'London', 'postcode' => 'N1'], + 'active' => true, + 'score' => 9.5, + ]; + + $this->assertSame($record, $this->encoder->decodeFields($this->encoder->encodeFields($record))); + } +} From ac8427bde6e197fcacdd7e55b4f159df625846b1 Mon Sep 17 00:00:00 2001 From: Adro Morelos Date: Fri, 21 Aug 2026 18:09:51 +0200 Subject: [PATCH 09/13] Transactions --- src/EntityTransaction.php | 77 ++++++++++++++++++++++++++ tests/TransactionTest.php | 112 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 src/EntityTransaction.php create mode 100644 tests/TransactionTest.php diff --git a/src/EntityTransaction.php b/src/EntityTransaction.php new file mode 100644 index 0000000..ff6e476 --- /dev/null +++ b/src/EntityTransaction.php @@ -0,0 +1,77 @@ + $entityClass + */ + public function __construct( + private Transaction $transaction, + private string $collection, + private string $entityClass, + ) { + } + + /** + * @return T|null + */ + public function find(string $id): ?EntityInterface + { + $record = $this->transaction->read($this->collection, $id); + + return $record === null ? null : ($this->entityClass)::fromArray($record, $id); + } + + /** + * @throws EntityNotFound + * + * @return T + */ + public function get(string $id): EntityInterface + { + return $this->find($id) + ?? throw EntityNotFound::withId($this->entityClass, $this->collection, $id); + } + + /** + * @param T $entity + */ + public function save(EntityInterface $entity): void + { + if (!$entity instanceof $this->entityClass) { + throw InvalidEntity::unexpectedClass($this->entityClass, $entity::class); + } + + $id = $entity->id() ?? throw InvalidEntity::missingId($entity::class); + + $this->transaction->write($this->collection, $id, $entity->toDatabase()); + } + + /** + * @param T|string $entity + */ + public function delete(EntityInterface|string $entity): void + { + $id = is_string($entity) + ? $entity + : $entity->id() ?? throw InvalidEntity::missingId($entity::class); + + $this->transaction->delete($this->collection, $id); + } +} diff --git a/tests/TransactionTest.php b/tests/TransactionTest.php new file mode 100644 index 0000000..b66391d --- /dev/null +++ b/tests/TransactionTest.php @@ -0,0 +1,112 @@ +users = new UserRepository(new ArrayConnection()); + + $ada = User::named('Ada', 'Lovelace'); + $ada->age = 36; + $this->users->save($ada->withId('ada')); + } + + #[Test] + public function modify_reads_changes_and_writes_back(): void + { + $written = $this->users->modify('ada', static function (User $user): User { + ++$user->age; + + return $user; + }); + + $this->assertSame(37, $written->age); + $this->assertSame(37, $this->users->get('ada')->age); + } + + #[Test] + public function modify_insists_the_entity_exists(): void + { + $this->expectException(EntityNotFound::class); + + $this->users->modify('nobody', static fn (User $user): User => $user); + } + + #[Test] + public function a_transaction_can_read_and_write_several_entities(): void + { + $this->users->save(User::named('Grace', 'Hopper')->withId('grace')); + + $this->users->transaction(function (EntityTransaction $users): void { + $ada = $users->get('ada'); + $grace = $users->get('grace'); + + $grace->age = $ada->age; + + $users->save($grace); + }); + + $this->assertSame(36, $this->users->get('grace')->age); + } + + #[Test] + public function a_transaction_returns_whatever_the_work_returns(): void + { + $name = $this->users->transaction( + static fn (EntityTransaction $users): string => $users->get('ada')->lastName, + ); + + $this->assertSame('Lovelace', $name); + } + + #[Test] + public function find_inside_a_transaction_returns_null_for_a_missing_entity(): void + { + $found = $this->users->transaction( + static fn (EntityTransaction $users): ?User => $users->find('nobody'), + ); + + $this->assertNull($found); + } + + #[Test] + public function a_transaction_can_delete(): void + { + $this->users->transaction(static function (EntityTransaction $users): void { + $users->delete('ada'); + }); + + $this->assertNull($this->users->find('ada')); + } + + #[Test] + public function saving_an_entity_without_an_id_inside_a_transaction_is_refused(): void + { + // A transaction protects a known location; there is nothing to protect + // about a record whose id does not exist yet. + $this->expectException(InvalidEntity::class); + $this->expectExceptionMessage('has no id'); + + $this->users->transaction(static function (EntityTransaction $users): void { + $users->save(User::named('Grace', 'Hopper')); + }); + } +} From a86ac2d313dd051b92ee8f09763b38197019b391 Mon Sep 17 00:00:00 2001 From: Adro Morelos Date: Sat, 22 Aug 2026 09:31:26 +0200 Subject: [PATCH 10/13] Server timestamps and field removal Resolution has to happen on the way to the wire, and I kept forgetting it on new write paths, so it's a type you can't build without resolving. --- src/Database/Sentinel.php | 44 +++++++ tests/SentinelInvariantTest.php | 126 +++++++++++++++++++ tests/SentinelTest.php | 209 ++++++++++++++++++++++++++++++++ 3 files changed, 379 insertions(+) create mode 100644 src/Database/Sentinel.php create mode 100644 tests/SentinelInvariantTest.php create mode 100644 tests/SentinelTest.php diff --git a/src/Database/Sentinel.php b/src/Database/Sentinel.php new file mode 100644 index 0000000..5450aa4 --- /dev/null +++ b/src/Database/Sentinel.php @@ -0,0 +1,44 @@ +updatedAt = Sentinel::ServerTimestamp; + * $orders->save($order); + * + * A sentinel only means something on the way in. What comes back is the + * resolved value - a timestamp, or nothing at all for a removed field. + * + * Until then it stands for a value the server has not decided yet, so it + * encodes as null. Without that, an entity holding one could not be passed to + * json_encode at all: a pure enum has no JSON form, and the failure would + * surface far from its cause. Extraction checks for a sentinel before it + * checks for JsonSerializable, so this does not affect what gets stored. + */ +enum Sentinel implements JsonSerializable +{ + /** + * The moment the server accepted the write, which is the only clock both + * ends of your system agree on. + */ + case ServerTimestamp; + + /** + * Remove the field entirely, as opposed to storing null. + */ + case Remove; + + public function jsonSerialize(): mixed + { + return null; + } +} diff --git a/tests/SentinelInvariantTest.php b/tests/SentinelInvariantTest.php new file mode 100644 index 0000000..7ac1f50 --- /dev/null +++ b/tests/SentinelInvariantTest.php @@ -0,0 +1,126 @@ + + */ + public static function writeTypes(): iterable + { + yield 'realtime database' => [RealtimeWrite::class]; + yield 'firestore' => [FirestoreWrite::class]; + } + + /** + * @param class-string $class + */ + #[Test] + #[DataProvider('writeTypes')] + public function resolved_data_cannot_be_constructed_directly(string $class): void + { + $constructor = (new ReflectionClass($class))->getConstructor(); + + $this->assertNotNull($constructor); + $this->assertTrue( + $constructor->isPrivate(), + $class.' must not be constructible directly, or resolution could be bypassed.', + ); + } + + /** + * @return iterable + */ + public static function writeMethods(): iterable + { + yield 'realtime set' => [RealtimeDatabaseConnection::class, 'set']; + yield 'firestore document write' => [FirestoreConnection::class, 'documentWrite']; + } + + /** + * @param class-string $class + */ + #[Test] + #[DataProvider('writeMethods')] + public function the_methods_that_reach_a_database_demand_resolved_data(string $class, string $method): void + { + $parameters = (new ReflectionMethod($class, $method))->getParameters(); + + $types = array_map( + static fn (\ReflectionParameter $p): string => $p->getType() instanceof ReflectionNamedType + ? $p->getType()->getName() + : '', + $parameters, + ); + + $this->assertTrue( + in_array(RealtimeWrite::class, $types, true) || in_array(FirestoreWrite::class, $types, true), + sprintf('%s::%s() must take a resolved write, not a bare array.', $class, $method), + ); + } + + #[Test] + public function resolving_replaces_the_realtime_sentinels(): void + { + $write = RealtimeWrite::resolve([ + 'touched' => Sentinel::ServerTimestamp, + 'gone' => Sentinel::Remove, + 'kept' => 'as is', + ]); + + $this->assertSame(['.sv' => 'timestamp'], $write->values['touched']); + $this->assertNull($write->values['gone']); + $this->assertSame('as is', $write->values['kept']); + } + + #[Test] + public function encoding_splits_the_firestore_sentinels_out_of_the_values(): void + { + $write = FirestoreWrite::encode([ + 'touched' => Sentinel::ServerTimestamp, + 'gone' => Sentinel::Remove, + 'kept' => 'as is', + ], new \PhpFirebase\Database\Firestore\ValueEncoder()); + + $this->assertSame(['kept' => ['stringValue' => 'as is']], $write->fields); + $this->assertSame([['fieldPath' => 'touched', 'setToServerValue' => 'REQUEST_TIME']], $write->transforms); + $this->assertSame(['gone'], $write->removed); + // The mask has to name everything the caller mentioned, or a removal + // would simply be left alone instead of removed. + $this->assertSame(['touched', 'gone', 'kept'], $write->touched); + $this->assertTrue($write->needsCommit()); + } + + #[Test] + public function a_record_without_sentinels_needs_no_commit(): void + { + $write = FirestoreWrite::encode(['kept' => 'as is'], new \PhpFirebase\Database\Firestore\ValueEncoder()); + + $this->assertFalse($write->needsCommit()); + $this->assertSame([], $write->transforms); + } +} diff --git a/tests/SentinelTest.php b/tests/SentinelTest.php new file mode 100644 index 0000000..329d8d0 --- /dev/null +++ b/tests/SentinelTest.php @@ -0,0 +1,209 @@ +newUserWith($updatedAt)->withId('user-1'); + } + + /** A user that has never been stored, so saving it has to allocate an id. */ + private function newUserWith(mixed $updatedAt): User + { + $user = User::named('Ada', 'Lovelace'); + $user->updatedAt = $updatedAt; + + return $user; + } + + #[Test] + public function an_entity_holding_a_sentinel_can_still_be_encoded(): void + { + // save() returns the entity you handed it, so it still carries the + // sentinel: the server has resolved it, but this copy has not been + // read back. Encoding that must not be fatal. + $user = $this->userWith(Sentinel::ServerTimestamp); + + $encoded = json_decode($user->toJson(), true); + + $this->assertNull($encoded['updatedAt']); + } + + #[Test] + public function the_realtime_database_writes_its_own_server_value(): void + { + $firebase = new FakeDatabase(); + $users = new UserRepository(new RealtimeDatabaseConnection($firebase->database())); + $firebase->willRespondWith(null); + + $users->save($this->userWith(Sentinel::ServerTimestamp)); + + $this->assertSame(['.sv' => 'timestamp'], $firebase->lastBody()['updatedAt']); + } + + #[Test] + public function the_realtime_database_removes_a_field_by_writing_null(): void + { + $firebase = new FakeDatabase(); + $users = new UserRepository(new RealtimeDatabaseConnection($firebase->database())); + $firebase->willRespondWith(null); + + $users->update('user-1', ['updatedAt' => Sentinel::Remove]); + + $this->assertNull($firebase->lastBody()['updatedAt']); + $this->assertArrayHasKey('updatedAt', $firebase->lastBody()); + } + + #[Test] + public function firestore_writes_a_server_timestamp_as_a_transform(): void + { + $http = new FakeHttpClient(); + $users = new UserRepository(new FirestoreConnection($http->client(), $http->factory(), $http->factory(), 'test-project')); + $http->willRespondWith(['writeResults' => []]); + + $users->save($this->userWith(Sentinel::ServerTimestamp)); + + // A transform is only expressible in a commit, so the write goes there. + $this->assertStringEndsWith(':commit', $http->lastPath()); + + $write = $http->lastBody()['writes'][0]; + $this->assertSame( + [['fieldPath' => 'updatedAt', 'setToServerValue' => 'REQUEST_TIME']], + $write['updateTransforms'], + ); + // The field must not also appear among the plain values. + $this->assertArrayNotHasKey('updatedAt', $write['update']['fields']); + } + + #[Test] + public function firestore_removes_a_field_by_masking_it_without_a_value(): void + { + $http = new FakeHttpClient(); + $users = new UserRepository(new FirestoreConnection($http->client(), $http->factory(), $http->factory(), 'test-project')); + $http->willRespondWith([]); + + $users->update('user-1', ['updatedAt' => Sentinel::Remove, 'lastName' => 'King']); + + $this->assertSame('PATCH', $http->lastRequest()->getMethod()); + $this->assertStringContainsString('updateMask.fieldPaths=updatedAt', urldecode($http->lastQueryString())); + $this->assertArrayNotHasKey('updatedAt', $http->lastBody()['fields']); + $this->assertSame(['stringValue' => 'King'], $http->lastBody()['fields']['lastName']); + } + + #[Test] + public function firestore_applies_a_transform_when_creating_a_new_document(): void + { + // The create path is separate from the update path, and the endpoint + // that allocates an id cannot carry a transform. + $http = new FakeHttpClient(); + $users = new UserRepository(new FirestoreConnection($http->client(), $http->factory(), $http->factory(), 'test-project')); + $http->willRespondWith(['writeResults' => []]); + + $saved = $users->save($this->newUserWith(Sentinel::ServerTimestamp)); + + $this->assertStringEndsWith(':commit', $http->lastPath()); + + $write = $http->lastBody()['writes'][0]; + $this->assertSame( + [['fieldPath' => 'updatedAt', 'setToServerValue' => 'REQUEST_TIME']], + $write['updateTransforms'], + ); + + // The id has to come back, because nothing else allocated one. + $this->assertNotNull($saved->id()); + $this->assertMatchesRegularExpression('/^[A-Za-z0-9]{20}$/', (string) $saved->id()); + $this->assertStringEndsWith('/users/'.$saved->id(), $write['update']['name']); + } + + #[Test] + public function firestore_still_uses_the_cheap_endpoint_when_creating_without_a_sentinel(): void + { + $http = new FakeHttpClient(); + $users = new UserRepository(new FirestoreConnection($http->client(), $http->factory(), $http->factory(), 'test-project')); + $http->willRespondWith([ + 'name' => 'projects/test-project/databases/(default)/documents/users/generated', + 'fields' => [], + ]); + + $saved = $users->save($this->newUserWith(null)); + + $this->assertSame('POST', $http->lastRequest()->getMethod()); + $this->assertStringEndsWith('/users', $http->lastPath()); + $this->assertSame('generated', $saved->id()); + } + + #[Test] + public function the_realtime_database_resolves_a_sentinel_when_pushing_a_new_record(): void + { + $firebase = new FakeDatabase(); + $users = new UserRepository(new RealtimeDatabaseConnection($firebase->database())); + $firebase->willRespondWith(['name' => '-NxGenerated']); + + $saved = $users->save($this->newUserWith(Sentinel::ServerTimestamp)); + + $this->assertSame('POST', $firebase->lastRequest()->getMethod()); + $this->assertSame(['.sv' => 'timestamp'], $firebase->lastBody()['updatedAt']); + $this->assertSame('-NxGenerated', $saved->id()); + } + + #[Test] + public function firestore_sends_an_empty_map_rather_than_an_empty_list(): void + { + // Removing the only named field leaves nothing among the values, and + // PHP encodes an empty array as [] - which Firestore rejects where it + // wants a map. Touching only a timestamp does the same thing. + $http = new FakeHttpClient(); + $users = new UserRepository(new FirestoreConnection($http->client(), $http->factory(), $http->factory(), 'test-project')); + $http->willRespondWith([]); + + $users->update('user-1', ['updatedAt' => Sentinel::Remove]); + + $body = (string) $http->lastRequest()->getBody(); + + $this->assertStringContainsString('"fields":{}', $body); + $this->assertStringNotContainsString('"fields":[]', $body); + } + + #[Test] + public function firestore_sends_an_empty_map_when_a_commit_carries_only_a_transform(): void + { + $http = new FakeHttpClient(); + $users = new UserRepository(new FirestoreConnection($http->client(), $http->factory(), $http->factory(), 'test-project')); + $http->willRespondWith(['writeResults' => []]); + + $users->update('user-1', ['updatedAt' => Sentinel::ServerTimestamp]); + + $body = (string) $http->lastRequest()->getBody(); + + $this->assertStringContainsString(':commit', $http->lastPath()); + $this->assertStringContainsString('"fields":{}', $body); + $this->assertStringNotContainsString('"fields":[]', $body); + } + + #[Test] + public function a_write_without_sentinels_still_uses_the_plain_document_endpoint(): void + { + $http = new FakeHttpClient(); + $users = new UserRepository(new FirestoreConnection($http->client(), $http->factory(), $http->factory(), 'test-project')); + $http->willRespondWith([]); + + $users->save($this->userWith(null)); + + $this->assertSame('PATCH', $http->lastRequest()->getMethod()); + $this->assertStringEndsWith('/users/user-1', $http->lastPath()); + } +} From 9a28ca2d86070a4a6d5dd64843035922260a62a2 Mon Sep 17 00:00:00 2001 From: Adro Morelos Date: Sat, 22 Aug 2026 10:47:39 +0200 Subject: [PATCH 11/13] Check both backends really do behave the same --- tests/BackendParityTest.php | 133 ++++++++++++++++++++++++++++++++ tests/ExtensibilityTest.php | 147 ++++++++++++++++++++++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 tests/BackendParityTest.php create mode 100644 tests/ExtensibilityTest.php diff --git a/tests/BackendParityTest.php b/tests/BackendParityTest.php new file mode 100644 index 0000000..479d848 --- /dev/null +++ b/tests/BackendParityTest.php @@ -0,0 +1,133 @@ + + */ + public static function backends(): iterable + { + yield 'realtime database' => [static function (): array { + $firebase = new FakeDatabase(); + + return [ + new RealtimeDatabaseConnection($firebase->database()), + static function (mixed $record) use ($firebase): void { + $firebase->willRespondWith($record); + }, + ]; + }]; + + yield 'firestore' => [static function (): array { + $http = new FakeHttpClient(); + + return [ + new FirestoreConnection( + $http->client(), + $http->factory(), + $http->factory(), + 'test-project', + ), + static function (mixed $record) use ($http): void { + $http->willRespondWith($record); + }, + ]; + }]; + } + + #[Test] + #[DataProvider('backends')] + public function the_same_entity_is_read_identically_from_every_backend(callable $build): void + { + [$connection, $respond] = $build(); + + $respond($this->storedUser($connection)); + + $user = (new UserRepository($connection))->find('user-1'); + + $this->assertNotNull($user); + $this->assertSame('Ada', $user->firstName); + $this->assertSame(36, $user->age); + $this->assertSame(Role::Admin, $user->role); + $this->assertSame(['maths', 'engines'], $user->tags); + $this->assertSame('2024-03-01T10:00:00+00:00', $user->joinedAt?->format('c')); + $this->assertSame('London', $user->address?->city); + $this->assertSame('user-1', $user->id()); + } + + #[Test] + public function an_entity_survives_a_full_round_trip_through_a_backend(): void + { + $users = new UserRepository(new ArrayConnection()); + + $ada = User::named('Ada', 'Lovelace'); + $ada->age = 36; + $ada->role = Role::Admin; + $ada->tags = ['maths', 'engines']; + $ada->joinedAt = new \DateTimeImmutable('2024-03-01T10:00:00+00:00'); + + $saved = $users->save($ada); + $read = $users->get((string) $saved->id()); + + $this->assertSame($ada->firstName, $read->firstName); + $this->assertSame($ada->age, $read->age); + $this->assertSame($ada->role, $read->role); + $this->assertSame($ada->tags, $read->tags); + $this->assertEquals($ada->joinedAt, $read->joinedAt); + } + + /** + * The same record, in whatever shape the backend in question stores it. + */ + private function storedUser(Connection $connection): mixed + { + $plain = [ + 'firstName' => 'Ada', + 'lastName' => 'Lovelace', + 'age' => 36, + 'role' => 'admin', + 'tags' => ['maths', 'engines'], + 'joinedAt' => '2024-03-01T10:00:00+00:00', + 'address' => ['street' => '1 Analytical Way', 'city' => 'London'], + ]; + + if (!$connection instanceof FirestoreConnection) { + return $plain; + } + + return [ + 'name' => 'projects/test-project/databases/(default)/documents/users/user-1', + 'fields' => [ + 'firstName' => ['stringValue' => 'Ada'], + 'lastName' => ['stringValue' => 'Lovelace'], + 'age' => ['integerValue' => '36'], + 'role' => ['stringValue' => 'admin'], + 'tags' => ['arrayValue' => ['values' => [['stringValue' => 'maths'], ['stringValue' => 'engines']]]], + 'joinedAt' => ['timestampValue' => '2024-03-01T10:00:00+00:00'], + 'address' => ['mapValue' => ['fields' => [ + 'street' => ['stringValue' => '1 Analytical Way'], + 'city' => ['stringValue' => 'London'], + ]]], + ], + ]; + } +} diff --git a/tests/ExtensibilityTest.php b/tests/ExtensibilityTest.php new file mode 100644 index 0000000..ea39d95 --- /dev/null +++ b/tests/ExtensibilityTest.php @@ -0,0 +1,147 @@ +client(), $http->factory(), $http->factory(), 'test-project'); + + $this->expectException(UnsupportedQuery::class); + $this->expectExceptionMessage('"startAfter"'); + + $connection->readAll('users', Criteria::none()->withExtra('startAfter', ['ada'])); + } + + #[Test] + public function the_realtime_database_refuses_it_too_rather_than_ignoring_it(): void + { + // The dangerous failure would be answering a different query silently. + $firebase = new FakeDatabase(); + $connection = new RealtimeDatabaseConnection($firebase->database()); + + $this->expectException(UnsupportedQuery::class); + $this->expectExceptionMessage('"startAfter"'); + + $connection->readAll('users', Criteria::none()->withExtra('startAfter', ['ada'])); + } + + #[Test] + public function a_third_party_can_add_a_cursor_without_forking(): void + { + $http = new FakeHttpClient(); + $http->willRespondWith([]); + + $connection = new CursorFirestoreConnection( + $http->client(), + $http->factory(), + $http->factory(), + 'test-project', + translator: new CursorQueryTranslator(), + ); + + $connection->readAll('users', Criteria::none()->orderBy('age')->withExtra('startAfter', [42])); + + $query = $http->lastBody()['structuredQuery']; + + $this->assertSame(['values' => [['integerValue' => '42']], 'before' => false], $query['startAt']); + } + + #[Test] + public function a_repository_can_hand_out_its_own_query_type(): void + { + $repository = new AdultUserRepository(new ArrayConnection()); + + $young = User::named('Kid', 'One'); + $young->age = 10; + $old = User::named('Ada', 'Lovelace'); + $old->age = 36; + $repository->saveMany([$young->withId('kid'), $old->withId('ada')]); + + $found = $repository->query()->adults()->fetch(); + + $this->assertSame(['ada'], array_keys($found)); + } +} + +/** + * @extends Query + */ +final readonly class UserQuery extends Query +{ + public function adults(): static + { + return $this->where('age', \PhpFirebase\Database\Operator::GreaterThanOrEqual, 18); + } +} + +/** + * @extends Repository + */ +final class AdultUserRepository extends Repository +{ + protected function collection(): string + { + return 'users'; + } + + protected function entityClass(): string + { + return User::class; + } + + public function query(): UserQuery + { + return new UserQuery($this->connection, $this->collection(), $this->entityClass()); + } +} + +final class CursorFirestoreConnection extends FirestoreConnection +{ + protected function supportedExtras(): array + { + return ['startAfter']; + } +} + +final readonly class CursorQueryTranslator extends QueryTranslator +{ + public function translate(string $collection, Criteria $criteria): array + { + $query = parent::translate($collection, $criteria); + $cursor = $criteria->extra('startAfter'); + + if (is_array($cursor)) { + $encoder = new \PhpFirebase\Database\Firestore\ValueEncoder(); + + $query['startAt'] = [ + 'values' => array_map($encoder->encode(...), $cursor), + 'before' => false, + ]; + } + + return $query; + } +} From 04699fd45033eb59cfee2a16f9dddba4c3e6e245 Mon Sep 17 00:00:00 2001 From: Adro Morelos Date: Sat, 22 Aug 2026 13:22:14 +0200 Subject: [PATCH 12/13] phpstan, cs-fixer, actions --- .gitattributes | 18 +++++-------- .github/workflows/ci.yml | 58 ++++++++++++++++++++++++++++++++++++++++ .gitignore | 10 ++++--- .php-cs-fixer.dist.php | 18 +++++++++++++ composer.json | 57 ++++++++++++++++++++++++++++++--------- phpstan.neon.dist | 5 ++++ phpunit.xml.dist | 49 +++++++++++---------------------- 7 files changed, 154 insertions(+), 61 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .php-cs-fixer.dist.php create mode 100644 phpstan.neon.dist diff --git a/.gitattributes b/.gitattributes index 0d4e9fa..cbe3c5d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,18 +1,12 @@ # Enforce Unix newlines * text=lf -# Exclude unused files -# see: https://redd.it/2jzp6k -/example export-ignore -/docs export-ignore +# Keep development files out of the installed package +/.github export-ignore /tests export-ignore -/.codeclimate.yml export-ignore -/.coveralls.yml export-ignore -/.editorconfig export-ignore /.gitattributes export-ignore /.gitignore export-ignore -/.travis.yml export-ignore -/CONTRIBUTING.md export-ignore -/README.md export-ignore -/phpcs.xml export-ignore -/phpunit.xml.dist export-ignore \ No newline at end of file +/.php-cs-fixer.dist.php export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore +/UPGRADING.md export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c680630 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + tests: + name: Tests on PHP ${{ matrix.php }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.3', '8.4', '8.5'] + + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: ctype, filter, json, mbstring + coverage: none + + - uses: ramsey/composer-install@v3 + + - run: vendor/bin/phpunit + + static-analysis: + name: Static analysis + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + + - uses: ramsey/composer-install@v3 + + - run: vendor/bin/phpstan analyse --no-progress --memory-limit=1G + + coding-standards: + name: Coding standards + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + + - uses: ramsey/composer-install@v3 + + - run: vendor/bin/php-cs-fixer fix --dry-run --diff diff --git a/.gitignore b/.gitignore index 6531905..0e782d6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ -build -html -vendor -composer.lock +/vendor/ +/composer.lock +/.phpunit.cache/ +/.php-cs-fixer.cache +/build/ +.phpactor.json diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..410ecdf --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,18 @@ +in([__DIR__.'/src', __DIR__.'/tests']); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(true) + ->setRules([ + '@PSR12' => true, + '@PHP83Migration' => true, + 'declare_strict_types' => true, + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + 'no_unused_imports' => true, + 'single_quote' => true, + 'trailing_comma_in_multiline' => ['elements' => ['arrays', 'arguments', 'parameters']], + ]) + ->setFinder($finder); diff --git a/composer.json b/composer.json index 46f6fb0..da35d8d 100644 --- a/composer.json +++ b/composer.json @@ -1,35 +1,68 @@ { "name": "adrorocker/php-firebase", - "description": "A PHP SDK for Google Firebase", + "description": "Entities and repositories for Firebase, mapping records to typed objects. Works with the Realtime Database and Firestore.", "license": "MIT", - "keywords": ["sdk", "php", "firebase", "google", "adrorocker"], + "keywords": [ + "firebase", + "firestore", + "realtime-database", + "repository", + "entity", + "php", + "odm" + ], "homepage": "https://github.com/adrorocker/php-firebase", "type": "library", "authors": [ { - "name": "Adro Rocker", + "name": "Adro Morelos", "email": "me@adro.rocks", "homepage": "https://github.com/adrorocker" } ], "require": { - "php": ">= 5.6", - "guzzlehttp/guzzle": "~6.0" + "php": "^8.3", + "ext-json": "*", + "cuyz/valinor": "^2.2", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" }, "require-dev": { - "phpunit/phpunit": "5.6.*" + "friendsofphp/php-cs-fixer": "^3.64", + "google/auth": "^1.53", + "guzzlehttp/guzzle": "^7.9", + "kreait/firebase-php": "^8.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^11.4" }, "autoload": { "psr-4": { - "PhpFirebase\\": ["src/", "extra/", "tests/"] - }, - "files": [ - "extra/Entities/functions.php" - ] + "PhpFirebase\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "PhpFirebase\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit", + "analyse": "phpstan analyse", + "cs": "php-cs-fixer fix --dry-run --diff", + "cs-fix": "php-cs-fixer fix" + }, + "config": { + "sort-packages": true }, "extra": { "branch-alias": { - "dev-master": "0.4-dev" + "dev-master": "1.0-dev" } + }, + "suggest": { + "kreait/firebase-php": "To use the Firebase Realtime Database as the backend, via RealtimeDatabaseConnection (^8.0)", + "google/auth": "To use Firestore with FirestoreConnectionFactory, which builds an authenticated client for you (^1.53)", + "guzzlehttp/guzzle": "A PSR-18 client for Firestore; any other will do, and google/auth brings this one along (^7.9)" } } diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..1cd333b --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,5 @@ +parameters: + level: 8 + paths: + - src + - tests diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 92da8c5..3388f4a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,38 +1,21 @@ - - - - - - + executionOrder="random" + failOnRisky="true" + failOnWarning="true" + beStrictAboutOutputDuringTests="true" + cacheDirectory=".phpunit.cache"> - - ./tests/ + + tests - - - - ./src - ./extra - - ./build - ./composer - ./tests - ./vendor - - - - - - - \ No newline at end of file + + + src + + + From 303b0ff10f5a135622c08a59714c6e70f7cf0770 Mon Sep 17 00:00:00 2001 From: Adro Morelos Date: Sat, 22 Aug 2026 16:58:02 +0200 Subject: [PATCH 13/13] Rewrite the readme, add an upgrade guide --- README.md | 616 ++++++++++++++++++++++++++++++++++++++++++++------- UPGRADING.md | 120 ++++++++++ 2 files changed, 658 insertions(+), 78 deletions(-) create mode 100644 UPGRADING.md diff --git a/README.md b/README.md index 53e938a..b8bbf38 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,27 @@ -[PHP-Firebase](https://github.com/adrorocker/php-firebase) -=================================== +# PHP-Firebase -A PHP SDK for Firebase REST API. +Entities and repositories for the **Firebase Realtime Database**, so your +application code deals in typed objects instead of nested arrays. Cloud +Firestore works through the same API if you need it. -[![Build status][Master image]][Master] -[![Coverage Status][Master covarage image]][Master covarage] -[![Latest Stable Version][Stable version image]][Stable version] -[![License][License image]][License] +```php +$users = new UserRepository($connection); + +$ada = new User(); +$ada->firstName = 'Ada'; +$ada->lastName = 'Lovelace'; + +$ada = $users->save($ada); // now carries its id + +$admins = $users->query() + ->whereEquals('role', 'admin') + ->limit(10) + ->fetch(); // array +``` ------------------------------------ +> **Version 1.0 is a rewrite** and breaks with 0.x deliberately. If you are +> coming from an older version, read [UPGRADING.md](UPGRADING.md), and +> [the short history](#a-short-history) for why. ## Installation @@ -16,124 +29,571 @@ A PHP SDK for Firebase REST API. composer require adrorocker/php-firebase ``` -## Usage +Requires PHP 8.3 or newer. The package itself pulls in four things - a mapper +and three PSR interface packages - and then you add a client for the database +you actually use: + +```bash +# Realtime Database +composer require kreait/firebase-php + +# Firestore +composer require google/auth +``` + +Neither is a hard dependency, so you never install the one you are not using. If +you already have an authenticated PSR-18 client, you need neither. + +## What this is + +This library is the mapping layer, not a Firebase SDK. It answers "how do I turn +these records into `User` objects and back" and leaves transport, credentials and +retries to a real SDK underneath. + +- **`Entity`** - a record, with typed properties instead of `stdClass`. +- **`Repository`** - reads and writes one collection of them, including batched + writes and optimistic transactions. +- **`Query`** - an immutable query builder returning hydrated entities. +- **`Connection`** - the port the first three talk to. Two adapters ship: the + Realtime Database (via `kreait/firebase-php`) and Firestore (over its REST API, + no `ext-grpc`). The entity layer itself has no Firebase in it, so swapping + backends does not touch your entities, repositories or queries. + +### What it is not + +**It is not an ORM, and Firebase is not a relational database.** That sentence is +the most useful thing on this page, because everything below it is shaped like +Doctrine and will happily let you pretend otherwise. + +There are no joins. There are no foreign keys. There is no query planner that +will rescue a design built around them. A `Repository` here is a typed door onto +one path in a document store - not a table, and not something you can join to +another one. + +**It does not stream, and that is deliberate.** The Realtime Database can push +changes over `text/event-stream`, and PHP can consume them perfectly well given +a runtime that stays alive - ReactPHP, Amp, Swoole, FrankenPHP in worker mode. +But holding a connection open, reconnecting it, and backing off when it drops is +infrastructure, and this package maps entities. Subscribe in whatever process +you already run for long-lived work, and use this to turn what arrives into +objects. + +Note that "Firebase" means something broader in 2026 than it did when this +package was written. The Realtime Database and Cloud Firestore are two separate +databases under the same brand, with different APIs and different query models. +The Realtime Database is what this package was built for and is still supported +by Google; Firestore is what Google recommends for new projects, which is why +both are here. + +Reading a record is delegated to [cuyz/valinor](https://github.com/CuyZ/Valinor), +which already handles enums, dates, nested objects and union types better than +anything worth hand-rolling. Writing is not: this library leaves private +properties and nulls out of the payload, and those are not valinor's defaults. + +## Modelling for Firebase, not against it + +Repositories and entities come from the relational world, where you model the +domain and then persist it. Firebase is the other way round: you model for the +reads you intend to do, and you denormalise on purpose. + +So resist the pull toward one repository per noun with references between them. +Fetching an order, then its customer, then each of its line items is three round +trips for something Firebase would rather you stored as one document. **Entities +nest** - an `Order` holding `LineItem` objects and a `Money` value object is a +single record, and that is usually the right shape: ```php -require '../vendor/autoload.php'; +$order = $orders->get('order-1'); // one read, whole aggregate +$order->lines[0]->price->amount; +``` -use PhpFirebase\Firebase; +Two consequences worth planning for: -// Base endpoint -$base = 'https://hey-123.firebaseio.com/somesubendpoint'; +- **A query cannot span collections.** If you need data together, store it + together. Duplicating a customer's name onto the order is normal here, not a + modelling failure. +- **Reads are cheap and writes are the constraint.** Denormalised copies have to + be kept in step, which is what [transactions](#transactions) and batched writes + are for. -// Auth token -$token = 'a1b2c3d4e5f6g7h8i9'; +Use the typed objects for what they are good at: making the record you already +decided to store safe to work with. Not for pretending it is a table. -$firebase = new Firebase($base,$token); +## Getting started -// Unique ID -$id = (new \DateTime())->getTimestamp(); +### 1. Describe your entity -// Set the data (body of the request). -$data = ['key' => 'value']; // The data could be even just a string +Public and protected properties are persisted. Private ones never are, which is +how the base class keeps its own `$id` out of the stored record: the key **is** +the id, so storing it twice only lets the two drift apart. -// Make a PUT request, the response is return -$put = $firebase->put('/logs/'.$id, $data); +```php +use PhpFirebase\Entity; -// Make a GET request, the response is return, -// you will have all the logs in the $get variable -$get = $firebase->get('/logs'); +enum Role: string +{ + case Admin = 'admin'; + case Member = 'member'; +} + +final class User extends Entity +{ + public string $firstName = ''; + public string $lastName = ''; + public ?int $age = null; + public Role $role = Role::Member; // backed enums are converted + public ?DateTimeImmutable $joinedAt = null; // as are dates +} ``` +Unknown keys in the stored record are ignored rather than fatal, which keeps old +records readable after you add a field. + +Entities are yours; `User` here is only an example. Any class works, including +aggregates holding plain value objects that know nothing about persistence: + +```php +final readonly class Money +{ + public function __construct(public int $amount, public string $currency) {} +} + +final class Order extends Entity +{ + public OrderStatus $status = OrderStatus::Pending; + public ?Money $total = null; + /** @var list */ + public array $lines = []; +} +``` -## Extras +#### Constructors -Now [PHP-Firebase](https://github.com/adrorocker/php-firebase) include a simple way to save and retrieve _Entities_ using repositories. +There are two shapes, and both work: -You can use them like this: +- **No constructor** - properties are filled directly. Simplest, and the kinder + choice for records that gain fields over time. +- **Every persisted property promoted into the constructor** - the object can + never exist half-built, which is what you want for an aggregate with + invariants to protect. -* Create an _entity_ class +A constructor covering only *some* persisted properties is **refused with an +error**, because the rest would silently keep their defaults on read and then +overwrite the stored values on the next save: ```php -// app/Model/User/User.php - */ +final class UserRepository extends Repository { - protected $id; - - public $firstName; + protected function collection(): string + { + return 'users'; + } - public $lastName; + protected function entityClass(): string + { + return User::class; + } } +``` +For the simple case, skip the subclass entirely: + +```php +$users = new EntityRepository($connection, 'users', User::class); ``` -* Create a _repository_ class +### 3. Connect it + +**Realtime Database**, through `kreait/firebase-php`: ```php -// app/Model/User/UserRepository.php -withServiceAccount('/path/to/service-account.json') + ->withDatabaseUri('https://your-project.firebaseio.com') + ->createDatabase(); + +$users = new UserRepository(new RealtimeDatabaseConnection($database)); +``` -use PhpFirebase\Entities\Repository\Repository; +**Firestore**, over its REST API: -class UserRepository extends Repository +```php +use PhpFirebase\Database\Firestore\FirestoreConnectionFactory; + +$connection = FirestoreConnectionFactory::fromServiceAccount('/path/to/service-account.json'); +// or, on Cloud Run / App Engine / GCE / after `gcloud auth application-default login`: +$connection = FirestoreConnectionFactory::fromApplicationDefaultCredentials('your-project-id'); + +$users = new UserRepository($connection); +``` + +Either way credentials come from a service account or Application Default +Credentials - not the legacy database secret 0.x used. + +## Using it + +```php +// Create: no id yet, so the backend allocates one +$ada = $users->save($ada); +$ada->id(); // '-NxAbc123...' + +// Read +$users->find('-NxAbc123...'); // ?User +$users->get('-NxAbc123...'); // User, or throws EntityNotFound +$users->all(); // array, keyed by id +$users->count(); + +// Update the whole record, or just part of it +$ada->lastName = 'King'; +$users->save($ada); +$users->update($ada, ['lastName' => 'King']); + +// Delete +$users->delete($ada); +$users->deleteAll(); +``` + +Entities are treated as immutable where it matters: `save()` and `withId()` return +a copy rather than mutating the instance you passed in. + +### Saving in batches + +`saveMany()` writes entities that already have an id in **one** round trip, using +a multi-path update on the Realtime Database and a batched commit on Firestore: + +```php +$users->saveMany([ + $ada->withId('user-1'), + $grace->withId('user-2'), +]); // one request, not two +``` + +Entities without an id are created one at a time, because only the backend can +allocate their ids. Mixing both in a single call is fine. + +### Subcollections and nested paths + +A collection is a path, so nesting works on both backends: + +```php +final class OrderRepository extends Repository { - public function __construct() + public function __construct(Connection $connection, private readonly string $userId) { - // Base endpoint - $base = 'https://hey-123.firebaseio.com/somesubendpoint'; - // Auth token - $token = 'a1b2c3d4e5f6g7h8i9'; + parent::__construct($connection); + } - $this->class = User::class; + protected function collection(): string + { + return sprintf('users/%s/orders', $this->userId); + } - parent::__construct($base, $token, '/users'); + protected function entityClass(): string + { + return Order::class; } } +``` + +On Firestore that is a real subcollection, queried against its parent document. +On the Realtime Database it is simply a deeper node in the tree. + +## Transactions + +Reading a value, changing it and writing it back is unsafe if anything else +might be doing the same. `modify()` does it properly: + +```php +$accounts->modify('ada', function (Account $account): Account { + $account->balance -= 10; + + return $account; +}); +``` + +The work runs against a consistent snapshot and is committed only if nothing it +read has changed. If another writer got there first, it runs **again** with the +newer data - so keep it repeatable, and send the receipt email after it returns +rather than inside it. After five losing attempts it gives up with +`TransactionConflict`. + +For more than one record, take the transaction itself: + +```php +$orders->transaction(function (EntityTransaction $orders): void { + $order = $orders->get('order-1'); + $order->status = OrderStatus::Shipped; + $orders->save($order); +}); +``` + +Read through the handle you are given, not through the repository: only reads +made there are watched for concurrent changes. + +**The guarantee differs by backend, and the difference is worth knowing.** +Firestore commits every write together or none of them. The Realtime Database +has no such thing - it checks each write individually against the version it +read, so a transaction touching two records can leave the first written and the +second refused. For the single-record case both give you exactly what you want. + +## Server timestamps and removing fields + +Some values are decided by the server, not by you: + +```php +use PhpFirebase\Database\Sentinel; + +$order->updatedAt = Sentinel::ServerTimestamp; // the server's clock, not yours +$orders->save($order); + +$orders->update($order, ['couponCode' => Sentinel::Remove]); // gone, not null +``` + +`Remove` is worth the distinction: storing `null` and having no field at all are +different states, and only the second one stops the field existing. + +`save()` returns the entity you handed it plus its id - it does not read back, +so that copy still holds the sentinel rather than the time the server chose. It +encodes as `null` until you re-read, which keeps an entity holding one safe to +`json_encode`. Read the record again when you need the resolved value. + +## Querying + +Queries are immutable, so a partly built one is safe to keep and branch from: + +```php +use PhpFirebase\Database\Direction; +use PhpFirebase\Database\Operator; + +$adults = $users->query()->where('age', Operator::GreaterThanOrEqual, 18); + +$firstTen = $adults->limit(10)->fetch(); +$oldest = $adults->orderBy('age', Direction::Descending)->first(); +``` + +`fetch()` returns entities keyed by id, `first()` returns one or `null`, and +`count()` returns how many matched. + +### What each backend can actually run + +This is where the two backends genuinely differ, and the library does not +pretend otherwise. + +| | Realtime Database | Firestore | +| --- | --- | --- | +| Filter on several fields | No - throws `UnsupportedQuery` | Yes, needs a composite index | +| Sort by a field other than the filtered one | No - throws `UnsupportedQuery` | Yes | +| Sort descending | Emulated client side | Native | +| `Operator::In`, `Operator::Contains` | No - throws `UnsupportedQuery` | Yes | +| `count()` | Reads the matching records | Native aggregation query | + +#### The Realtime Database + +The Realtime Database is not a query engine. It filters and sorts on **one field +per query**, and that field must be indexed in your security rules. Rather than +quietly returning the wrong rows, the adapter throws `UnsupportedQuery`: + +```php +$users->query() + ->whereEquals('role', 'admin') + ->whereEquals('age', 36) + ->fetch(); +// UnsupportedQuery: The Realtime Database cannot run this query: +// it can filter on one field only, and this query filters on role, age. +``` + +Three details it handles for you. Filtering implies ordering on the same field, +so you do not have to say it twice. Because the database only ever sorts +ascending, a descending query is served by reading the matching window and +reversing it. And a `limit()` with nothing to order by is given one - the +database rejects a bare limit with *"orderBy must be defined when other query +parameters are defined"*, and taking the first n of a keyed collection means in +key order. + +#### Firestore + +Firestore can express everything `Criteria` describes, so nothing is refused. It +will instead ask for an index the first time you run a compound query, and the +error it returns contains a link that creates it. + +Two things worth knowing about this adapter: + +- **Dates are stored as ISO-8601 strings**, not Firestore timestamps. They sort + and compare correctly because ISO-8601 is lexicographically ordered, and they + are read back into `DateTimeImmutable` either way. +- **`deleteAll()` is not atomic.** Firestore has no truncate - a collection is + only the documents in it - so it reads the collection and deletes each + document. Empty a large collection from a batched job instead. + +## When something goes wrong + +Everything this package throws implements +`PhpFirebase\Exception\PhpFirebaseException`, so it can all be caught together: + +| Exception | Means | +| --- | --- | +| `EntityNotFound` | `get()` found nothing under that id | +| `InvalidEntity` | a record could not be mapped, or a value could not be stored | +| `UnsupportedQuery` | this backend cannot express the query, and will not guess | +| `TransactionConflict` | a transaction lost the race on every attempt | +| `BackendError` | the backend refused the request | + +`BackendError` carries the HTTP `status` and Google's own `googleStatus` +(`FAILED_PRECONDITION`, `PERMISSION_DENIED`, ...), plus the message the API sent - +which matters, because the useful ones are specific: + +```php +try { + $tasks->query()->whereEquals('status', 'open')->orderBy('due')->fetch(); +} catch (BackendError $e) { + $e->googleStatus; // FAILED_PRECONDITION + $e->reason; // "The query requires an index. You can create it here: https://..." +} +``` + +That link creates the index. Firestore reports failures on its streaming +endpoints inside a JSON *array* rather than an object, and missing that shape +turns the most useful error either database produces into "Bad Request" - so +both shapes are unwrapped. + +A `TransactionConflict` says what the final attempt actually failed with, rather +than only that it gave up. Failures that are not conflicts are raised +immediately instead of being retried, since retrying them cannot help. +## Extending it + +The package is deliberately small, and the parts it leaves out are reachable +from outside rather than only by forking. + +### A different backend + +Implement `Connection` - nine methods over a collection of records, plus +`transaction()`. Nothing above it knows what Firebase is, so the same entities, +repositories and queries run against whatever you write. The test suite does +exactly this with an in-memory implementation, and a parity test asserts the +same entity comes back identically from each. + +### A constraint this package does not model + +Cursors are the honest example. Firestore has them; the Realtime Database +cannot express one, so a neutral API for them would be a Firestore feature in a +backend-neutral coat. Carry it as an extra instead: + +```php +$criteria = $criteria->orderBy('age')->withExtra('startAfter', [42]); ``` -* Usage +A connection declares the extras it honours, and **must refuse any it does not** +- otherwise a query silently means something different depending on where it +runs: ```php -require '../vendor/autoload.php'; +final class CursorFirestoreConnection extends FirestoreConnection +{ + protected function supportedExtras(): array + { + return ['startAfter']; + } +} + +final readonly class CursorQueryTranslator extends QueryTranslator +{ + public function translate(string $collection, Criteria $criteria): array + { + $query = parent::translate($collection, $criteria); + + if (is_array($cursor = $criteria->extra('startAfter'))) { + $query['startAt'] = ['values' => ..., 'before' => false]; + } -$repo = new UserRepository(); -// Create user -$user = new User([ - 'id' => 1, - 'firstName' => 'Adro', - 'lastName' => 'Rocker', -]); -$user = $repo->store($user); // $user will be an instance of App\Model\User + return $query; + } +} +``` -// Update user -// You can get or assign values to an entity property using a method named as the property name. -$user->lastName('Rocks'); // setting $lastName to be 'Rocks'. -$lastName = $user->lastName(); // getting $lastName, $lastName has the value 'Rocks'. -$user = $repo->store($user); +`FirestoreConnection` takes its `QueryTranslator` and `ValueEncoder` as +constructor arguments, so swapping either needs no changes here. There is a +working version of the above in `tests/ExtensibilityTest.php`. -// Find user -$user = $repo->find(1); // $user will be an instance of App\Model\User +### Query methods of your own +Subclass `Query`, and have the repository hand out your type: + +```php +/** @extends Query */ +final readonly class UserQuery extends Query +{ + public function adults(): static + { + return $this->where('age', Operator::GreaterThanOrEqual, 18); + } +} + +final class UserRepository extends Repository +{ + // A narrower return type is allowed, and is what callers see. + public function query(): UserQuery + { + return new UserQuery($this->connection, $this->collection(), $this->entityClass()); + } +} + +$users->query()->adults()->limit(10)->fetch(); ``` -## Authors: +Override `with()` in the subclass if you add state of your own; the chain keeps +returning your type either way. + +## A short history + +This is one of my oldest repositories. I wrote it back in 2016, when talking to +the Firebase Realtime Database from PHP mostly meant writing the REST calls +yourself, and it did that job well enough that it ended up inside a real product +at work. In 2018 I added an entity and repository layer on top of it. Then I +stopped touching it. + +It sat like that for years. Firebase moved on and PHP moved on, and the code did +not. It still asked for PHP 5.6 and Guzzle 6, it authenticated with a database +secret that Google has since deprecated, and it called a Guzzle function that +does not exist any more - so somewhere along the way a fresh install quietly +stopped working at all. + +I kept the repository anyway. It was one of my first open source projects and I +was fond of it. + +This version is that fondness acted on. The old HTTP client is gone; it was never +the good part, and `kreait/firebase-php` does that job properly. What I kept is +the entity layer, rewritten from scratch - typed properties, a real port +underneath, and Firestore alongside the Realtime Database. + +Preserved and reborn out of love for it, and in the hope that it is useful again. + +## Development + +```bash +composer install +composer test # phpunit +composer analyse # phpstan, level 8 +composer cs # php-cs-fixer, dry run +``` -[Alejandro Morelos](https://github.com/adrorocker). +## License - [Master]: https://travis-ci.org/adrorocker/php-firebase/ - [Master image]: https://travis-ci.org/adrorocker/php-firebase.svg?branch=master - [Master covarage]: https://coveralls.io/github/adrorocker/php-firebase - [Master covarage image]: https://coveralls.io/repos/github/adrorocker/php-firebase/badge.svg?branch=master - [Stable version]: https://packagist.org/packages/adrorocker/php-firebase - [Stable version image]: https://poser.pugx.org/adrorocker/php-firebase/v/stable - [License]: https://packagist.org/packages/adrorocker/php-firebase - [License image]: https://poser.pugx.org/adrorocker/php-firebase/license +MIT. See [LICENSE](LICENSE). diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 0000000..286c6d4 --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,120 @@ +# Upgrading + +## 0.x to 1.0 + +1.0 is a rewrite. The entity and repository layer survives with a changed API; +everything below it is gone. + +### Why + +0.x shipped its own HTTP client and authenticated by putting a Firebase database +secret in the query string. Google deprecated that credential, and the code had +rotted besides: it required PHP 5.6, Guzzle 6, and a `GuzzleHttp\Psr7\stream_for()` +function that no longer exists in Guzzle's PSR-7 package, so a fresh install +could not run at all. One repository also disabled TLS certificate verification. + +Rather than patch a client that was never the valuable part, 1.0 hands transport +and credentials to `kreait/firebase-php` and keeps the mapping layer. + +### What was removed + +| 0.x | 1.0 | +| --- | --- | +| `PhpFirebase\Firebase` | Gone. Use a `Connection`, or `kreait/firebase-php` directly for raw access. | +| `PhpFirebase\Clients\GuzzleClient` | Gone. | +| `PhpFirebase\Interfaces\ClientInterface` | Replaced by `PhpFirebase\Database\Connection`. | +| `PhpFirebase\Entities\Bridge` | Gone. Property mapping is internal to the hydrator. | +| `PhpFirebase\Entities\Call` | Gone. Declare typed properties instead of magic accessors. | +| `guid()` | Gone. Ids come from the backend, which orders them chronologically. | +| The `extra/` autoload path | Everything lives under `src/` now. | + +### What changed + +**Namespaces.** `PhpFirebase\Entities\Entity` is now `PhpFirebase\Entity`, and +`PhpFirebase\Entities\Repository\Repository` is now `PhpFirebase\Repository`. + +**Constructing a repository.** A repository no longer builds its own client from +a URL and a token. It takes a connection, and names its collection and entity +class through methods: + +```php +// 0.x +class UserRepository extends Repository +{ + public function __construct() + { + $this->class = User::class; + parent::__construct('https://hey-123.firebaseio.com', 'secret-token', '/users'); + } +} + +// 1.0 +final class UserRepository extends Repository +{ + protected function collection(): string + { + return 'users'; + } + + protected function entityClass(): string + { + return User::class; + } +} +``` + +**Entities do not take an array constructor.** Use `User::fromArray($data)`. + +If you declare a constructor of your own, every persisted property has to be +promoted into it - see the note on partial constructors below. 0.x entities +declared no constructor, so most carry over untouched. + +**The id is no longer stored inside the record.** It is the key. Existing data +that carries an `id` field still reads correctly; the key wins when they disagree, +and the field is dropped the next time the record is written. + +**Method names.** + +| 0.x | 1.0 | +| --- | --- | +| `store($entity)` | `save($entity)`, or `saveMany($entities)` for a list | +| `find($id)` | `find($id)` returns `null` when missing; `get($id)` throws | +| `fetch($criteria)` | `query()` with `where()` / `orderBy()` / `limit()`, then `fetch()` | +| `get()` | Gone - repositories no longer hold the last result. Use the value `fetch()` returns. | +| `top($n)` / `tail($n)` | `limit($n)`, with `orderBy(..., Direction::Descending)` for the tail | +| `orderBy($field)` | `orderBy($field, Direction::Ascending)` | +| `deleteAll()` | `deleteAll()` (unchanged) | + +**Queries are immutable.** In 0.x, `query()`, `top()` and `orderBy()` mutated the +repository, so constraints leaked into the next call. Each call now returns a new +query and the repository holds no state. + +**You must now require the Realtime Database client yourself.** This is a step +everyone upgrading has to take: you came from 0.x, so the Realtime Database is +what you are using. + +0.x reached it through its own bundled HTTP client. 1.0 reaches it through +`kreait/firebase-php`, which is not installed automatically - Firestore users do +not need it, so it is not a hard dependency of the package. For you it is +required: + +```bash +composer require kreait/firebase-php +``` + +**There is more than there was.** 0.x could read and write records and run a +single-field query. 1.0 adds batched writes, optimistic transactions +(`modify()` / `transaction()`), server-side value sentinels, subcollections, and +Firestore as a second backend. None of it has a 0.x equivalent to migrate from. + +**Errors are typed.** Everything this library throws implements +`PhpFirebase\Exception\PhpFirebaseException`: `EntityNotFound`, `InvalidEntity`, +`UnsupportedQuery`, `TransactionConflict`, and `BackendError` for a request the +backend refused. On the Realtime Database, transport failures still surface as +`kreait/firebase-php`'s own exceptions. + +**Entities may not declare a partial constructor.** A constructor covering only +some persisted properties is rejected, because the rest would be silently +skipped on read. Either declare no constructor, or promote every persisted +property into it. 0.x entities declared no constructor, so most will be fine as +they are.