diff --git a/README.md b/README.md index 3dc7e97..0a0e930 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,32 @@ DB::setSystemVariables(['long_query_time' => 10.0, 'tx_isolation' => 'read-commi // Assign a variable on a different connection DB::connection('other_mysql_connection')->setSystemVariable('long_query_time', 10.0); + +// Run callback temporarily assigning a variable +DB::usingSystemVariable('long_query_time', 10.0, function () { + /* ... */ +}); + +// Run callback temporarily assigning multiple variables +DB::usingSystemVariables(['long_query_time' => 10.0, 'tx_isolation' => 'read-committed'], function () { + /* ... */ +}); + +// Run callback replacing current value +// NOTE: You MUST declare closure return types. +DB::usingSystemVariables( + [ + 'long_query_time' => function (float $currentValue): float { + return $currentValue + 5.0; + }, + 'sql_mode' => function (string $currentValue): string { + return str_replace('ONLY_FULL_GROUP_BY', '', $currentValue); + }, + ], + function () { + /* ... */ + } +); ``` **WARNING:** @@ -97,12 +123,14 @@ class MySqlConnection extends BaseMySqlConnection public function withoutForeignKeyChecks(callable $callback, ...$args) { - $this->setSystemVariable('foreign_key_checks', false); - try { - return $callback(...$args); - } finally { - $this->setSystemVariable('foreign_key_checks', true); - } + return $this->usingSystemVariable('foreign_key_checks', false, $callback, ...$args); + } + + public function allowingPartialGroupBy(callable $callback, ...$args) + { + return $this->usingSystemVariable('sql_mode', function (string $mode): string { + return str_replace('ONLY_FULL_GROUP_BY', '', $mode); + }, $callback, ...$args); } } ``` diff --git a/src/ExpressionInterface.php b/src/ExpressionInterface.php new file mode 100644 index 0000000..0ea751a --- /dev/null +++ b/src/ExpressionInterface.php @@ -0,0 +1,40 @@ + self::TYPE_INTEGER, + 'bool' => self::TYPE_BOOLEAN, + 'float' => self::TYPE_FLOAT, + 'double' => self::TYPE_FLOAT, + 'string' => self::TYPE_STRING, + ]; + + /** + * Return type. + * + * @return string + */ + public function getType(): string; + + /** + * Return PDO::PARAM_* type.s + * + * @return int + */ + public function getParamType(): int; + + /** + * Return placeholder for prepared statement. + * + * @return string + */ + public function getPlaceholder(): string; +} diff --git a/src/ExpressionTrait.php b/src/ExpressionTrait.php new file mode 100644 index 0000000..df180cf --- /dev/null +++ b/src/ExpressionTrait.php @@ -0,0 +1,55 @@ +getType()) { + case ExpressionInterface::TYPE_INTEGER: + return PDO::PARAM_INT; + case ExpressionInterface::TYPE_BOOLEAN: + return PDO::PARAM_BOOL; + case ExpressionInterface::TYPE_FLOAT: + case ExpressionInterface::TYPE_STRING: + default: + return PDO::PARAM_STR; + } + } + + /** + * Return a placeholder format. + * + * @return string + */ + public function getPlaceholder(): string + { + switch ($this->getType()) { + case ExpressionInterface::TYPE_FLOAT: + return 'cast(? as decimal(65, 30))'; + case ExpressionInterface::TYPE_INTEGER: + case ExpressionInterface::TYPE_BOOLEAN: + case ExpressionInterface::TYPE_STRING: + default: + return '?'; + } + } +} diff --git a/src/ManagesSystemVariables.php b/src/ManagesSystemVariables.php index b84cce7..a49a412 100644 --- a/src/ManagesSystemVariables.php +++ b/src/ManagesSystemVariables.php @@ -52,4 +52,36 @@ public function setSystemVariables(array $values, bool $memoizeForReconnect = tr return $this; } + + /** + * Run callback temporarily setting MySQL system variable for both read and write PDOs. + * It is lazily executed for unresolved PDO instance. + * + * @param string $key + * @param mixed $value + * @param callable $callback + * @param mixed ...$args + * @return $this + */ + public function usingSystemVariable(string $key, $value, callable $callback, ...$args) + { + return $this->usingSystemVariables([$key => $value], $callback, ...$args); + } + + /** + * Run callback temporarily setting MySQL system variables for both read and write PDOs. + * It is lazily executed for unresolved PDO instance. + * + * @param array $values + * @param callable $callback + * @param mixed ...$args + * @return $this + */ + public function usingSystemVariables(array $values, callable $callback, ...$args) + { + (new SystemVariableTemporaryAssigner($this->readPdo, $this->pdo)) + ->using($values, $callback, ...$args); + + return $this; + } } diff --git a/src/Replacer.php b/src/Replacer.php new file mode 100644 index 0000000..f0d43fd --- /dev/null +++ b/src/Replacer.php @@ -0,0 +1,83 @@ +callback = $callback; + } + + /** + * Replace boolean variable value. + * + * @param bool $value + * @return bool + */ + public function replace(bool $value): bool + { + return ($this->callback)($value); + } + + /** + * Return type. + * + * @return string + */ + public function getType(): string + { + return ExpressionInterface::TYPE_BOOLEAN; + } +} diff --git a/src/Replacers/CallbackFloatReplacer.php b/src/Replacers/CallbackFloatReplacer.php new file mode 100644 index 0000000..2a1ad9b --- /dev/null +++ b/src/Replacers/CallbackFloatReplacer.php @@ -0,0 +1,47 @@ +callback = $callback; + } + + /** + * Replace float variable value. + * + * @param float $value + * @return float + */ + public function replace(float $value): float + { + return ($this->callback)($value); + } + + /** + * Return type. + * + * @return string + */ + public function getType(): string + { + return ExpressionInterface::TYPE_FLOAT; + } +} diff --git a/src/Replacers/CallbackIntegerReplacer.php b/src/Replacers/CallbackIntegerReplacer.php new file mode 100644 index 0000000..c1fd562 --- /dev/null +++ b/src/Replacers/CallbackIntegerReplacer.php @@ -0,0 +1,47 @@ +callback = $callback; + } + + /** + * Replace integer variable value. + * + * @param int $value + * @return int + */ + public function replace(int $value): int + { + return ($this->callback)($value); + } + + /** + * Return type. + * + * @return string + */ + public function getType(): string + { + return ExpressionInterface::TYPE_INTEGER; + } +} diff --git a/src/Replacers/CallbackStringReplacer.php b/src/Replacers/CallbackStringReplacer.php new file mode 100644 index 0000000..0beb8ed --- /dev/null +++ b/src/Replacers/CallbackStringReplacer.php @@ -0,0 +1,47 @@ +callback = $callback; + } + + /** + * Replace string variable value. + * + * @param string $value + * @return string + */ + public function replace(string $value): string + { + return ($this->callback)($value); + } + + /** + * Return type. + * + * @return string + */ + public function getType(): string + { + return ExpressionInterface::TYPE_STRING; + } +} diff --git a/src/Replacers/FloatReplacerInterface.php b/src/Replacers/FloatReplacerInterface.php new file mode 100644 index 0000000..0cc07e5 --- /dev/null +++ b/src/Replacers/FloatReplacerInterface.php @@ -0,0 +1,16 @@ +prepare($query); - foreach (array_values($values) as $i => $value) { - $value = BindingValue::wrap($value); - $statement->bindValue($i + 1, $value->getValue(), $value->getParamType()); + + $i = 0; + foreach ($expressions as $key => $expression) { + static::bindValue($statement, ++$i, $expression, $original[$key] ?? null); } $statement->execute(); return $pdo; } + + /** + * @param \PDO $pdo + * @param \Mpyw\LaravelMySqlSystemVariableManager\ExpressionInterface[] $expressions + * @return \Mpyw\LaravelMySqlSystemVariableManager\ValueInterface[] + */ + protected static function selectOriginalVariablesForReplacer(PDO $pdo, array $expressions): array + { + $replacers = array_filter($expressions, function ($value) { + return $value instanceof IntegerReplacerInterface + || $value instanceof BooleanReplacerInterface + || $value instanceof FloatReplacerInterface + || $value instanceof StringReplacerInterface; + }); + + return SystemVariableSelector::selectOriginalVariables($pdo, $replacers); + } + + /** + * @param \PDOStatement $statement + * @param int $parameter + * @param \Mpyw\LaravelMySqlSystemVariableManager\ExpressionInterface $expression + * @param null|\Mpyw\LaravelMySqlSystemVariableManager\ValueInterface $original + */ + protected static function bindValue(PDOStatement $statement, int $parameter, ExpressionInterface $expression, ?ValueInterface $original = null): void + { + if ($expression instanceof ValueInterface) { + $statement->bindValue($parameter, $expression->getValue(), $expression->getParamType()); + return; + } + + if (($expression instanceof IntegerReplacerInterface + || $expression instanceof BooleanReplacerInterface + || $expression instanceof FloatReplacerInterface + || $expression instanceof StringReplacerInterface + ) && $original) { + $statement->bindValue($parameter, $expression->replace($original->getValue()), $expression->getParamType()); + return; + } + + // @codeCoverageIgnoreStart + throw new LogicException('Unreachable code.'); + // @codeCoverageIgnoreEnd + } } diff --git a/src/SystemVariableGrammar.php b/src/SystemVariableGrammar.php index 16f5b5e..034b0e2 100644 --- a/src/SystemVariableGrammar.php +++ b/src/SystemVariableGrammar.php @@ -4,6 +4,28 @@ class SystemVariableGrammar { + /** + * @param string[] $variables + * @return string + */ + public static function selectStatement(array $variables): string + { + return 'select ' . implode(', ', static::variableExpressions($variables)); + } + + /** + * @param string[] $variables + * @return string[] + */ + public static function variableExpressions(array $variables): array + { + $expressions = []; + foreach ($variables as $variable) { + $expressions[] = sprintf('@@%1$s as %1$s', static::escapeIdentifier($variable)); + } + return $expressions; + } + /** * @param array $values * @return string diff --git a/src/SystemVariableSelector.php b/src/SystemVariableSelector.php new file mode 100644 index 0000000..5850cb7 --- /dev/null +++ b/src/SystemVariableSelector.php @@ -0,0 +1,33 @@ +query(SystemVariableGrammar::selectStatement(array_keys($newValues))) + ->fetch(PDO::FETCH_ASSOC); + + foreach ($original as $key => $value) { + $original[$key] = BindingValue::as(BindingValue::wrap($newValues[$key])->getType(), $value); + } + + return $original; + } +} diff --git a/src/SystemVariableTemporaryAssigner.php b/src/SystemVariableTemporaryAssigner.php new file mode 100644 index 0000000..015a417 --- /dev/null +++ b/src/SystemVariableTemporaryAssigner.php @@ -0,0 +1,44 @@ +pdos = array_filter($pdos); + } + + /** + * Temporarily set MySQL system variables for PDO. + * + * @param array $using + * @param callable $callback + * @param array $args + * @return $this + */ + public function using(array $using, callable $callback, ...$args) + { + return Value::withEffectForEach($this->pdos, function (PDO $pdo) use ($using) { + $original = SystemVariableSelector::selectOriginalVariables($pdo, $using); + (new SystemVariableAssigner($pdo))->assign($using); + + return function (PDO $pdo) use ($original) { + (new SystemVariableAssigner($pdo))->assign($original); + }; + }, $callback, ...$args); + } +} diff --git a/src/Value.php b/src/Value.php index b6feca8..12162bf 100644 --- a/src/Value.php +++ b/src/Value.php @@ -2,11 +2,14 @@ namespace Mpyw\LaravelMySqlSystemVariableManager; +use Closure; use InvalidArgumentException; -use PDO; +use ReflectionFunction; class Value implements ValueInterface { + use ExpressionTrait; + /** * @var mixed */ @@ -25,7 +28,7 @@ class Value implements ValueInterface */ public static function int(int $value) { - return new static($value, static::TYPE_INT); + return new static($value, static::TYPE_INTEGER); } /** @@ -36,7 +39,7 @@ public static function int(int $value) */ public static function bool(bool $value) { - return new static($value, static::TYPE_BOOL); + return new static($value, static::TYPE_BOOLEAN); } /** @@ -58,47 +61,62 @@ public static function float(float $value) */ public static function str(string $value) { - return new static($value, static::TYPE_STR); + return new static($value, static::TYPE_STRING); } /** * Create new typed value for MySQL system variable. * - * @param string $type - * @param bool|float|int|string $value - * @return \Mpyw\LaravelMySqlSystemVariableManager\ValueInterface + * @param string $type + * @param bool|float|int|string $value + * @return \Mpyw\LaravelMySqlSystemVariableManager\ExpressionInterface */ - public static function as(string $type, $value): ValueInterface + public static function as(string $type, $value): ExpressionInterface { switch ($type) { - case static::TYPE_INT: + case static::TYPE_INTEGER: return static::int($value); - case static::TYPE_BOOL: + case static::TYPE_BOOLEAN: return static::bool($value); case static::TYPE_FLOAT: return static::float($value); - case static::TYPE_STR: + case static::TYPE_STRING: return static::str($value); default: throw new InvalidArgumentException('The type must be one of "integer", "boolean", "double" or "string".'); } } + /** @noinspection PhpDocMissingThrowsInspection */ + /** * Automatically wrap a non-null value. * - * @param mixed $value - * @return \Mpyw\LaravelMySqlSystemVariableManager\ValueInterface + * @param mixed $value + * @return \Mpyw\LaravelMySqlSystemVariableManager\ExpressionInterface */ - public static function wrap($value): ValueInterface + public static function wrap($value): ExpressionInterface { - if ($value instanceof ValueInterface) { + if ($value instanceof ExpressionInterface) { return $value; } + if (is_scalar($value)) { return static::as(gettype($value), $value); } - throw new InvalidArgumentException('The value must be a scalar or ' . ValueInterface::class . ' instance.'); + + if ($value instanceof Closure) { + /* @noinspection PhpUnhandledExceptionInspection */ + $reflector = new ReflectionFunction($value); + if ($reflector->hasReturnType()) { + $returnType = $reflector->getReturnType(); + if (!$returnType->allowsNull() && $type = ExpressionInterface::GRAMMATICAL_TYPE_TO_STRING_TYPE[$returnType->getName()] ?? null) { + return Replacer::as($type, $value); + } + } + } + + throw new InvalidArgumentException('The value must be a scalar, return-type-explicit closure or ' . ExpressionInterface::class . ' instance.'); } /** @@ -132,41 +150,4 @@ public function getType(): string { return $this->type; } - - /** - * Return PDO::PARAM_* type. - * - * @return int - */ - public function getParamType(): int - { - switch ($this->type) { - case static::TYPE_INT: - return PDO::PARAM_INT; - case static::TYPE_BOOL: - return PDO::PARAM_BOOL; - case static::TYPE_FLOAT: - case static::TYPE_STR: - default: - return PDO::PARAM_STR; - } - } - - /** - * Return a placeholder format. - * - * @return string - */ - public function getPlaceholder(): string - { - switch ($this->type) { - case static::TYPE_FLOAT: - return 'cast(? as decimal(65, 30))'; - case static::TYPE_INT: - case static::TYPE_BOOL: - case static::TYPE_STR: - default: - return '?'; - } - } } diff --git a/src/ValueInterface.php b/src/ValueInterface.php index 6d880e3..f0a81a8 100644 --- a/src/ValueInterface.php +++ b/src/ValueInterface.php @@ -2,38 +2,12 @@ namespace Mpyw\LaravelMySqlSystemVariableManager; -interface ValueInterface +interface ValueInterface extends ExpressionInterface { - public const TYPE_INT = 'integer'; - public const TYPE_BOOL = 'boolean'; - public const TYPE_FLOAT = 'double'; - public const TYPE_STR = 'string'; - /** * Return original value. * * @return bool|float|int|string */ public function getValue(); - - /** - * Return type. - * - * @return string - */ - public function getType(): string; - - /** - * Return PDO::PARAM_* type. - * - * @return int - */ - public function getParamType(): int; - - /** - * Return placeholder for prepared statement. - * - * @return string - */ - public function getPlaceholder(): string; } diff --git a/tests/BasicVariableAssignmentTest.php b/tests/BasicVariableAssignmentTest.php index f992ab6..17a7dd1 100644 --- a/tests/BasicVariableAssignmentTest.php +++ b/tests/BasicVariableAssignmentTest.php @@ -4,6 +4,7 @@ use InvalidArgumentException; use Mpyw\LaravelMySqlSystemVariableManager\MySqlConnection; +use Mpyw\LaravelMySqlSystemVariableManager\Replacer; use Mpyw\LaravelMySqlSystemVariableManager\Value; class BasicVariableAssignmentTest extends TestCase @@ -17,7 +18,7 @@ class BasicVariableAssignmentTest extends TestCase * @param mixed $expectedChanged * @dataProvider provideBasicVariables */ - public function testBasicVariables(string $variableName, bool $emulated, $expectedOriginal, $newValue, $expectedChanged): void + public function testAssignments(string $variableName, bool $emulated, $expectedOriginal, $newValue, $expectedChanged): void { $this->{$emulated ? 'onEmulatedConnection' : 'onNativeConnection'}(function (MySqlConnection $db) use ($variableName, $expectedOriginal, $newValue, $expectedChanged) { $this->assertSame($expectedOriginal, $db->selectOne("select @@{$variableName} as value")->value); @@ -26,6 +27,26 @@ public function testBasicVariables(string $variableName, bool $emulated, $expect }); } + /** + * @test + * @param string $variableName + * @param bool $emulated + * @param mixed $expectedOriginal + * @param mixed $newValue + * @param mixed $expectedChanged + * @dataProvider provideBasicVariables + */ + public function testTemporaryAssignments(string $variableName, bool $emulated, $expectedOriginal, $newValue, $expectedChanged): void + { + $this->{$emulated ? 'onEmulatedConnection' : 'onNativeConnection'}(function (MySqlConnection $db) use ($variableName, $expectedOriginal, $newValue, $expectedChanged) { + $this->assertSame($expectedOriginal, $db->selectOne("select @@{$variableName} as value")->value); + $db->usingSystemVariable($variableName, $newValue, function () use ($expectedChanged, $db, $variableName) { + $this->assertSame($expectedChanged, $db->selectOne("select @@{$variableName} as value")->value); + }); + $this->assertSame($expectedOriginal, $db->selectOne("select @@{$variableName} as value")->value); + }); + } + /** * @return array */ @@ -48,13 +69,29 @@ public function provideBasicVariables(): array 'assigning wrapped boolean (emulated)' => ['foreign_key_checks', true, '1', Value::bool(false), '0'], 'assigning wrapped string (native)' => ['tx_isolation', false, 'REPEATABLE-READ', Value::str('read-committed'), 'READ-COMMITTED'], 'assigning wrapped string (emulated)' => ['tx_isolation', true, 'REPEATABLE-READ', Value::str('read-committed'), 'READ-COMMITTED'], + 'replacing explicit float (native)' => ['long_query_time', false, 10.0, Replacer::float(function ($v) { return $v + 5.0; }), 15.0], + 'replacing explicit float (emulated)' => ['long_query_time', true, '10.000000', Replacer::float(function ($v) { return $v + 5.0; }), '15.000000'], + 'replacing explicit integer (native)' => ['long_query_time', false, 10.0, Replacer::int(function ($v) { return $v + 5; }), 15.0], + 'replacing explicit integer (emulated)' => ['long_query_time', true, '10.000000', Replacer::int(function ($v) { return $v + 5; }), '15.000000'], + 'replacing explicit boolean (native)' => ['foreign_key_checks', false, 1, Replacer::bool(function ($v) { return !$v; }), 0], + 'replacing explicit boolean (emulated)' => ['foreign_key_checks', true, '1', Replacer::bool(function ($v) { return !$v; }), '0'], + 'replacing explicit string (native)' => ['tx_isolation', false, 'REPEATABLE-READ', Replacer::str(function ($v) { return str_ireplace('repeatable-read', 'read-committed', $v); }), 'READ-COMMITTED'], + 'replacing explicit string (emulated)' => ['tx_isolation', true, 'REPEATABLE-READ', Replacer::str(function ($v) { return str_ireplace('repeatable-read', 'read-committed', $v); }), 'READ-COMMITTED'], + 'replacing implicit float (native)' => ['long_query_time', false, 10.0, function ($v): float { return $v + 5.0; }, 15.0], + 'replacing implicit float (emulated)' => ['long_query_time', true, '10.000000', function ($v): float { return $v + 5.0; }, '15.000000'], + 'replacing implicit integer (native)' => ['long_query_time', false, 10.0, function ($v): int { return $v + 5; }, 15.0], + 'replacing implicit integer (emulated)' => ['long_query_time', true, '10.000000', function ($v): int { return $v + 5; }, '15.000000'], + 'replacing implicit boolean (native)' => ['foreign_key_checks', false, 1, function ($v): bool { return !$v; }, 0], + 'replacing implicit boolean (emulated)' => ['foreign_key_checks', true, '1', function ($v): bool { return !$v; }, '0'], + 'replacing implicit string (native)' => ['tx_isolation', false, 'REPEATABLE-READ', function ($v): string { return str_ireplace('repeatable-read', 'read-committed', $v); }, 'READ-COMMITTED'], + 'replacing implicit string (emulated)' => ['tx_isolation', true, 'REPEATABLE-READ', function ($v): string { return str_ireplace('repeatable-read', 'read-committed', $v); }, 'READ-COMMITTED'], ]; } public function testAssigningNullThrowsExceptionOnNative(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The value must be a scalar or Mpyw\LaravelMySqlSystemVariableManager\ValueInterface instance.'); + $this->expectExceptionMessage('The value must be a scalar, return-type-explicit closure or Mpyw\LaravelMySqlSystemVariableManager\ExpressionInterface instance.'); $this->onNativeConnection(function (MySqlConnection $db) { $db->setSystemVariable('foreign_key_checks', null); @@ -65,7 +102,7 @@ public function testAssigningNullThrowsExceptionOnNative(): void public function testAssigningNullThrowsExceptionOnEmulation(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The value must be a scalar or Mpyw\LaravelMySqlSystemVariableManager\ValueInterface instance.'); + $this->expectExceptionMessage('The value must be a scalar, return-type-explicit closure or Mpyw\LaravelMySqlSystemVariableManager\ExpressionInterface instance.'); $this->onEmulatedConnection(function (MySqlConnection $db) { $db->setSystemVariable('foreign_key_checks', null); @@ -76,7 +113,7 @@ public function testAssigningNullThrowsExceptionOnEmulation(): void public function testAssigningNullThrowsOnUnresolvedNativeConnection(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The value must be a scalar or Mpyw\LaravelMySqlSystemVariableManager\ValueInterface instance.'); + $this->expectExceptionMessage('The value must be a scalar, return-type-explicit closure or Mpyw\LaravelMySqlSystemVariableManager\ExpressionInterface instance.'); $this->onNativeConnection(function (MySqlConnection $db) { $db->setSystemVariable('foreign_key_checks', null); @@ -86,7 +123,7 @@ public function testAssigningNullThrowsOnUnresolvedNativeConnection(): void public function testAssigningNullThrowsOnUnresolvedEmulatedConnection(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The value must be a scalar or Mpyw\LaravelMySqlSystemVariableManager\ValueInterface instance.'); + $this->expectExceptionMessage('The value must be a scalar, return-type-explicit closure or Mpyw\LaravelMySqlSystemVariableManager\ExpressionInterface instance.'); $this->onEmulatedConnection(function (MySqlConnection $db) { $db->setSystemVariable('foreign_key_checks', null);