├── LICENSE ├── README.md ├── composer.json └── src ├── Result ├── ColumnType.php ├── ColumnType │ ├── FloatColumnType.php │ └── JsonColumnType.php ├── ColumnTypeMapper.php ├── ColumnTypeRegistry.php ├── ColumnTypeRegistry │ ├── ContainerColumnTypeRegistry.php │ └── InMemoryColumnTypeRegistry.php ├── ExtractColumnMapper.php ├── Hydrator.php ├── Hydrator │ └── SimpleHydrator.php └── Result.php ├── StatementContext ├── Parameters.php ├── Tsx.php ├── ValueRecursiveResolver.php ├── ValueResolver.php ├── ValueResolver │ ├── DateTimeResolver.php │ ├── Identifier.php │ ├── IdentifierResolver.php │ ├── Json.php │ ├── JsonResolver.php │ ├── Like.php │ ├── LikeEscaper.php │ └── LikeResolver.php ├── ValueResolverRegistry.php └── ValueResolverRegistry │ ├── ContainerValueResolverRegistry.php │ ├── InMemoryValueResolverRegistry.php │ └── ValueResolverRegistryChain.php ├── StatementExecutor ├── Exception │ ├── UniqueConstraintViolationException.php │ └── UnresolvedException.php ├── ExecutedStatement.php ├── ExecutionTimeMeasuringStatementExecutor.php ├── LoggingStatementExecutor.php ├── PdoStatementExecutor.php ├── PdoValue.php ├── StatementExecutionException.php └── StatementExecutor.php └── Transaction ├── SqlTransactionHandler.php ├── TransactionContext.php ├── TransactionHandler.php └── TransactionIsolationLevels.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Thesis 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Thesis 2 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "thesis/thesis", 3 | "type": "library", 4 | "description": "A fancy tool to use SQL in PHP with ease", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Valentin Udaltsov", 9 | "homepage": "https://github.com/vudaltsov" 10 | }, 11 | { 12 | "name": "Pavel Ivanov", 13 | "homepage": "https://github.com/Etherlord" 14 | } 15 | ], 16 | "require": { 17 | "php": "^8.0", 18 | "ext-pdo": "*", 19 | "psr/container": "^1.0 || ^2.0", 20 | "psr/log": "^1.0 || ^2.0 || ^3.0" 21 | }, 22 | "require-dev": { 23 | "friendsofphp/php-cs-fixer": "^3.1", 24 | "icanhazstring/composer-unused": "^0.7", 25 | "maglnet/composer-require-checker": "^3.3", 26 | "phpunit/phpunit": "^9.5", 27 | "psalm/plugin-phpunit": "^0.16", 28 | "vimeo/psalm": "^4.10" 29 | }, 30 | "config": { 31 | "sort-packages": true 32 | }, 33 | "autoload": { 34 | "psr-4": { 35 | "Thesis\\": "src/" 36 | } 37 | }, 38 | "autoload-dev": { 39 | "psr-4": { 40 | "Thesis\\": "tests/" 41 | } 42 | }, 43 | "scripts": { 44 | "fixcs": "php-cs-fixer fix -v", 45 | "psalm": "psalm --no-diff --show-info --threads=4", 46 | "check-require": "composer-require-checker check --config-file=composer-require-checker.json" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Result/ColumnType.php: -------------------------------------------------------------------------------- 1 | flags = $this->flags | JSON_THROW_ON_ERROR; 15 | } 16 | 17 | public function transform(mixed $value): mixed 18 | { 19 | if ($value === null) { 20 | return null; 21 | } 22 | 23 | if (!\is_string($value)) { 24 | throw new \UnexpectedValueException(sprintf( 25 | 'Expected a valid JSON string or null, got %s.', 26 | get_debug_type($value), 27 | )); 28 | } 29 | 30 | try { 31 | return json_decode($value, associative: true, flags: $this->flags); 32 | } catch (\JsonException $exception) { 33 | throw new \UnexpectedValueException( 34 | sprintf('Expected a valid JSON string, got %s.', $value), 35 | previous: $exception, 36 | ); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Result/ColumnTypeMapper.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | private ?array $resolvers = null; 17 | 18 | /** 19 | * @param array> $columnTypes 20 | */ 21 | public function __construct( 22 | private ColumnTypeRegistry $columnTypeRegistry, 23 | private array $columnTypes, 24 | ) { 25 | } 26 | 27 | public function __invoke(mixed $row): array 28 | { 29 | return array_map( 30 | static fn (\Closure $resolver): mixed => $resolver($row), 31 | $this->resolvers($row), 32 | ); 33 | } 34 | 35 | /** 36 | * @psalm-assert array $row 37 | * @return array<\Closure(array): mixed> 38 | */ 39 | private function resolvers(mixed $row): array 40 | { 41 | if ($this->resolvers !== null) { 42 | return $this->resolvers; 43 | } 44 | 45 | if (!\is_array($row)) { 46 | throw new \UnexpectedValueException(sprintf( 47 | 'Column types can be mapped only if row is an array, %s given.', 48 | get_debug_type($row), 49 | )); 50 | } 51 | 52 | $this->resolvers = []; 53 | 54 | foreach (array_keys($row) as $column) { 55 | if (!isset($this->columnTypes[$column])) { 56 | $this->resolvers[$column] = static fn (array $row): mixed => $row[$column]; 57 | 58 | continue; 59 | } 60 | 61 | $type = $this 62 | ->columnTypeRegistry 63 | ->get($this->columnTypes[$column]) 64 | ?? throw new \LogicException(sprintf( 65 | 'Column type "%s" does not exist.', 66 | $this->columnTypes[$column], 67 | )) 68 | ; 69 | 70 | $this->resolvers[$column] = static fn (array $row): mixed => $type->transform($row[$column]); 71 | } 72 | 73 | return $this->resolvers; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Result/ColumnTypeRegistry.php: -------------------------------------------------------------------------------- 1 | $columnType 11 | */ 12 | public function get(string $columnType): ?ColumnType; 13 | } 14 | -------------------------------------------------------------------------------- /src/Result/ColumnTypeRegistry/ContainerColumnTypeRegistry.php: -------------------------------------------------------------------------------- 1 | , ColumnType> $columnTypes 16 | */ 17 | public function __construct( 18 | private ContainerInterface $columnTypes, 19 | ) { 20 | } 21 | 22 | public function get(string $columnType): ?ColumnType 23 | { 24 | try { 25 | return $this->columnTypes->get($columnType); 26 | } catch (NotFoundExceptionInterface) { 27 | return null; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Result/ColumnTypeRegistry/InMemoryColumnTypeRegistry.php: -------------------------------------------------------------------------------- 1 | , ColumnType> 14 | */ 15 | private array $columnTypes = []; 16 | 17 | /** 18 | * @param iterable $columnTypes 19 | */ 20 | public function __construct( 21 | iterable $columnTypes = [], 22 | ) { 23 | foreach ($columnTypes as $columnType) { 24 | $this->columnTypes[$columnType::class] = $columnType; 25 | } 26 | } 27 | 28 | public function get(string $columnType): ?ColumnType 29 | { 30 | return $this->columnTypes[$columnType] ?? null; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Result/ExtractColumnMapper.php: -------------------------------------------------------------------------------- 1 | check($row); 24 | 25 | return $row[$this->column]; 26 | } 27 | 28 | /** 29 | * @psalm-assert array $row 30 | */ 31 | private function check(mixed $row): void 32 | { 33 | if ($this->checked) { 34 | return; 35 | } 36 | 37 | if (!\is_array($row)) { 38 | throw new \UnexpectedValueException(sprintf( 39 | '%s(\'%s\') expects row value to be of type array{%2$s: mixed}, got %s.', 40 | $this->method, 41 | $this->column, 42 | get_debug_type($row), 43 | )); 44 | } 45 | 46 | if (!\array_key_exists($this->column, $row)) { 47 | throw new \UnexpectedValueException(sprintf( 48 | '%s(\'%s\') expects row array to have offset \'%2$s\', got %s.', 49 | $this->method, 50 | $this->column, 51 | $row ? "array with offsets '".implode("', '", array_keys($row))."'" : 'empty array', 52 | )); 53 | } 54 | 55 | $this->checked = true; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Result/Hydrator.php: -------------------------------------------------------------------------------- 1 | $class 12 | * @return T 13 | */ 14 | public function hydrate(mixed $data, string $class): object; 15 | } 16 | -------------------------------------------------------------------------------- /src/Result/Hydrator/SimpleHydrator.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | final class Result implements \IteratorAggregate 16 | { 17 | /** 18 | * @param \Iterator $rows 19 | */ 20 | private function __construct( 21 | private \Iterator $rows, 22 | public int $affectedRowsNumber, 23 | private Hydrator $hydrator, 24 | private ColumnTypeRegistry $columnTypeRegistry, 25 | ) { 26 | } 27 | 28 | /** 29 | * @template TNewKey 30 | * @template TNewRow 31 | * @param \Traversable $rows 32 | * @return self 33 | */ 34 | public static function create( 35 | \Traversable $rows, 36 | int $affectedRowsNumber, 37 | ?Hydrator $hydrator = null, 38 | ?ColumnTypeRegistry $columnTypeRegistry = null, 39 | ): self { 40 | $rows = new \IteratorIterator($rows); 41 | $rows->rewind(); 42 | 43 | return new self( 44 | new \NoRewindIterator($rows), 45 | $affectedRowsNumber, 46 | $hydrator ?? new SimpleHydrator(), 47 | $columnTypeRegistry ?? new InMemoryColumnTypeRegistry(), 48 | ); 49 | } 50 | 51 | /** 52 | * @template TNewKey 53 | * @param callable(TRow): TNewKey $mapper 54 | * @return self 55 | */ 56 | public function mapKey(callable $mapper): self 57 | { 58 | return new self( 59 | (function () use ($mapper): \Generator { 60 | foreach ($this->rows as $row) { 61 | yield $mapper($row) => $row; 62 | } 63 | })(), 64 | $this->affectedRowsNumber, 65 | $this->hydrator, 66 | $this->columnTypeRegistry, 67 | ); 68 | } 69 | 70 | /** 71 | * @template TNewRow 72 | * @param callable(TRow): TNewRow $mapper 73 | * @return self 74 | */ 75 | public function mapRow(callable $mapper): self 76 | { 77 | return new self( 78 | (function () use ($mapper): \Generator { 79 | foreach ($this->rows as $key => $row) { 80 | yield $key => $mapper($row); 81 | } 82 | })(), 83 | $this->affectedRowsNumber, 84 | $this->hydrator, 85 | $this->columnTypeRegistry, 86 | ); 87 | } 88 | 89 | /** 90 | * @param class-string ...$columnTypes 91 | */ 92 | public function columnTypes(string ...$columnTypes): self 93 | { 94 | return $this->mapRow(new ColumnTypeMapper($this->columnTypeRegistry, $columnTypes)); 95 | } 96 | 97 | /** 98 | * @throws \UnexpectedValueException If row value is not of type array{$column: mixed} 99 | * @return self 100 | */ 101 | public function keyColumn(int|string $column): self 102 | { 103 | return $this->mapKey(new ExtractColumnMapper(__METHOD__, $column)); 104 | } 105 | 106 | /** 107 | * @throws \UnexpectedValueException If row value is not of type array{$column: mixed} 108 | * @return self 109 | */ 110 | public function rowColumn(int|string $column): self 111 | { 112 | return $this->mapRow(new ExtractColumnMapper(__METHOD__, $column)); 113 | } 114 | 115 | /** 116 | * @template TNewRow of object 117 | * @param class-string $class 118 | * @return self 119 | */ 120 | public function hydrate(string $class): self 121 | { 122 | return $this->mapRow(fn ($row): object => $this->hydrator->hydrate($row, $class)); 123 | } 124 | 125 | /** 126 | * @throws \TypeError If result key is not of type ?scalar 127 | * @return (TKey is int|string ? array : array) 128 | */ 129 | public function toArray(): array 130 | { 131 | return iterator_to_array($this->rows); 132 | } 133 | 134 | /** 135 | * @return list 136 | */ 137 | public function toList(): array 138 | { 139 | return iterator_to_array($this->rows, false); 140 | } 141 | 142 | /** 143 | * @template TDefault 144 | * @template TCallable of ?callable(): TDefault 145 | * @param TCallable $default 146 | * @return (TCallable is null ? TRow|null : TRow|TDefault) 147 | */ 148 | public function fetch(?callable $default = null): mixed 149 | { 150 | if ($this->rows->valid()) { 151 | $row = $this->rows->current(); 152 | $this->rows->next(); 153 | 154 | return $row; 155 | } 156 | 157 | if ($default === null) { 158 | return null; 159 | } 160 | 161 | return $default(); 162 | } 163 | 164 | /** 165 | * @return \Iterator 166 | */ 167 | public function getIterator(): \Iterator 168 | { 169 | return $this->rows; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/StatementContext/Parameters.php: -------------------------------------------------------------------------------- 1 | 15 | * @psalm-readonly-allow-private-mutation 16 | */ 17 | public array $parameters = []; 18 | private int $unnamedParametersCount = 0; 19 | 20 | public function add(?string $name, mixed $value): string 21 | { 22 | $name = $this->resolveName($name); 23 | $this->parameters[$name] = $value; 24 | 25 | return $name; 26 | } 27 | 28 | private function resolveName(?string $name): string 29 | { 30 | if ($name === null) { 31 | return 'p'.($this->unnamedParametersCount++); 32 | } 33 | 34 | if (!preg_match('/^\w+$/', $name)) { 35 | throw new \InvalidArgumentException(sprintf('Invalid parameter name %s.', $name)); 36 | } 37 | 38 | return $name; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/StatementContext/Tsx.php: -------------------------------------------------------------------------------- 1 | |callable(self): string|\Generator 13 | */ 14 | final class Tsx 15 | { 16 | private function __construct( 17 | private ValueResolverRegistry $valueResolverRegistry, 18 | private Parameters $parameters, 19 | ) { 20 | } 21 | 22 | /** 23 | * @param Statement $statement 24 | * @return array{string, array} 25 | */ 26 | public static function resolve(string|\Generator|callable $statement, ?ValueResolverRegistry $valueResolverRegistry = null): array 27 | { 28 | $parameters = new Parameters(); 29 | $context = new self($valueResolverRegistry ?? new InMemoryValueResolverRegistry(), $parameters); 30 | 31 | return [$context->embed($statement), $parameters->parameters]; 32 | } 33 | 34 | public function __invoke(mixed ...$values): string 35 | { 36 | $resolved = []; 37 | 38 | foreach ($values as $name => $value) { 39 | $resolver = new ValueRecursiveResolver( 40 | $this->valueResolverRegistry, 41 | $this->parameters, 42 | \is_string($name) ? $name : null, 43 | ); 44 | 45 | $resolved[] = $resolver->resolve($value); 46 | } 47 | 48 | return implode(', ', $resolved); 49 | } 50 | 51 | /** 52 | * @param Statement $statement 53 | */ 54 | public function embed(string|\Generator|callable $statement): string 55 | { 56 | if (\is_callable($statement)) { 57 | $statement = $statement($this); 58 | } 59 | 60 | if (\is_string($statement)) { 61 | return $statement; 62 | } 63 | 64 | return implode(' ', iterator_to_array($statement, false)); 65 | } 66 | 67 | /** 68 | * Formats an iterable of values as insert value sets. 69 | * 70 | * @template TKey 71 | * @template TValue 72 | * @param iterable $values 73 | * @param callable(TValue, TKey, self): string $formatter 74 | */ 75 | public function sets(iterable $values, callable $formatter): string 76 | { 77 | $string = ''; 78 | $first = true; 79 | 80 | foreach ($values as $key => $value) { 81 | if ($first) { 82 | $first = false; 83 | } else { 84 | $string .= ', '; 85 | } 86 | 87 | $string .= '('.$formatter($value, $key, $this).')'; 88 | } 89 | 90 | return $string; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/StatementContext/ValueRecursiveResolver.php: -------------------------------------------------------------------------------- 1 | > 11 | */ 12 | private array $resolvedTypes = []; 13 | 14 | public function __construct( 15 | private ValueResolverRegistry $valueResolverRegistry, 16 | private Parameters $parameters, 17 | private ?string $parameterName, 18 | ) { 19 | } 20 | 21 | /** 22 | * @return \Generator 23 | */ 24 | private static function valueTypes(mixed $value): \Generator 25 | { 26 | yield get_debug_type($value); 27 | 28 | if (\is_object($value)) { 29 | yield from class_parents($value); 30 | yield from class_implements($value); 31 | } 32 | } 33 | 34 | public function resolve(mixed $value): string 35 | { 36 | foreach (self::valueTypes($value) as $type) { 37 | $valueResolver = $this->valueResolverRegistry->get($type); 38 | 39 | if ($valueResolver === null) { 40 | continue; 41 | } 42 | 43 | if (isset($this->resolvedTypes[$type])) { 44 | throw new \LogicException(sprintf( 45 | 'Type resolution cycle %s > %s (%s) detected.', 46 | implode(' > ', array_map( 47 | static fn (string $type, string $valueResolver): string => "{$type} ({$valueResolver})", 48 | array_keys($this->resolvedTypes), 49 | $this->resolvedTypes, 50 | )), 51 | $type, 52 | $valueResolver::class, 53 | )); 54 | } 55 | 56 | $this->resolvedTypes[$type] = $valueResolver::class; 57 | 58 | return $valueResolver->resolve($value, $this); 59 | } 60 | 61 | return ':'.$this->parameters->add($this->parameterName, $value); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/StatementContext/ValueResolver.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | public static function valueTypes(): array; 23 | 24 | /** 25 | * @param T $value 26 | */ 27 | public function resolve(mixed $value, ValueRecursiveResolver $resolver): string; 28 | } 29 | -------------------------------------------------------------------------------- /src/StatementContext/ValueResolver/DateTimeResolver.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final class DateTimeResolver implements ValueResolver 14 | { 15 | public function __construct( 16 | private string $format = 'Y-m-d H:i:s', 17 | ) { 18 | } 19 | 20 | public static function valueTypes(): array 21 | { 22 | return [\DateTimeInterface::class]; 23 | } 24 | 25 | /** 26 | * @param \DateTimeInterface $value 27 | */ 28 | public function resolve(mixed $value, ValueRecursiveResolver $resolver): string 29 | { 30 | return $resolver->resolve($value->format($this->format)); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/StatementContext/ValueResolver/Identifier.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final class IdentifierResolver implements ValueResolver 14 | { 15 | private const DEFAULT_QUOTE_CHARACTER = '"'; 16 | private const DEFAULT_DELIMITER = '.'; 17 | 18 | public function __construct( 19 | private string $quoteCharacter = self::DEFAULT_QUOTE_CHARACTER, 20 | private string $delimiter = self::DEFAULT_DELIMITER, 21 | ) { 22 | } 23 | 24 | public static function valueTypes(): array 25 | { 26 | return [Identifier::class]; 27 | } 28 | 29 | /** 30 | * @param Identifier $value 31 | */ 32 | public function resolve(mixed $value, ValueRecursiveResolver $resolver): string 33 | { 34 | return implode( 35 | $this->delimiter, 36 | array_map( 37 | [$this, 'quote'], 38 | explode($this->delimiter, $value->identifier), 39 | ), 40 | ); 41 | } 42 | 43 | private function quote(string $identifier): string 44 | { 45 | $char = $this->quoteCharacter; 46 | 47 | return $char.str_replace($char, $char.$char, $identifier).$char; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/StatementContext/ValueResolver/Json.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final class JsonResolver implements ValueResolver 14 | { 15 | public static function valueTypes(): array 16 | { 17 | return [Json::class]; 18 | } 19 | 20 | /** 21 | * @param Json $value 22 | * @throws \JsonException 23 | */ 24 | public function resolve(mixed $value, ValueRecursiveResolver $resolver): string 25 | { 26 | if ($value->forceObject === Json::ROOT && $value->value === []) { 27 | return $resolver->resolve('{}'); 28 | } 29 | 30 | $options = JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR; 31 | 32 | if ($value->forceObject === Json::ALWAYS) { 33 | $options = $options | JSON_FORCE_OBJECT; 34 | } 35 | 36 | return $resolver->resolve(json_encode($value->value, $options)); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/StatementContext/ValueResolver/Like.php: -------------------------------------------------------------------------------- 1 | "%{$like->escape($value)}%"); 26 | } 27 | 28 | /** 29 | * @psalm-pure 30 | */ 31 | public static function startsWith(string $value): self 32 | { 33 | return new self(static fn (LikeEscaper $like): string => "{$like->escape($value)}%"); 34 | } 35 | 36 | /** 37 | * @psalm-pure 38 | */ 39 | public static function endsWith(string $value): self 40 | { 41 | return new self(static fn (LikeEscaper $like): string => "%{$like->escape($value)}"); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/StatementContext/ValueResolver/LikeEscaper.php: -------------------------------------------------------------------------------- 1 | escapePattern = '~([%_'.preg_quote($escapeCharacter, '~').'])~'; 19 | $this->escapeReplacement = $escapeCharacter.'$0'; 20 | } 21 | 22 | public function escape(string $value): string 23 | { 24 | return preg_replace($this->escapePattern, $this->escapeReplacement, $value); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/StatementContext/ValueResolver/LikeResolver.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final class LikeResolver implements ValueResolver 14 | { 15 | private const DEFAULT_ESCAPE_CHARACTER = '/'; 16 | 17 | private LikeEscaper $escaper; 18 | private string $escapeExpression; 19 | 20 | public function __construct( 21 | string $escapeCharacter = self::DEFAULT_ESCAPE_CHARACTER, 22 | ) { 23 | $this->escaper = new LikeEscaper($escapeCharacter); 24 | $this->escapeExpression = " escape '{$escapeCharacter}'"; 25 | } 26 | 27 | public static function valueTypes(): array 28 | { 29 | return [Like::class]; 30 | } 31 | 32 | /** 33 | * @param Like $value 34 | */ 35 | public function resolve(mixed $value, ValueRecursiveResolver $resolver): string 36 | { 37 | return $resolver->resolve(($value->query)($this->escaper)).$this->escapeExpression; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/StatementContext/ValueResolverRegistry.php: -------------------------------------------------------------------------------- 1 | $valueResolvers 16 | */ 17 | public function __construct( 18 | private ContainerInterface $valueResolvers, 19 | ) { 20 | } 21 | 22 | public function get(string $valueType): ?ValueResolver 23 | { 24 | try { 25 | return $this->valueResolvers->get($valueType); 26 | } catch (NotFoundExceptionInterface) { 27 | return null; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/StatementContext/ValueResolverRegistry/InMemoryValueResolverRegistry.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | private array $valueResolvers = []; 16 | 17 | /** 18 | * @param iterable $valueResolvers 19 | */ 20 | public function __construct( 21 | iterable $valueResolvers = [], 22 | ) { 23 | foreach ($valueResolvers as $valueResolver) { 24 | foreach ($valueResolver::valueTypes() as $valueType) { 25 | $this->valueResolvers[$valueType] = $valueResolver; 26 | } 27 | } 28 | } 29 | 30 | public function get(string $valueType): ?ValueResolver 31 | { 32 | return $this->valueResolvers[$valueType] ?? null; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/StatementContext/ValueResolverRegistry/ValueResolverRegistryChain.php: -------------------------------------------------------------------------------- 1 | $valueResolverRegistries 14 | */ 15 | public function __construct( 16 | private iterable $valueResolverRegistries, 17 | ) { 18 | } 19 | 20 | public function get(string $valueType): ?ValueResolver 21 | { 22 | foreach ($this->valueResolverRegistries as $valueResolverRegistry) { 23 | $valueResolver = $valueResolverRegistry->get($valueType); 24 | 25 | if ($valueResolver !== null) { 26 | return $valueResolver; 27 | } 28 | } 29 | 30 | return null; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/StatementExecutor/Exception/UniqueConstraintViolationException.php: -------------------------------------------------------------------------------- 1 | 11 | * @psalm-readonly 12 | */ 13 | public \Traversable $rows; 14 | 15 | /** 16 | * @psalm-readonly 17 | */ 18 | public int $affectedRowsNumber; 19 | 20 | /** 21 | * @psalm-readonly 22 | */ 23 | public array $debugData = []; 24 | 25 | /** 26 | * @param \Traversable $rows 27 | */ 28 | public function __construct( 29 | \Traversable $rows, 30 | int $affectedRowsNumber, 31 | array $debugData = [], 32 | ) { 33 | $this->rows = $rows; 34 | $this->affectedRowsNumber = $affectedRowsNumber; 35 | $this->debugData = $debugData; 36 | } 37 | 38 | public function withDebugData(array $debugData): self 39 | { 40 | $statement = clone $this; 41 | $statement->debugData = array_merge($statement->debugData, $debugData); 42 | 43 | return $statement; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/StatementExecutor/ExecutionTimeMeasuringStatementExecutor.php: -------------------------------------------------------------------------------- 1 | microtime = $microtime ?? static fn (): float => microtime(true); 22 | } 23 | 24 | public function execute(string $statement, array $parameters = []): ExecutedStatement 25 | { 26 | $executionStartTime = ($this->microtime)(); 27 | $executedStatement = $this->statementExecutor->execute($statement, $parameters); 28 | $executionTime = (int) round((($this->microtime)() - $executionStartTime) * 1000); 29 | 30 | return $executedStatement->withDebugData(['execution_time_ms' => $executionTime]); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/StatementExecutor/LoggingStatementExecutor.php: -------------------------------------------------------------------------------- 1 | log($statement, $parameters); 25 | 26 | $executedStatement = $this->statementExecutor->execute($statement, $parameters); 27 | 28 | $this->log('Executed.', array_merge( 29 | $executedStatement->debugData, 30 | ['affected_rows_number' => $executedStatement->affectedRowsNumber], 31 | )); 32 | 33 | return $executedStatement; 34 | } 35 | 36 | private function log(string $message, array $context): void 37 | { 38 | $this->logger->log($this->level, $message, $context); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/StatementExecutor/PdoStatementExecutor.php: -------------------------------------------------------------------------------- 1 | $pdoStatement */ 19 | $pdoStatement = $this->pdo->prepare($statement); 20 | 21 | foreach ($parameters as $name => $value) { 22 | $pdoValue = PdoValue::fromMixed($value); 23 | $pdoStatement->bindValue($name, $pdoValue->value, $pdoValue->type); 24 | } 25 | 26 | try { 27 | $pdoStatement->execute(); 28 | } catch (\PDOException $exception) { 29 | throw new UnresolvedException($exception->getMessage(), (string) $exception->getCode(), $exception); 30 | } 31 | 32 | $pdoStatement->setFetchMode(\PDO::FETCH_ASSOC); 33 | 34 | ob_start(); 35 | $pdoStatement->debugDumpParams(); 36 | $debugInfo = ['pdo' => ob_get_clean() ?: '']; 37 | 38 | return new ExecutedStatement($pdoStatement, $pdoStatement->rowCount(), $debugInfo); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/StatementExecutor/PdoValue.php: -------------------------------------------------------------------------------- 1 | new self(null, \PDO::PARAM_NULL), 28 | 'bool' => new self($value, \PDO::PARAM_BOOL), 29 | 'int' => new self($value, \PDO::PARAM_INT), 30 | 'float', 'string' => new self((string) $value, \PDO::PARAM_STR), 31 | self::class => $value, 32 | default => throw new \InvalidArgumentException(sprintf( 33 | 'PdoStatementExecutor expects value of type null|scalar|PdoValue, %s given.', 34 | get_debug_type($value), 35 | )), 36 | }; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/StatementExecutor/StatementExecutionException.php: -------------------------------------------------------------------------------- 1 | statementExecutor)('start transaction'); 20 | } 21 | 22 | public function setIsolationLevel(string $isolationLevel): void 23 | { 24 | ($this->statementExecutor)('set transaction isolation level '.$isolationLevel); 25 | } 26 | 27 | public function commit(): void 28 | { 29 | ($this->statementExecutor)('commit'); 30 | } 31 | 32 | public function rollback(): void 33 | { 34 | ($this->statementExecutor)('rollback'); 35 | } 36 | 37 | public function savepoint(string $savepoint): void 38 | { 39 | ($this->statementExecutor)('savepoint '.$savepoint); 40 | } 41 | 42 | public function releaseSavepoint(string $savepoint): void 43 | { 44 | ($this->statementExecutor)('release savepoint '.$savepoint); 45 | } 46 | 47 | public function rollbackToSavepoint(string $savepoint): void 48 | { 49 | ($this->statementExecutor)('rollback to savepoint '.$savepoint); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Transaction/TransactionContext.php: -------------------------------------------------------------------------------- 1 | level; 29 | 30 | $this->begin($isolationLevel); 31 | 32 | try { 33 | $result = $operation(); 34 | 35 | if (!$result instanceof \Generator) { 36 | $this->commit(); 37 | 38 | return $result; 39 | } 40 | } catch (\Throwable $exception) { 41 | $this->rollback(); 42 | 43 | throw $exception; 44 | } finally { 45 | --$this->level; 46 | } 47 | 48 | ++$this->level; 49 | 50 | return $this->transactionalizedGenerator($result); 51 | } 52 | 53 | /** 54 | * @psalm-suppress InvalidReturnType, MixedReturnStatement https://github.com/vimeo/psalm/issues/5549 55 | * @template T of \Generator 56 | * @param T $generator 57 | * @throws \Throwable 58 | * @return T 59 | */ 60 | private function transactionalizedGenerator(\Generator $generator): \Generator 61 | { 62 | try { 63 | $result = yield from $generator; 64 | $this->commit(); 65 | 66 | return $result; 67 | } catch (\Throwable $exception) { 68 | $this->rollback(); 69 | 70 | throw $exception; 71 | } finally { 72 | --$this->level; 73 | } 74 | } 75 | 76 | /** 77 | * @param ?TransactionIsolationLevels::* $isolationLevel 78 | */ 79 | private function begin(?string $isolationLevel): void 80 | { 81 | if ($this->level === self::MAIN_TRANSACTION_LEVEL) { 82 | $this->transactionHandler->begin(); 83 | 84 | if ($isolationLevel !== null) { 85 | $this->transactionHandler->setIsolationLevel($isolationLevel); 86 | } 87 | 88 | return; 89 | } 90 | 91 | $this->transactionHandler->savepoint($this->levelSavepoint()); 92 | } 93 | 94 | private function commit(): void 95 | { 96 | if ($this->level === self::MAIN_TRANSACTION_LEVEL) { 97 | $this->transactionHandler->commit(); 98 | 99 | return; 100 | } 101 | 102 | $this->transactionHandler->releaseSavepoint($this->levelSavepoint()); 103 | } 104 | 105 | private function rollback(): void 106 | { 107 | if ($this->level === self::MAIN_TRANSACTION_LEVEL) { 108 | $this->transactionHandler->rollback(); 109 | 110 | return; 111 | } 112 | 113 | $this->transactionHandler->rollbackToSavepoint($this->levelSavepoint()); 114 | } 115 | 116 | private function levelSavepoint(): string 117 | { 118 | return 'thesis_savepoint_'.$this->level; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/Transaction/TransactionHandler.php: -------------------------------------------------------------------------------- 1 |