From ecf1082368eea2fd224871110c960f427c9ed1cf Mon Sep 17 00:00:00 2001 From: Maxim Smakouz Date: Thu, 8 Feb 2024 11:54:01 +0200 Subject: [PATCH 1/2] Add returning for SQL Server --- .../SQLServer/Query/SQLServerInsertQuery.php | 87 +++++++++++++ src/Driver/SQLServer/SQLServerCompiler.php | 39 ++++++ src/Driver/SQLServer/SQLServerDriver.php | 4 +- .../SQLServer/Query/InsertQueryTest.php | 122 +++++++++++++++++- .../Query/SQLServerInsertQueryTest.php | 21 +++ 5 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 src/Driver/SQLServer/Query/SQLServerInsertQuery.php create mode 100644 tests/Database/Unit/Driver/SQLServer/Query/SQLServerInsertQueryTest.php diff --git a/src/Driver/SQLServer/Query/SQLServerInsertQuery.php b/src/Driver/SQLServer/Query/SQLServerInsertQuery.php new file mode 100644 index 00000000..08b389ac --- /dev/null +++ b/src/Driver/SQLServer/Query/SQLServerInsertQuery.php @@ -0,0 +1,87 @@ + + */ + protected array $returningColumns = []; + + public function withDriver(DriverInterface $driver, string $prefix = null): QueryInterface + { + $driver instanceof SQLServerDriver or throw new BuilderException( + 'SQLServer InsertQuery can be used only with SQLServer driver' + ); + + return parent::withDriver($driver, $prefix); + } + + public function returning(string|FragmentInterface ...$columns): self + { + $columns === [] and throw new BuilderException('RETURNING clause should contain at least 1 column.'); + + $this->returningColumns = \array_values($columns); + + return $this; + } + + public function run(): mixed + { + if ($this->returningColumns === []) { + return parent::run(); + } + + $params = new QueryParameters(); + $queryString = $this->sqlStatement($params); + + $this->driver->isReadonly() and throw ReadonlyConnectionException::onWriteStatementExecution(); + + $result = $this->driver->query($queryString, $params->getParameters()); + + try { + if (\count($this->returningColumns) === 1) { + return $result->fetchColumn(); + } + return $result->fetch(StatementInterface::FETCH_ASSOC); + } finally { + $result->close(); + } + } + + public function getTokens(): array + { + return [ + 'table' => $this->table, + 'return' => $this->returningColumns, + 'columns' => $this->columns, + 'values' => $this->values, + ]; + } +} diff --git a/src/Driver/SQLServer/SQLServerCompiler.php b/src/Driver/SQLServer/SQLServerCompiler.php index 9515ceea..c29f8749 100644 --- a/src/Driver/SQLServer/SQLServerCompiler.php +++ b/src/Driver/SQLServer/SQLServerCompiler.php @@ -14,6 +14,7 @@ use Cycle\Database\Driver\Compiler; use Cycle\Database\Driver\Quoter; use Cycle\Database\Injection\Fragment; +use Cycle\Database\Injection\FragmentInterface; use Cycle\Database\Injection\Parameter; use Cycle\Database\Query\QueryParameters; @@ -28,6 +29,44 @@ class SQLServerCompiler extends Compiler */ public const ROW_NUMBER = '_ROW_NUMBER_'; + /** + * @psalm-return non-empty-string + */ + protected function insertQuery(QueryParameters $params, Quoter $q, array $tokens): string + { + if (empty($tokens['return'])) { + return parent::insertQuery($params, $q, $tokens); + } + + $values = []; + foreach ($tokens['values'] as $value) { + $values[] = $this->value($params, $q, $value); + } + + $output = \implode(',', \array_map( + fn (string|FragmentInterface|null $return) => $return instanceof FragmentInterface + ? (string) $return + : 'INSERTED.' . $this->quoteIdentifier($return), + $tokens['return'] + )); + + if ($tokens['columns'] === []) { + return \sprintf( + 'INSERT INTO %s OUTPUT %s DEFAULT VALUES', + $this->name($params, $q, $tokens['table'], true), + $output + ); + } + + return \sprintf( + 'INSERT INTO %s (%s) OUTPUT %s VALUES %s', + $this->name($params, $q, $tokens['table'], true), + $this->columns($params, $q, $tokens['columns']), + $output, + \implode(', ', $values) + ); + } + /** * {@inheritdoc} * diff --git a/src/Driver/SQLServer/SQLServerDriver.php b/src/Driver/SQLServer/SQLServerDriver.php index 88a99726..d92ef1a5 100644 --- a/src/Driver/SQLServer/SQLServerDriver.php +++ b/src/Driver/SQLServer/SQLServerDriver.php @@ -17,12 +17,12 @@ use Cycle\Database\Driver\Driver; use Cycle\Database\Driver\PDOStatementInterface; use Cycle\Database\Driver\SQLServer\Query\SQLServerDeleteQuery; +use Cycle\Database\Driver\SQLServer\Query\SQLServerInsertQuery; use Cycle\Database\Driver\SQLServer\Query\SQLServerSelectQuery; use Cycle\Database\Driver\SQLServer\Query\SQLServerUpdateQuery; use Cycle\Database\Exception\DriverException; use Cycle\Database\Exception\StatementException; use Cycle\Database\Injection\ParameterInterface; -use Cycle\Database\Query\InsertQuery; use Cycle\Database\Query\QueryBuilder; use PDO; @@ -175,7 +175,7 @@ public static function create(DriverConfig $config): static new SQLServerCompiler('[]'), new QueryBuilder( new SQLServerSelectQuery(), - new InsertQuery(), + new SQLServerInsertQuery(), new SQLServerUpdateQuery(), new SQLServerDeleteQuery() ) diff --git a/tests/Database/Functional/Driver/SQLServer/Query/InsertQueryTest.php b/tests/Database/Functional/Driver/SQLServer/Query/InsertQueryTest.php index 41665082..7c3fec5a 100644 --- a/tests/Database/Functional/Driver/SQLServer/Query/InsertQueryTest.php +++ b/tests/Database/Functional/Driver/SQLServer/Query/InsertQueryTest.php @@ -5,13 +5,133 @@ namespace Cycle\Database\Tests\Functional\Driver\SQLServer\Query; // phpcs:ignore +use Cycle\Database\Driver\SQLServer\Query\SQLServerInsertQuery; +use Cycle\Database\Driver\SQLServer\Schema\SQLServerColumn; +use Cycle\Database\Exception\BuilderException; +use Cycle\Database\Injection\Fragment; use Cycle\Database\Tests\Functional\Driver\Common\Query\InsertQueryTest as CommonClass; /** * @group driver * @group driver-sqlserver */ -class InsertQueryTest extends CommonClass +final class InsertQueryTest extends CommonClass { public const DRIVER = 'sqlserver'; + + public function testQueryInstance(): void + { + parent::testQueryInstance(); + $this->assertInstanceOf(SQLServerInsertQuery::class, $this->database->insert()); + } + + public function testReturning(): void + { + $insert = $this->database->insert()->into('table') + ->columns('name', 'balance') + ->values('John Doe', 100) + ->returning('name'); + + $this->assertSameQuery( + 'INSERT INTO {table} ({name}, {balance}) OUTPUT INSERTED.{name} VALUES (?,?)', + $insert + ); + } + + public function testMultipleReturning(): void + { + $insert = $this->database->insert()->into('table') + ->columns('name', 'balance') + ->values('John Doe', 100) + ->returning('name', 'created_at'); + + $this->assertSameQuery( + 'INSERT INTO {table} ({name}, {balance}) OUTPUT INSERTED.{name}, INSERTED.{created_at} VALUES (?,?)', + $insert + ); + } + + public function testReturningWithFragment(): void + { + $insert = $this->database->insert()->into('table') + ->columns('name', 'balance') + ->values('John Doe', 100) + ->returning(new Fragment('INSERTED.[name] as [full_name]')); + + $this->assertSameQuery( + 'INSERT INTO {table} ({name}, {balance}) OUTPUT INSERTED.{name} as {full_name} VALUES (?,?)', + $insert + ); + } + + public function testMultipleReturningWithFragment(): void + { + $insert = $this->database->insert()->into('table') + ->columns('name', 'balance') + ->values('John Doe', 100) + ->returning('name', new Fragment('INSERTED.[created_at] as [date]')); + + $this->assertSameQuery( + 'INSERT INTO {table} ({name}, {balance}) OUTPUT INSERTED.{name}, INSERTED.{created_at} as {date} VALUES (?,?)', + $insert + ); + } + + public function testReturningValuesFromDatabase(): void + { + $schema = $this->schema('returning_values'); + $schema->primary('id'); + $schema->string('name'); + $schema->datetime('created_at', defaultValue: SQLServerColumn::DATETIME_NOW); + $schema->save(); + + $returning = $this->database + ->insert('returning_values') + ->values(['name' => 'foo']) + ->returning('id', 'created_at') + ->run(); + + $this->assertSame(1, (int) $returning['id']); + $this->assertIsString($returning['created_at']); + $this->assertNotFalse(\strtotime($returning['created_at'])); + + $returning = $this->database + ->insert('returning_values') + ->values(['name' => 'foo']) + ->returning('id', new Fragment('INSERTED.[created_at] as [datetime]')) + ->run(); + + $this->assertSame(2, (int) $returning['id']); + $this->assertIsString($returning['datetime']); + $this->assertNotFalse(\strtotime($returning['datetime'])); + } + + public function testReturningSingleValueFromDatabase(): void + { + $schema = $this->schema('returning_value'); + $schema->primary('id'); + $schema->string('name'); + $schema->datetime('created_at', defaultValue: SQLServerColumn::DATETIME_NOW); + $schema->save(); + + $returning = $this->database + ->insert('returning_value') + ->values(['name' => 'foo']) + ->returning(new Fragment('INSERTED.[created_at] as [datetime]')) + ->run(); + + $this->assertIsString($returning); + $this->assertNotFalse(\strtotime($returning)); + } + + public function testCustomReturningShouldContainColumns(): void + { + $this->expectException(BuilderException::class); + $this->expectExceptionMessage('RETURNING clause should contain at least 1 column.'); + + $this->database->insert()->into('table') + ->columns('name', 'balance') + ->values('John Doe', 100) + ->returning(); + } } diff --git a/tests/Database/Unit/Driver/SQLServer/Query/SQLServerInsertQueryTest.php b/tests/Database/Unit/Driver/SQLServer/Query/SQLServerInsertQueryTest.php new file mode 100644 index 00000000..d1fdc620 --- /dev/null +++ b/tests/Database/Unit/Driver/SQLServer/Query/SQLServerInsertQueryTest.php @@ -0,0 +1,21 @@ +expectException(BuilderException::class); + $insert->withDriver($this->createMock(DriverInterface::class)); + } +} From 4e2017f15f5b44c4495578da3f7b0558810fa534 Mon Sep 17 00:00:00 2001 From: Maxim Smakouz Date: Thu, 8 Feb 2024 13:07:59 +0200 Subject: [PATCH 2/2] Add unit tests for default values with output --- .../SQLServer/Query/SQLServerInsertQuery.php | 5 +-- .../SQLServer/Query/InsertQueryTest.php | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/Driver/SQLServer/Query/SQLServerInsertQuery.php b/src/Driver/SQLServer/Query/SQLServerInsertQuery.php index 08b389ac..52d1e24a 100644 --- a/src/Driver/SQLServer/Query/SQLServerInsertQuery.php +++ b/src/Driver/SQLServer/Query/SQLServerInsertQuery.php @@ -77,11 +77,8 @@ public function run(): mixed public function getTokens(): array { - return [ - 'table' => $this->table, + return parent::getTokens() + [ 'return' => $this->returningColumns, - 'columns' => $this->columns, - 'values' => $this->values, ]; } } diff --git a/tests/Database/Functional/Driver/SQLServer/Query/InsertQueryTest.php b/tests/Database/Functional/Driver/SQLServer/Query/InsertQueryTest.php index 7c3fec5a..9df7b287 100644 --- a/tests/Database/Functional/Driver/SQLServer/Query/InsertQueryTest.php +++ b/tests/Database/Functional/Driver/SQLServer/Query/InsertQueryTest.php @@ -77,6 +77,16 @@ public function testMultipleReturningWithFragment(): void ); } + public function testReturningWithDefaultValues(): void + { + $insert = $this->database->insert()->into('table')->values([])->returning('created_at'); + + $this->assertSameQuery( + 'INSERT INTO {table} OUTPUT INSERTED.[created_at] DEFAULT VALUES', + $insert + ); + } + public function testReturningValuesFromDatabase(): void { $schema = $this->schema('returning_values'); @@ -124,6 +134,27 @@ public function testReturningSingleValueFromDatabase(): void $this->assertNotFalse(\strtotime($returning)); } + public function testReturningValuesFromDatabaseWithDefaultValuesInsert(): void + { + $schema = $this->schema('returning_value'); + $schema->primary('id'); + $schema->datetime('created_at', defaultValue: SQLServerColumn::DATETIME_NOW); + $schema->datetime('updated_at', defaultValue: SQLServerColumn::DATETIME_NOW); + $schema->save(); + + $returning = $this->database + ->insert('returning_value') + ->values([]) + ->returning('updated_at', new Fragment('INSERTED.[created_at] as [created]')) + ->run(); + + $this->assertIsString($returning['created']); + $this->assertNotFalse(\strtotime($returning['created'])); + + $this->assertIsString($returning['updated_at']); + $this->assertNotFalse(\strtotime($returning['updated_at'])); + } + public function testCustomReturningShouldContainColumns(): void { $this->expectException(BuilderException::class);