├── .docker └── Dockerfile-migration ├── .env.example ├── .env.github-actions ├── LICENSE ├── composer.json ├── composer.lock ├── docker-compose.yml ├── phinx.yml └── src ├── Adapter └── Postgres.php ├── AdapterInterface.php ├── Attribute ├── Clause.php ├── InnerJoin.php ├── JoinInterface.php ├── LeftJoin.php └── Table.php ├── Client.php ├── ClientInterface.php ├── Configuration.php ├── Connection.php ├── Entity ├── Field.php └── Join.php ├── EntityInspector.php ├── EntityInterface.php ├── Hydrator.php ├── InspectedEntity.php ├── InspectedEntityInterface.php ├── LazyInspectedEntity.php ├── Middleware └── QueryCountMiddleware.php ├── MiddlewareInterface.php ├── MiddlewareRunner.php ├── Query ├── Limit.php ├── Order.php ├── Order │ ├── Asc.php │ └── Desc.php ├── OrderInterface.php ├── SectionInterface.php ├── Where.php ├── Where │ ├── Expression.php │ └── Field.php └── WhereInterface.php ├── Repository.php ├── RepositoryInterface.php └── Tools └── WithFieldsTrait.php /.docker/Dockerfile-migration: -------------------------------------------------------------------------------- 1 | FROM wyrihaximusnet/php:7.4-zts-alpine3.13-slim-dev AS migration 2 | 3 | WORKDIR /opt/migration 4 | ENTRYPOINT ["wait-for", "postgres:5432", "-t", "600", "--"] 5 | CMD ["make", "install_db"] 6 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PHINX_DB_HOST=postgres 2 | PHINX_DB_USER=postgres 3 | PHINX_DB_PASSWORD=postgres 4 | PHINX_DB_DATABASE=postgres 5 | -------------------------------------------------------------------------------- /.env.github-actions: -------------------------------------------------------------------------------- 1 | PHINX_DB_HOST=postgres 2 | PHINX_DB_USER=postgres 3 | PHINX_DB_PASSWORD=postgres 4 | PHINX_DB_DATABASE=postgres 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Cees-Jan Kiewiet 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wyrihaximus/react-simple-orm", 3 | "description": "EXPERIMENTAL: Package to see how feasible a simple ORM in ReactPHP is", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Cees-Jan Kiewiet", 8 | "email": "ceesjank@gmail.com", 9 | "homepage": "https://www.wyrihaximus.net/" 10 | } 11 | ], 12 | "require": { 13 | "php": "^8.2", 14 | "eventsauce/object-hydrator": "^1.4", 15 | "latitude/latitude": "^4.1", 16 | "ramsey/uuid": "^4.2.3", 17 | "react/event-loop": "^1.3", 18 | "react/promise": "^3.1", 19 | "react/stream": "^1.1", 20 | "reactivex/rxphp": "^2.0.12", 21 | "roave/better-reflection": "^6", 22 | "thecodingmachine/safe": "^2", 23 | "voryx/pgasync": "^2.0", 24 | "wyrihaximus/constants": "^1.5", 25 | "wyrihaximus/doctrine-annotation-autoloader": "^1.0", 26 | "wyrihaximus/react-event-loop-rx-scheduler-hook-up": "^0.1.1" 27 | }, 28 | "require-dev": { 29 | "robmorgan/phinx": "^0.12.11", 30 | "symfony/yaml": "^5.4 || ^7.0", 31 | "vlucas/phpdotenv": "^5.4", 32 | "wyrihaximus/async-test-utilities": "^8.0.1" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "WyriHaximus\\React\\SimpleORM\\": "src/" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { 41 | "WyriHaximus\\React\\Tests\\SimpleORM\\": "tests/" 42 | } 43 | }, 44 | "config": { 45 | "allow-plugins": { 46 | "composer/package-versions-deprecated": true, 47 | "dealerdirect/phpcodesniffer-composer-installer": true, 48 | "ergebnis/composer-normalize": true, 49 | "icanhazstring/composer-unused": true, 50 | "infection/extension-installer": true 51 | }, 52 | "platform": { 53 | "php": "8.2.13" 54 | }, 55 | "sort-packages": true 56 | }, 57 | "extra": { 58 | "unused": [ 59 | "react/dns", 60 | "react/event-loop", 61 | "react/stream" 62 | ] 63 | }, 64 | "scripts": { 65 | "post-install-cmd": [ 66 | "composer normalize", 67 | "composer update --lock --no-scripts" 68 | ], 69 | "post-update-cmd": [ 70 | "composer normalize", 71 | "composer update --lock --no-scripts" 72 | ] 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | migration: 5 | build: 6 | context: . 7 | dockerfile: .docker/Dockerfile-migration 8 | env_file: 9 | - .env 10 | volumes: 11 | - .:/opt/migration 12 | links: 13 | - postgres 14 | depends_on: 15 | - postgres 16 | networks: 17 | - reactphp-simple-orm 18 | 19 | postgres: 20 | ports: 21 | - 5432:5432 22 | image: postgres:16.2-alpine 23 | restart: always 24 | environment: 25 | POSTGRES_PASSWORD: postgres 26 | networks: 27 | - reactphp-simple-orm 28 | 29 | networks: 30 | reactphp-simple-orm: 31 | name: reactphp-simple-orm 32 | -------------------------------------------------------------------------------- /phinx.yml: -------------------------------------------------------------------------------- 1 | paths: 2 | migrations: '%%PHINX_CONFIG_DIR%%/etc/db/migrations' 3 | seeds: '%%PHINX_CONFIG_DIR%%/etc/db/seeds' 4 | 5 | environments: 6 | default_migration_table: phinxlog 7 | default_database: production 8 | production: 9 | adapter: pgsql 10 | host: '%%PHINX_DB_HOST%%' 11 | name: '%%PHINX_DB_DATABASE%%' 12 | user: '%%PHINX_DB_USER%%' 13 | pass: '%%PHINX_DB_PASSWORD%%' 14 | port: 5432 15 | charset: utf8 16 | 17 | version_order: creation 18 | -------------------------------------------------------------------------------- /src/Adapter/Postgres.php: -------------------------------------------------------------------------------- 1 | engine = new PostgresEngine(); 28 | } 29 | 30 | public function query(ExpressionInterface $expression): Observable 31 | { 32 | $params = $expression->params($this->engine); 33 | $sql = $expression->sql($this->engine); 34 | if (strpos($sql, '?') !== FALSE_) { 35 | $chunks = explode('?', $sql); 36 | $sqlChunks = []; 37 | foreach ($chunks as $i => $chunk) { 38 | if ($i === ZERO) { 39 | $sqlChunks[] = $chunk; 40 | continue; 41 | } 42 | 43 | $sqlChunks[] = '$' . $i . $chunk; 44 | } 45 | 46 | $sql = implode('', $sqlChunks); 47 | } 48 | 49 | return $this->client->executeStatement($sql, $params); 50 | } 51 | 52 | public function engine(): EngineInterface 53 | { 54 | return $this->engine; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/AdapterInterface.php: -------------------------------------------------------------------------------- 1 | $clause */ 15 | public function __construct( /** @phpstan-ignore-line */ 16 | public string $entity, 17 | public array $clause, 18 | public string $property, 19 | public bool $lazy = self::IS_NOT_LAZY, 20 | ) { 21 | $this->type = 'inner'; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Attribute/JoinInterface.php: -------------------------------------------------------------------------------- 1 | $clause 13 | */ 14 | interface JoinInterface 15 | { 16 | public const IS_LAZY = true; 17 | public const IS_NOT_LAZY = false; 18 | } 19 | -------------------------------------------------------------------------------- /src/Attribute/LeftJoin.php: -------------------------------------------------------------------------------- 1 | $clause */ 15 | public function __construct( /** @phpstan-ignore-line */ 16 | public string $entity, 17 | public array $clause, 18 | public string $property, 19 | public bool $lazy = self::IS_NOT_LAZY, 20 | ) { 21 | $this->type = 'left'; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Attribute/Table.php: -------------------------------------------------------------------------------- 1 | */ 18 | private array $repositories = []; 19 | 20 | private Connection $connection; 21 | 22 | private QueryFactory $queryFactory; 23 | 24 | private Hydrator $hydrator; 25 | 26 | public static function create(AdapterInterface $adapter, Configuration $configuration, MiddlewareInterface ...$middleware): self 27 | { 28 | return new self($adapter, $configuration, ...$middleware); 29 | } 30 | 31 | private function __construct(private AdapterInterface $adapter, Configuration $configuration, MiddlewareInterface ...$middleware) 32 | { 33 | $this->entityInspector = new EntityInspector($configuration); 34 | $this->queryFactory = new QueryFactory($adapter->engine()); 35 | 36 | $this->connection = new Connection($this->adapter, new MiddlewareRunner(...$middleware)); 37 | $this->hydrator = new Hydrator(); 38 | } 39 | 40 | /** 41 | * @param class-string $entity 42 | * 43 | * @return RepositoryInterface 44 | * 45 | * @template T 46 | */ 47 | public function repository(string $entity): RepositoryInterface 48 | { 49 | if (! array_key_exists($entity, $this->repositories)) { 50 | $this->repositories[$entity] = new Repository( 51 | $this->entityInspector->entity($entity), 52 | $this, 53 | $this->queryFactory, 54 | $this->connection, 55 | $this->hydrator, 56 | ); 57 | } 58 | 59 | return $this->repositories[$entity]; 60 | } 61 | 62 | public function query(ExpressionInterface $query): Observable 63 | { 64 | return $this->connection->query($query); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/ClientInterface.php: -------------------------------------------------------------------------------- 1 | $entity 14 | * 15 | * @return RepositoryInterface 16 | * 17 | * @template T 18 | */ 19 | public function repository(string $entity): RepositoryInterface; 20 | 21 | /** @deprecated This function will disappear at initial release */ 22 | public function query(ExpressionInterface $query): Observable; 23 | } 24 | -------------------------------------------------------------------------------- /src/Configuration.php: -------------------------------------------------------------------------------- 1 | middlewareRunner->query( 25 | $query, 26 | function (ExpressionInterface $query): PromiseInterface { 27 | return resolve($this->adapter->query($query)); 28 | }, 29 | ))->mergeAll(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Entity/Field.php: -------------------------------------------------------------------------------- 1 | name; 16 | } 17 | 18 | public function type(): string 19 | { 20 | return $this->type; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Entity/Join.php: -------------------------------------------------------------------------------- 1 | */ 13 | public array $clause; 14 | 15 | /** 16 | * @param InspectedEntityInterface $entity 17 | * 18 | * @template T 19 | */ 20 | public function __construct( 21 | public InspectedEntityInterface $entity, 22 | public string $type, 23 | public string $property, 24 | public bool $lazy, 25 | Clause ...$clause, 26 | ) { 27 | $this->clause = $clause; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/EntityInspector.php: -------------------------------------------------------------------------------- 1 | $entity 33 | * 34 | * @return InspectedEntityInterface 35 | * 36 | * @template T 37 | */ 38 | public function entity(string $entity): InspectedEntityInterface 39 | { 40 | if (! array_key_exists($entity, $this->entities)) { 41 | $class = new ReflectionClass($entity); 42 | $tableAttributes = $class->getAttributes(Table::class); 43 | 44 | if (count($tableAttributes) === 0) { 45 | throw new RuntimeException('Missing Table annotation on entity: ' . $entity); 46 | } 47 | 48 | $tableAttribute = current($tableAttributes)->newInstance(); 49 | 50 | $joins = [...$this->joins($class)]; 51 | $this->entities[$entity] = new InspectedEntity( 52 | $entity, 53 | $this->configuration->tablePrefix . $tableAttribute->table, 54 | [...$this->fields($class, $joins)], 55 | $joins, 56 | ); 57 | } 58 | 59 | return $this->entities[$entity]; 60 | } 61 | 62 | /** 63 | * @param ReflectionClass $class 64 | * @param Join[] $joins 65 | * 66 | * @return iterable 67 | */ 68 | private function fields(ReflectionClass $class, array $joins): iterable 69 | { 70 | foreach ($class->getProperties() as $property) { 71 | if (array_key_exists($property->getName(), $joins)) { 72 | continue; 73 | } 74 | 75 | $roaveProperty = (static function (BetterReflection $br, string $class): \Roave\BetterReflection\Reflection\ReflectionClass { 76 | if (method_exists($br, 'classReflector')) { 77 | return $br->classReflector()->reflect($class); 78 | } 79 | 80 | return $br->reflector()->reflectClass($class); 81 | })(new BetterReflection(), $class->getName())->getProperty($property->getName()); 82 | 83 | if ($roaveProperty === null) { 84 | continue; 85 | } 86 | 87 | /** @psalm-suppress PossiblyNullReference */ 88 | yield $property->getName() => new Field( 89 | $property->getName(), 90 | (static function (ReflectionProperty $property): string { 91 | $type = $property->getType(); 92 | if ($type !== null) { 93 | return (string) $type; 94 | } 95 | 96 | return (string) current($property->getDocBlockTypes()); 97 | })($roaveProperty), 98 | ); 99 | } 100 | } 101 | 102 | /** 103 | * @param ReflectionClass $class 104 | * 105 | * @return iterable 106 | */ 107 | private function joins(ReflectionClass $class): iterable 108 | { 109 | foreach ($class->getAttributes() as $attribute) { 110 | $annotation = $attribute->newInstance(); 111 | if ($annotation instanceof JoinInterface === false) { 112 | continue; 113 | } 114 | 115 | yield from $this->join($annotation); 116 | } 117 | } 118 | 119 | /** @return iterable */ 120 | private function join(JoinInterface $join): iterable 121 | { 122 | yield $join->property => new Join( 123 | new LazyInspectedEntity($this, $join->entity), 124 | $join->type, 125 | $join->property, 126 | $join->lazy, 127 | ...$join->clause, 128 | ); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/EntityInterface.php: -------------------------------------------------------------------------------- 1 | fallbackMapper = new ObjectMapperUsingReflection(); 18 | } 19 | 20 | /** @param array $data */ 21 | public function hydrate(InspectedEntityInterface $inspectedEntity, array $data): EntityInterface 22 | { 23 | foreach ($inspectedEntity->joins() as $join) { 24 | if (! is_array($data[$join->property])) { 25 | continue; 26 | } 27 | 28 | $data[$join->property] = $this->hydrate( 29 | $join->entity, 30 | $data[$join->property], 31 | ); 32 | } 33 | 34 | return $this->fallbackMapper->hydrateObject($inspectedEntity->class(), $data); 35 | } 36 | 37 | /** @return array */ 38 | public function extract(EntityInterface $entity): array 39 | { 40 | return $this->fallbackMapper->serializeObject($entity); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/InspectedEntity.php: -------------------------------------------------------------------------------- 1 | class; 23 | } 24 | 25 | public function table(): string 26 | { 27 | return $this->table; 28 | } 29 | 30 | /** @return Field[] */ 31 | public function fields(): array 32 | { 33 | return $this->fields; 34 | } 35 | 36 | /** @return Join[] */ 37 | public function joins(): array 38 | { 39 | return $this->joins; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/InspectedEntityInterface.php: -------------------------------------------------------------------------------- 1 | */ 18 | public function fields(): array; 19 | 20 | /** @return array */ 21 | public function joins(): array; 22 | } 23 | -------------------------------------------------------------------------------- /src/LazyInspectedEntity.php: -------------------------------------------------------------------------------- 1 | entityInspector = $entityInspector; 25 | } 26 | 27 | public function class(): string 28 | { 29 | return $this->class; 30 | } 31 | 32 | /** @psalm-suppress InvalidNullableReturnType */ 33 | public function table(): string 34 | { 35 | if ($this->table === null) { 36 | $this->loadEntity(); 37 | } 38 | 39 | /** 40 | * @phpstan-ignore-next-line 41 | * @psalm-suppress NullableReturnStatement 42 | */ 43 | return $this->table; 44 | } 45 | 46 | /** @return Field[] */ 47 | public function fields(): array 48 | { 49 | if ($this->table === null) { 50 | $this->loadEntity(); 51 | } 52 | 53 | return $this->fields; 54 | } 55 | 56 | /** @return Join[] */ 57 | public function joins(): array 58 | { 59 | if ($this->table === null) { 60 | $this->loadEntity(); 61 | } 62 | 63 | return $this->joins; 64 | } 65 | 66 | private function loadEntity(): void 67 | { 68 | if ($this->entityInspector === null) { 69 | return; 70 | } 71 | 72 | $inspectedEntity = $this->entityInspector->entity($this->class); 73 | $this->entityInspector = null; 74 | 75 | $this->table = $inspectedEntity->table(); 76 | $this->fields = $inspectedEntity->fields(); 77 | $this->joins = $inspectedEntity->joins(); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Middleware/QueryCountMiddleware.php: -------------------------------------------------------------------------------- 1 | initiatedCount++; 38 | 39 | $startTime = hrtime()[0]; 40 | 41 | return resolve($next($query))->then(function (Observable $observable) use ($startTime): PromiseInterface { 42 | return resolve(Observable::defer(function () use ($observable, $startTime): Subject { 43 | $handledInitialRow = false; 44 | $subject = new Subject(); 45 | $observable->subscribe( 46 | function (array $row) use ($subject, $startTime, &$handledInitialRow): void { 47 | $subject->onNext($row); 48 | 49 | if ($handledInitialRow === true) { 50 | return; 51 | } 52 | 53 | $this->successfulCount++; 54 | 55 | if (hrtime()[0] - $startTime > $this->slowQueryTime) { 56 | $this->slowCount++; 57 | } 58 | 59 | $handledInitialRow = true; 60 | }, 61 | function (Throwable $throwable) use ($startTime, $subject): void { 62 | $this->erroredCount++; 63 | 64 | if (hrtime()[0] - $startTime > $this->slowQueryTime) { 65 | $this->slowCount++; 66 | } 67 | 68 | $subject->onError($throwable); 69 | }, 70 | function () use ($subject, &$handledInitialRow): void { 71 | $this->completedCount++; 72 | $subject->onCompleted(); 73 | 74 | if ($handledInitialRow === true) { 75 | return; 76 | } 77 | 78 | $this->successfulCount++; 79 | }, 80 | ); 81 | 82 | return $subject; 83 | })); 84 | }); 85 | } 86 | 87 | /** @return iterable */ 88 | public function counters(): iterable 89 | { 90 | yield 'initiated' => $this->initiatedCount; 91 | yield 'successful' => $this->successfulCount; 92 | yield 'errored' => $this->erroredCount; 93 | yield 'slow' => $this->slowCount; 94 | yield 'completed' => $this->completedCount; 95 | } 96 | 97 | public function resetCounters(): void 98 | { 99 | $this->initiatedCount = self::ZERO; 100 | $this->successfulCount = self::ZERO; 101 | $this->erroredCount = self::ZERO; 102 | $this->slowCount = self::ZERO; 103 | $this->completedCount = self::ZERO; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/MiddlewareInterface.php: -------------------------------------------------------------------------------- 1 | */ 19 | private array $middleware; 20 | 21 | public function __construct(MiddlewareInterface ...$middleware) 22 | { 23 | $this->middleware = $middleware; 24 | } 25 | 26 | public function query(ExpressionInterface $query, callable $last): PromiseInterface 27 | { 28 | if (! array_key_exists(ZERO, $this->middleware)) { 29 | return $last($query); 30 | } 31 | 32 | return $this->call($query, ZERO, $last); 33 | } 34 | 35 | private function call(ExpressionInterface $query, int $position, callable $last): PromiseInterface 36 | { 37 | $nextPosition = $position; 38 | $nextPosition++; 39 | // final request handler will be invoked without hooking into the promise 40 | if (! array_key_exists($nextPosition, $this->middleware)) { 41 | return $this->middleware[$position]->query($query, $last); 42 | } 43 | 44 | return $this->middleware[$position]->query($query, function (ExpressionInterface $query) use ($nextPosition, $last): PromiseInterface { 45 | return $this->call($query, $nextPosition, $last); 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Query/Limit.php: -------------------------------------------------------------------------------- 1 | limit; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Query/Order.php: -------------------------------------------------------------------------------- 1 | */ 10 | private array $orders = []; 11 | 12 | public function __construct(OrderInterface ...$orders) 13 | { 14 | $this->orders = $orders; 15 | } 16 | 17 | /** @return iterable */ 18 | public function orders(): iterable 19 | { 20 | yield from $this->orders; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Query/Order/Asc.php: -------------------------------------------------------------------------------- 1 | field; 18 | } 19 | 20 | public function order(): string 21 | { 22 | return 'ASC'; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Query/Order/Desc.php: -------------------------------------------------------------------------------- 1 | field; 18 | } 19 | 20 | public function order(): string 21 | { 22 | return 'DESC'; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Query/OrderInterface.php: -------------------------------------------------------------------------------- 1 | */ 10 | private array $wheres = []; 11 | 12 | public function __construct(WhereInterface ...$wheres) 13 | { 14 | $this->wheres = $wheres; 15 | } 16 | 17 | /** @return iterable */ 18 | public function wheres(): iterable 19 | { 20 | yield from $this->wheres; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Query/Where/Expression.php: -------------------------------------------------------------------------------- 1 | $criteriaArguments */ 15 | public function __construct( 16 | private ExpressionInterface $expression, 17 | private string $criteria, 18 | private array $criteriaArguments = [], /** @phpstan-ignore-line */ 19 | ) { 20 | } 21 | 22 | public function expression(): ExpressionInterface 23 | { 24 | return $this->expression; 25 | } 26 | 27 | public function applyExpression(ExpressionInterface $expression): CriteriaInterface 28 | { 29 | /** @phpstan-ignore-next-line */ 30 | return (new CriteriaBuilder($expression))->{$this->criteria}(...$this->criteriaArguments); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Query/Where/Field.php: -------------------------------------------------------------------------------- 1 | field; 24 | } 25 | 26 | public function applyCriteria(CriteriaBuilder $criteria): CriteriaInterface 27 | { 28 | /** @phpstan-ignore-next-line */ 29 | return $criteria->{$this->criteria}(...$this->criteriaArguments); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Query/WhereInterface.php: -------------------------------------------------------------------------------- 1 | 49 | */ 50 | final class Repository implements RepositoryInterface 51 | { 52 | private const DATE_TIME_TIMEZONE_FORMAT = 'Y-m-d H:i:s e'; 53 | private const SINGLE = 1; 54 | private const STREAM_PER_PAGE = 100; 55 | 56 | /** @var ExpressionInterface[] */ 57 | private array $fields = []; 58 | 59 | /** @var string[] */ 60 | private array $tableAliases = []; 61 | 62 | public function __construct( 63 | private InspectedEntityInterface $entity, 64 | private ClientInterface $client, 65 | private QueryFactory $queryFactory, 66 | private Connection $connection, 67 | private Hydrator $hydrator, 68 | ) { 69 | } 70 | 71 | /** @return PromiseInterface */ 72 | public function count(Where|null $where = null): PromiseInterface 73 | { 74 | $query = $this->queryFactory->select(alias(func('COUNT', '*'), 'count'))->from(alias($this->entity->table(), 't0')); 75 | if ($where instanceof Where) { 76 | $query = $this->applyWhereToQuery($where, $query); 77 | } 78 | 79 | return $this->connection->query( 80 | $query->asExpression(), 81 | )->take(self::SINGLE)->toPromise()->then(static function (array $row): int { 82 | return (int) $row['count']; 83 | }); 84 | } 85 | 86 | /** @return Observable */ 87 | public function page(int $page, Where|null $where = null, Order|null $order = null, int $perPage = RepositoryInterface::DEFAULT_PER_PAGE): Observable 88 | { 89 | $query = $this->buildSelectQuery($where ?? new Where(), $order ?? new Order()); 90 | $query = $query->limit($perPage)->offset(--$page * $perPage); 91 | 92 | return $this->fetchAndHydrate($query); 93 | } 94 | 95 | /** @return Observable */ 96 | public function fetch(SectionInterface ...$sections): Observable 97 | { 98 | $query = $this->buildSelectQuery(...$sections); 99 | foreach ($sections as $section) { 100 | if (! ($section instanceof Limit) || $section->limit() <= ZERO) { 101 | continue; 102 | } 103 | 104 | $query = $query->limit($section->limit())->offset(ZERO); 105 | } 106 | 107 | return $this->fetchAndHydrate($query); 108 | } 109 | 110 | /** @return Observable */ 111 | public function stream(SectionInterface ...$sections): Observable 112 | { 113 | $stream = new Subject(); 114 | $query = $this->buildSelectQuery(...$sections); 115 | 116 | $page = function (int $offset) use (&$page, $query, $stream): void { 117 | $q = clone $query; 118 | 119 | $hasRows = false; 120 | $this->fetchAndHydrate($q->limit(self::STREAM_PER_PAGE)->offset($offset))->subscribe( 121 | /** @psalm-suppress MissingClosureParamType */ 122 | static function ($value) use (&$hasRows, $stream): void { 123 | if ($stream->isDisposed()) { 124 | return; 125 | } 126 | 127 | $hasRows = true; 128 | $stream->onNext($value); 129 | }, 130 | [$stream, 'onError'], 131 | static function () use (&$hasRows, &$page, $stream, $offset): void { 132 | if (! $hasRows || $stream->isDisposed()) { 133 | $stream->onCompleted(); 134 | 135 | return; 136 | } 137 | 138 | $page($offset + self::STREAM_PER_PAGE); 139 | }, 140 | ); 141 | }; 142 | 143 | $page(ZERO); 144 | 145 | return $stream; 146 | } 147 | 148 | /** 149 | * @param array $fields 150 | * 151 | * @return PromiseInterface 152 | */ 153 | public function create(array $fields): PromiseInterface 154 | { 155 | $id = Uuid::getFactory()->uuid4()->toString(); 156 | $fields['id'] = $id; 157 | $fields['created'] = new DateTimeImmutable(); 158 | $fields['modified'] = new DateTimeImmutable(); 159 | 160 | $fields = $this->prepareFields($fields); 161 | 162 | return $this->connection->query( 163 | $this->queryFactory->insert($this->entity->table(), $fields)->asExpression(), 164 | )->toPromise()->then(function () use ($id): PromiseInterface { 165 | return $this->fetch(new Where( 166 | new Where\Field( 167 | 'id', 168 | 'eq', 169 | [$id], 170 | ), 171 | ))->take(ONE)->toPromise(); 172 | }); 173 | } 174 | 175 | /** @return PromiseInterface */ 176 | public function update(EntityInterface $entity): PromiseInterface 177 | { 178 | $fields = $this->hydrator->extract($entity); 179 | $fields['modified'] = new DateTimeImmutable(); 180 | $fields = $this->prepareFields($fields); 181 | 182 | return $this->connection->query( 183 | $this->queryFactory->update($this->entity->table(), $fields)-> 184 | where(field('id')->eq($entity->id))->asExpression(), 185 | )->toPromise()->then(function () use ($entity): PromiseInterface { 186 | return $this->fetch(new Where( 187 | new Where\Field('id', 'eq', [$entity->id]), 188 | ), new Limit(ONE))->toPromise(); 189 | }); 190 | } 191 | 192 | /** @return PromiseInterface */ 193 | public function delete(EntityInterface $entity): PromiseInterface 194 | { 195 | return $this->connection->query( 196 | $this->queryFactory->delete($this->entity->table())-> 197 | where(field('id')->eq($entity->id))->asExpression(), 198 | )->toPromise(); 199 | } 200 | 201 | /** 202 | * @param array $sections 203 | * 204 | * @phpstan-ignore-next-line 205 | */ 206 | private function buildSelectQuery(SectionInterface ...$sections): SelectQuery 207 | { 208 | $query = $this->buildBaseSelectQuery(); 209 | $query = $query->columns(...array_values($this->fields)); 210 | foreach ($sections as $section) { 211 | /** @phpstan-ignore-next-line */ 212 | switch (TRUE_) { 213 | case $section instanceof Where: 214 | /** @psalm-suppress ArgumentTypeCoercion */ 215 | $query = $this->applyWhereToQuery($section, $query); 216 | break; 217 | case $section instanceof Order: 218 | /** @psalm-suppress UndefinedInterfaceMethod */ 219 | foreach ($section->orders() as $by) { 220 | $field = $this->translateFieldName($by->field()); 221 | $query = $query->orderBy($field, $by->order()); 222 | } 223 | 224 | break; 225 | } 226 | } 227 | 228 | return $query; 229 | } 230 | 231 | private function applyWhereToQuery(Where $constraints, SelectQuery $query): SelectQuery 232 | { 233 | foreach ($constraints->wheres() as $i => $constraint) { 234 | if ($constraint instanceof Expression) { 235 | $where = $constraint->expression(); 236 | $where = $constraint->applyExpression($where); 237 | } elseif ($constraint instanceof Field) { 238 | $where = field($this->translateFieldName($constraint->field())); 239 | $where = $constraint->applyCriteria($where); 240 | } else { 241 | continue; 242 | } 243 | 244 | if ($i === ZERO) { 245 | $query = $query->where($where); 246 | continue; 247 | } 248 | 249 | $query = $query->andWhere($where); 250 | } 251 | 252 | return $query; 253 | } 254 | 255 | private function buildBaseSelectQuery(): SelectQuery 256 | { 257 | $i = ZERO; 258 | $tableKey = spl_object_hash($this->entity) . '___root'; 259 | $this->tableAliases[$tableKey] = 't' . $i++; 260 | $query = $this->queryFactory->select()->from(alias($this->entity->table(), $this->tableAliases[$tableKey])); 261 | 262 | foreach ($this->entity->fields() as $field) { 263 | $this->fields[$this->tableAliases[$tableKey] . '___' . $field->name()] = alias($this->tableAliases[$tableKey] . '.' . $field->name(), $this->tableAliases[$tableKey] . '___' . $field->name()); 264 | } 265 | 266 | $query = $this->buildJoins($query, $this->entity, $i); 267 | 268 | return $query; 269 | } 270 | 271 | private function buildJoins(SelectQuery $query, InspectedEntityInterface $entity, int &$i, string $rootProperty = 'root'): SelectQuery 272 | { 273 | foreach ($entity->joins() as $join) { 274 | if ($join->type() !== 'inner') { 275 | continue; 276 | } 277 | 278 | if ($join->lazy() === JoinInterface::IS_LAZY) { 279 | continue; 280 | } 281 | 282 | if ($entity->class() === $join->entity()->class()) { 283 | continue; 284 | } 285 | 286 | $tableKey = spl_object_hash($join->entity) . '___' . $join->property; 287 | if (! array_key_exists($tableKey, $this->tableAliases)) { 288 | $this->tableAliases[$tableKey] = 't' . $i++; 289 | } 290 | 291 | $clauses = null; 292 | foreach ($join->clause as $clause) { 293 | $onLeftSide = $this->tableAliases[$tableKey] . '.' . $clause->foreignKey; 294 | if ($clause->foreignFunction !== null) { 295 | /** @psalm-suppress PossiblyNullOperand */ 296 | $onLeftSide = $clause->foreignFunction . '(' . $onLeftSide . ')'; 297 | } 298 | 299 | if ($clause->foreignCast !== null) { 300 | /** @psalm-suppress PossiblyNullOperand */ 301 | $onLeftSide = 'CAST(' . $onLeftSide . ' AS ' . $clause->foreignCast . ')'; 302 | } 303 | 304 | $onRightSide = 305 | $this->tableAliases[spl_object_hash($entity) . '___' . $rootProperty] . '.' . $clause->localKey; 306 | if ($clause->localFunction !== null) { 307 | /** @psalm-suppress PossiblyNullOperand */ 308 | $onRightSide = $clause->localFunction . '(' . $onRightSide . ')'; 309 | } 310 | 311 | if ($clause->localCast !== null) { 312 | /** @psalm-suppress PossiblyNullOperand */ 313 | $onRightSide = 'CAST(' . $onRightSide . ' AS ' . $clause->localCast . ')'; 314 | } 315 | 316 | if ($clauses === null) { 317 | $clauses = on($onLeftSide, $onRightSide); 318 | 319 | continue; 320 | } 321 | 322 | $clauses = on($onLeftSide, $onRightSide)->and($clauses); 323 | } 324 | 325 | if ($clauses !== null) { 326 | /** @psalm-suppress PossiblyNullArgument */ 327 | $query = $query->innerJoin( 328 | alias( 329 | $join->entity->table(), 330 | $this->tableAliases[$tableKey], 331 | ), 332 | $clauses, 333 | ); 334 | } 335 | 336 | foreach ($join->entity->fields() as $field) { 337 | $this->fields[$this->tableAliases[$tableKey] . '___' . $field->name()] = alias($this->tableAliases[$tableKey] . '.' . $field->name(), $this->tableAliases[$tableKey] . '___' . $field->name()); 338 | } 339 | 340 | unset($this->fields[$entity->table() . '___' . $join->property]); 341 | 342 | $query = $this->buildJoins($query, $join->entity, $i, $join->property); 343 | } 344 | 345 | return $query; 346 | } 347 | 348 | /** @return Observable */ 349 | private function fetchAndHydrate(QueryInterface $query): Observable 350 | { 351 | return $this->connection->query( 352 | $query->asExpression(), 353 | )->map(function (array $row): array { 354 | return $this->inflate($row); 355 | })->map(function (array $row): array { 356 | return $this->buildTree($row, $this->entity); 357 | })->map(function (array $row): EntityInterface { 358 | return $this->hydrator->hydrate($this->entity, $row); 359 | }); 360 | } 361 | 362 | /** 363 | * @param array $row 364 | * 365 | * @return array> 366 | */ 367 | private function inflate(array $row): array 368 | { 369 | $tables = []; 370 | 371 | foreach ($row as $key => $value) { 372 | [$table, $field] = explode('___', $key); 373 | $tables[$table][$field] = $value; 374 | } 375 | 376 | return $tables; 377 | } 378 | 379 | /** 380 | * @param array> $row 381 | * 382 | * @return array 383 | */ 384 | private function buildTree(array $row, InspectedEntityInterface $entity, string $tableKeySuffix = 'root'): array 385 | { 386 | $tableKey = spl_object_hash($entity) . '___' . $tableKeySuffix; 387 | $tree = $row[$this->tableAliases[$tableKey]]; 388 | 389 | foreach ($entity->joins() as $join) { 390 | if ($join->type() === 'inner' && $entity->class() !== $join->entity()->class() && $join->lazy() === false) { 391 | $tree[$join->property()] = $this->buildTree($row, $join->entity(), $join->property()); 392 | 393 | continue; 394 | } 395 | 396 | if ($join->type() === 'inner' && ($join->lazy() === JoinInterface::IS_LAZY || $entity->class() === $join->entity()->class())) { 397 | $tree[$join->property()] = new LazyPromise(function () use ($row, $join, $tableKey): PromiseInterface { 398 | return new Promise(function (callable $resolve, callable $reject) use ($row, $join, $tableKey): void { 399 | foreach ($join->clause() as $clause) { 400 | if ($row[$this->tableAliases[$tableKey]][$clause->localKey] === null) { 401 | $resolve(null); 402 | 403 | return; 404 | } 405 | } 406 | 407 | $where = []; 408 | 409 | foreach ($join->clause() as $clause) { 410 | $onLeftSide = $clause->foreignKey; 411 | if ($clause->foreignFunction !== null) { 412 | /** @psalm-suppress PossiblyNullArgument */ 413 | $onLeftSide = func($clause->foreignFunction, $onLeftSide); 414 | } 415 | 416 | if ($clause->foreignCast !== null) { 417 | /** @psalm-suppress PossiblyNullArgument */ 418 | $onLeftSide = alias(func('CAST', $onLeftSide), $clause->foreignCast); 419 | } 420 | 421 | if (is_string($onLeftSide)) { 422 | $where[] = new Where\Field( 423 | $onLeftSide, 424 | 'eq', 425 | [ 426 | $row[$this->tableAliases[$tableKey]][$clause->localKey], 427 | ], 428 | ); 429 | } else { 430 | $where[] = new Where\Expression( 431 | $onLeftSide, 432 | 'eq', 433 | [ 434 | $row[$this->tableAliases[$tableKey]][$clause->localKey], 435 | ], 436 | ); 437 | } 438 | } 439 | 440 | $this->client 441 | ->repository($join->entity() 442 | ->class()) 443 | ->fetch(new Where(...$where), new Limit(self::SINGLE)) 444 | ->toPromise() 445 | ->then($resolve, $reject); 446 | }); 447 | }); 448 | 449 | continue; 450 | } 451 | 452 | $tree[$join->property()] = Observable::defer( 453 | function () use ($row, $join, $tableKey): Observable { 454 | $where = []; 455 | 456 | foreach ($join->clause() as $clause) { 457 | $where[] = new Where\Field( 458 | $clause->foreignKey, 459 | 'eq', 460 | [ 461 | $row[$this->tableAliases[$tableKey]][$clause->localKey], 462 | ], 463 | ); 464 | } 465 | 466 | return $this->client->repository($join->entity()->class())->fetch(new Where(...$where)); 467 | }, 468 | new ImmediateScheduler(), 469 | ); 470 | } 471 | 472 | return $tree; 473 | } 474 | 475 | private function translateFieldName(string $name): string 476 | { 477 | $pos = strpos($name, '('); 478 | if ($pos === false) { 479 | return 't0.' . $name; 480 | } 481 | 482 | return substr($name, ZERO, $pos + ONE) . 't0.' . substr($name, $pos + ONE); 483 | } 484 | 485 | /** 486 | * @param array $fields 487 | * 488 | * @return array 489 | */ 490 | private function prepareFields(array $fields): array 491 | { 492 | foreach ($fields as $key => $value) { 493 | if ($value instanceof DateTimeInterface) { 494 | $fields[$key] = $value = date( 495 | self::DATE_TIME_TIMEZONE_FORMAT, 496 | (int) $value->format('U'), 497 | ); 498 | } 499 | 500 | if (is_scalar($value)) { 501 | continue; 502 | } 503 | 504 | unset($fields[$key]); 505 | } 506 | 507 | return $fields; 508 | } 509 | } 510 | -------------------------------------------------------------------------------- /src/RepositoryInterface.php: -------------------------------------------------------------------------------- 1 | 20 | * 21 | * @phpstan-ignore-next-line 22 | */ 23 | public function count(Where|null $where = null): PromiseInterface; 24 | 25 | /** 26 | * @return Observable 27 | * 28 | * @phpstan-ignore-next-line 29 | */ 30 | public function page(int $page, Where|null $where = null, Order|null $order = null, int $perPage = self::DEFAULT_PER_PAGE): Observable; 31 | 32 | /** @return Observable */ 33 | public function fetch(SectionInterface ...$sections): Observable; 34 | 35 | /** @return Observable */ 36 | public function stream(SectionInterface ...$sections): Observable; 37 | 38 | /** 39 | * @param array $fields 40 | * 41 | * @return PromiseInterface 42 | */ 43 | public function create(array $fields): PromiseInterface; 44 | 45 | /** @return PromiseInterface */ 46 | public function update(EntityInterface $entity): PromiseInterface; 47 | 48 | /** @return PromiseInterface */ 49 | public function delete(EntityInterface $entity): PromiseInterface; 50 | } 51 | -------------------------------------------------------------------------------- /src/Tools/WithFieldsTrait.php: -------------------------------------------------------------------------------- 1 | $fields */ 12 | public function withFields(array $fields): self 13 | { 14 | $clone = clone $this; 15 | 16 | foreach ($fields as $key => $value) { 17 | if (in_array($key, ['id', 'created', 'modified'], true)) { 18 | continue; 19 | } 20 | 21 | $clone->$key = $value; /** @phpstan-ignore-line */ 22 | } 23 | 24 | return $clone; 25 | } 26 | } 27 | --------------------------------------------------------------------------------