diff --git a/src/Database.php b/src/Database.php index cb7ec1be..15210ab7 100644 --- a/src/Database.php +++ b/src/Database.php @@ -17,6 +17,7 @@ use Cycle\Database\Query\InsertQuery; use Cycle\Database\Query\SelectQuery; use Cycle\Database\Query\UpdateQuery; +use Cycle\Database\Query\UpsertQuery; /** * Database class is high level abstraction at top of Driver. Databases usually linked to real @@ -139,6 +140,13 @@ public function insert(?string $table = null): InsertQuery ->insertQuery($this->prefix, $table); } + public function upsert(?string $table = null): UpsertQuery + { + return $this->getDriver(self::WRITE) + ->getQueryBuilder() + ->upsertQuery($this->prefix, $table); + } + public function update(?string $table = null, array $values = [], array $where = []): UpdateQuery { return $this->getDriver(self::WRITE) diff --git a/src/DatabaseInterface.php b/src/DatabaseInterface.php index 421826a7..bf947902 100644 --- a/src/DatabaseInterface.php +++ b/src/DatabaseInterface.php @@ -17,6 +17,7 @@ use Cycle\Database\Query\InsertQuery; use Cycle\Database\Query\SelectQuery; use Cycle\Database\Query\UpdateQuery; +use Cycle\Database\Query\UpsertQuery; /** * DatabaseInterface is high level abstraction used to represent single database. You must always @@ -104,6 +105,15 @@ public function query(string $query, array $parameters = []): StatementInterface */ public function insert(string $table = ''): InsertQuery; + /** + * Get instance of UpsertBuilder associated with current Database. + * + * @param string $table Table where values should be upserted to. + * + * @see self::withoutCache() May be useful to disable query cache for batch inserts. + */ + public function upsert(string $table = ''): UpsertQuery; + /** * Get instance of UpdateBuilder associated with current Database. * diff --git a/src/Driver/Compiler.php b/src/Driver/Compiler.php index 93ba54a9..3ad0a284 100644 --- a/src/Driver/Compiler.php +++ b/src/Driver/Compiler.php @@ -109,6 +109,9 @@ protected function fragment( case self::INSERT_QUERY: return $this->insertQuery($params, $q, $tokens); + case self::UPSERT_QUERY: + return $this->upsertQuery($params, $q, $tokens); + case self::SELECT_QUERY: if ($nestedQuery) { if ($fragment->getPrefix() !== null) { @@ -169,6 +172,43 @@ protected function insertQuery(QueryParameters $params, Quoter $q, array $tokens ); } + /** + * @psalm-return non-empty-string + */ + protected function upsertQuery(QueryParameters $params, Quoter $q, array $tokens): string + { + if (\count($tokens['conflicts']) === 0) { + throw new CompilerException('Upsert query must define conflicting index column names'); + } + + if (\count($tokens['columns']) === 0) { + throw new CompilerException('Upsert query must define at least one column'); + } + + $values = []; + + foreach ($tokens['values'] as $value) { + $values[] = $this->value($params, $q, $value); + } + + $updates = \array_map( + function (string $column) use ($params, $q) { + $name = $this->name($params, $q, $column); + return \sprintf('%s = EXCLUDED.%s', $name, $name); + }, + $tokens['columns'], + ); + + return \sprintf( + 'INSERT INTO %s (%s) VALUES %s ON CONFLICT (%s) DO UPDATE SET %s', + $this->name($params, $q, $tokens['table'], true), + $this->columns($params, $q, $tokens['columns']), + \implode(', ', $values), + $this->columns($params, $q, $tokens['conflicts']), + \implode(', ', $updates), + ); + } + /** * @psalm-return non-empty-string */ diff --git a/src/Driver/CompilerInterface.php b/src/Driver/CompilerInterface.php index 281c124b..fceabd0d 100644 --- a/src/Driver/CompilerInterface.php +++ b/src/Driver/CompilerInterface.php @@ -25,6 +25,7 @@ interface CompilerInterface public const DELETE_QUERY = 7; public const JSON_EXPRESSION = 8; public const SUBQUERY = 9; + public const UPSERT_QUERY = 10; public const TOKEN_AND = '@AND'; public const TOKEN_OR = '@OR'; public const TOKEN_AND_NOT = '@AND NOT'; diff --git a/src/Driver/MySQL/MySQLCompiler.php b/src/Driver/MySQL/MySQLCompiler.php index 4789d9f4..1106e438 100644 --- a/src/Driver/MySQL/MySQLCompiler.php +++ b/src/Driver/MySQL/MySQLCompiler.php @@ -15,6 +15,7 @@ use Cycle\Database\Driver\Compiler; use Cycle\Database\Driver\MySQL\Injection\CompileJson; use Cycle\Database\Driver\Quoter; +use Cycle\Database\Exception\CompilerException; use Cycle\Database\Injection\FragmentInterface; use Cycle\Database\Injection\Parameter; use Cycle\Database\Query\QueryParameters; @@ -36,6 +37,38 @@ protected function insertQuery(QueryParameters $params, Quoter $q, array $tokens return parent::insertQuery($params, $q, $tokens); } + /** + * @psalm-return non-empty-string + */ + protected function upsertQuery(QueryParameters $params, Quoter $q, array $tokens): string + { + if (\count($tokens['columns']) === 0) { + throw new CompilerException('Upsert query must define at least one column'); + } + + $values = []; + + foreach ($tokens['values'] as $value) { + $values[] = $this->value($params, $q, $value); + } + + $updates = \array_map( + function ($column) use ($params, $q) { + $name = $this->name($params, $q, $column); + return \sprintf('%s = VALUES(%s)', $name, $name); + }, + $tokens['columns'], + ); + + return \sprintf( + 'INSERT INTO %s (%s) VALUES %s ON DUPLICATE KEY UPDATE %s', + $this->name($params, $q, $tokens['table'], true), + $this->columns($params, $q, $tokens['columns']), + \implode(', ', $values), + \implode(', ', $updates), + ); + } + /** * * diff --git a/src/Driver/MySQL/MySQLDriver.php b/src/Driver/MySQL/MySQLDriver.php index 01d02eb1..b2c83574 100644 --- a/src/Driver/MySQL/MySQLDriver.php +++ b/src/Driver/MySQL/MySQLDriver.php @@ -20,6 +20,7 @@ use Cycle\Database\Exception\StatementException; use Cycle\Database\Query\InsertQuery; use Cycle\Database\Query\QueryBuilder; +use Cycle\Database\Query\UpsertQuery; /** * Talks to mysql databases. @@ -38,6 +39,7 @@ public static function create(DriverConfig $config): static new QueryBuilder( new MySQLSelectQuery(), new InsertQuery(), + new UpsertQuery(), new MySQLUpdateQuery(), new MySQLDeleteQuery(), ), diff --git a/src/Driver/Postgres/PostgresCompiler.php b/src/Driver/Postgres/PostgresCompiler.php index bbebbaac..d1eb6b37 100644 --- a/src/Driver/Postgres/PostgresCompiler.php +++ b/src/Driver/Postgres/PostgresCompiler.php @@ -52,6 +52,29 @@ protected function insertQuery(QueryParameters $params, Quoter $q, array $tokens ); } + /** + * @psalm-return non-empty-string + */ + protected function upsertQuery(QueryParameters $params, Quoter $q, array $tokens): string + { + $query = parent::upsertQuery($params, $q, $tokens); + + if (empty($tokens['return'])) { + return $query; + } + + return \sprintf( + '%s RETURNING %s', + $query, + \implode(',', \array_map( + fn(string|FragmentInterface|null $return) => $return instanceof FragmentInterface + ? $this->fragment($params, $q, $return) + : $this->quoteIdentifier($return), + $tokens['return'], + )), + ); + } + protected function distinct(QueryParameters $params, Quoter $q, string|bool|array $distinct): string { if ($distinct === false) { diff --git a/src/Driver/Postgres/PostgresDriver.php b/src/Driver/Postgres/PostgresDriver.php index 7adee3ad..c0863d51 100644 --- a/src/Driver/Postgres/PostgresDriver.php +++ b/src/Driver/Postgres/PostgresDriver.php @@ -19,6 +19,7 @@ use Cycle\Database\Driver\Postgres\Query\PostgresInsertQuery; use Cycle\Database\Driver\Postgres\Query\PostgresSelectQuery; use Cycle\Database\Driver\Postgres\Query\PostgresUpdateQuery; +use Cycle\Database\Driver\Postgres\Query\PostgresUpsertQuery; use Cycle\Database\Exception\DriverException; use Cycle\Database\Exception\StatementException; use Cycle\Database\Query\QueryBuilder; @@ -65,6 +66,7 @@ public static function create(DriverConfig $config): static new QueryBuilder( new PostgresSelectQuery(), new PostgresInsertQuery(), + new PostgresUpsertQuery(), new PostgresUpdateQuery(), new PostgresDeleteQuery(), ), diff --git a/src/Driver/Postgres/Query/PostgresUpsertQuery.php b/src/Driver/Postgres/Query/PostgresUpsertQuery.php new file mode 100644 index 00000000..ba836600 --- /dev/null +++ b/src/Driver/Postgres/Query/PostgresUpsertQuery.php @@ -0,0 +1,106 @@ + */ + protected array $returningColumns = []; + + public function withDriver(DriverInterface $driver, ?string $prefix = null): QueryInterface + { + $driver instanceof PostgresDriver or throw new BuilderException( + 'Postgres UpsertQuery can be used only with Postgres driver', + ); + + return parent::withDriver($driver, $prefix); + } + + /** + * Set returning column. If not set, the driver will detect PK automatically. + */ + public function returning(string|FragmentInterface ...$columns): self + { + $columns === [] and throw new BuilderException('RETURNING clause should contain at least 1 column.'); + + $this->returning = \count($columns) === 1 ? \reset($columns) : null; + + $this->returningColumns = \array_values($columns); + + return $this; + } + + public function run(): mixed + { + $params = new QueryParameters(); + $queryString = $this->sqlStatement($params); + + $this->driver->isReadonly() and throw ReadonlyConnectionException::onWriteStatementExecution(); + + $result = $this->driver->query($queryString, $params->getParameters()); + + try { + if ($this->returningColumns !== []) { + if (\count($this->returningColumns) === 1) { + return $result->fetchColumn(); + } + + return $result->fetch(StatementInterface::FETCH_ASSOC); + } + + // Return PK if no RETURNING clause is set + if ($this->getPrimaryKey() !== null) { + return $result->fetchColumn(); + } + + return null; + } finally { + $result->close(); + } + } + + public function getTokens(): array + { + return parent::getTokens() + [ + 'return' => $this->returningColumns !== [] ? $this->returningColumns : (array) $this->getPrimaryKey(), + ]; + } + + private function getPrimaryKey(): ?string + { + try { + return $this->driver?->getPrimaryKey($this->prefix, $this->table); + } catch (\Throwable) { + return null; + } + } +} diff --git a/src/Driver/SQLServer/Query/SQLServerUpsertQuery.php b/src/Driver/SQLServer/Query/SQLServerUpsertQuery.php new file mode 100644 index 00000000..a144cbfb --- /dev/null +++ b/src/Driver/SQLServer/Query/SQLServerUpsertQuery.php @@ -0,0 +1,84 @@ + + */ + protected array $returningColumns = []; + + public function withDriver(DriverInterface $driver, ?string $prefix = null): QueryInterface + { + $driver instanceof SQLServerDriver or throw new BuilderException( + 'SQLServer UpsertQuery 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 parent::getTokens() + [ + 'return' => $this->returningColumns, + ]; + } +} diff --git a/src/Driver/SQLServer/SQLServerCompiler.php b/src/Driver/SQLServer/SQLServerCompiler.php index c7819b63..1dcbbab5 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\Driver\SQLServer\Injection\CompileJson; +use Cycle\Database\Exception\CompilerException; use Cycle\Database\Injection\Fragment; use Cycle\Database\Injection\FragmentInterface; use Cycle\Database\Injection\Parameter; @@ -68,6 +69,71 @@ protected function insertQuery(QueryParameters $params, Quoter $q, array $tokens ); } + /** + * @psalm-return non-empty-string + */ + protected function upsertQuery(QueryParameters $params, Quoter $q, array $tokens): string + { + if (\count($tokens['conflicts']) === 0) { + throw new CompilerException('Upsert query must define conflicting index column names'); + } + + if (\count($tokens['columns']) === 0) { + throw new CompilerException('Upsert query must define at least one column'); + } + + $values = []; + + foreach ($tokens['values'] as $value) { + $values[] = $this->value($params, $q, $value); + } + + $target = 'target'; + $source = 'source'; + + $conflicts = \array_map( + function (string $column) use ($params, $q, $target, $source) { + $name = $this->name($params, $q, $column); + $target = $this->name($params, $q, $target); + $source = $this->name($params, $q, $source); + return \sprintf('%s.%s = %s.%s', $target, $name, $source, $name); + }, + $tokens['conflicts'], + ); + + $updates = \array_map( + function (string $column) use ($params, $q, $target, $source) { + $name = $this->name($params, $q, $column); + $target = $this->name($params, $q, $target); + $source = $this->name($params, $q, $source); + return \sprintf('%s.%s = %s.%s', $target, $name, $source, $name); + }, + $tokens['columns'], + ); + + $inserts = \array_map( + function (string $column) use ($params, $q, $source) { + $name = $this->name($params, $q, $column); + $source = $this->name($params, $q, $source); + return \sprintf('%s.%s', $source, $name); + }, + $tokens['columns'], + ); + + return \sprintf( + 'MERGE INTO %s WITH (holdlock) AS %s USING ( VALUES %s) AS %s (%s) ON %s WHEN MATCHED THEN UPDATE SET %s WHEN NOT MATCHED THEN INSERT (%s) VALUES (%s)', + $this->name($params, $q, $tokens['table'], true), + $this->name($params, $q, $target), + \implode(', ', $values), + $this->name($params, $q, 'source'), + $this->columns($params, $q, $tokens['columns']), + \implode(' AND ', $conflicts), + \implode(', ', $updates), + $this->columns($params, $q, $tokens['columns']), + \implode(', ', $inserts), + ); + } + /** * {@inheritDoc} * diff --git a/src/Driver/SQLServer/SQLServerDriver.php b/src/Driver/SQLServer/SQLServerDriver.php index b39cabe1..403e1fd2 100644 --- a/src/Driver/SQLServer/SQLServerDriver.php +++ b/src/Driver/SQLServer/SQLServerDriver.php @@ -19,6 +19,7 @@ use Cycle\Database\Driver\SQLServer\Query\SQLServerInsertQuery; use Cycle\Database\Driver\SQLServer\Query\SQLServerSelectQuery; use Cycle\Database\Driver\SQLServer\Query\SQLServerUpdateQuery; +use Cycle\Database\Driver\SQLServer\Query\SQLServerUpsertQuery; use Cycle\Database\Exception\DriverException; use Cycle\Database\Exception\StatementException; use Cycle\Database\Injection\ParameterInterface; @@ -45,6 +46,7 @@ public static function create(DriverConfig $config): static new QueryBuilder( new SQLServerSelectQuery(), new SQLServerInsertQuery(), + new SQLServerUpsertQuery(), new SQLServerUpdateQuery(), new SQLServerDeleteQuery(), ), diff --git a/src/Driver/SQLite/SQLiteDriver.php b/src/Driver/SQLite/SQLiteDriver.php index 9e40c180..261820ab 100644 --- a/src/Driver/SQLite/SQLiteDriver.php +++ b/src/Driver/SQLite/SQLiteDriver.php @@ -20,6 +20,7 @@ use Cycle\Database\Exception\StatementException; use Cycle\Database\Query\InsertQuery; use Cycle\Database\Query\QueryBuilder; +use Cycle\Database\Query\UpsertQuery; class SQLiteDriver extends Driver { @@ -35,6 +36,7 @@ public static function create(DriverConfig $config): static new QueryBuilder( new SQLiteSelectQuery(), new InsertQuery(), + new UpsertQuery(), new SQLiteUpdateQuery(), new SQLiteDeleteQuery(), ), diff --git a/src/Query/BuilderInterface.php b/src/Query/BuilderInterface.php index 7edddfb0..8040b505 100644 --- a/src/Query/BuilderInterface.php +++ b/src/Query/BuilderInterface.php @@ -33,6 +33,15 @@ public function insertQuery( ?string $table = null, ): InsertQuery; + /** + * Get UpsertQuery builder with driver specific query compiler. + * + */ + public function upsertQuery( + string $prefix, + ?string $table = null, + ): UpsertQuery; + /** * Get SelectQuery builder with driver specific query compiler. * diff --git a/src/Query/QueryBuilder.php b/src/Query/QueryBuilder.php index 3c06b8ca..f69d8fb5 100644 --- a/src/Query/QueryBuilder.php +++ b/src/Query/QueryBuilder.php @@ -23,6 +23,7 @@ final class QueryBuilder implements BuilderInterface public function __construct( private SelectQuery $selectQuery, private InsertQuery $insertQuery, + private UpsertQuery $upsertQuery, private UpdateQuery $updateQuery, private DeleteQuery $deleteQuery, ) {} @@ -32,6 +33,7 @@ public static function defaultBuilder(): self return new self( new SelectQuery(), new InsertQuery(), + new UpsertQuery(), new UpdateQuery(), new DeleteQuery(), ); @@ -61,6 +63,22 @@ public function insertQuery( return $insert; } + /** + * Get UpsertQuery builder with driver specific query compiler. + */ + public function upsertQuery( + string $prefix, + ?string $table = null, + ): UpsertQuery { + $upsert = $this->upsertQuery->withDriver($this->driver, $prefix); + + if ($table !== null) { + $upsert->into($table); + } + + return $upsert; + } + /** * Get SelectQuery builder with driver specific query compiler. */ diff --git a/src/Query/UpsertQuery.php b/src/Query/UpsertQuery.php new file mode 100644 index 00000000..2c61de43 --- /dev/null +++ b/src/Query/UpsertQuery.php @@ -0,0 +1,158 @@ +table = $table ?? ''; + } + + /** + * Set upsert target table. + * + * @psalm-param non-empty-string $into + */ + public function into(string $into): self + { + $this->table = $into; + + return $this; + } + + /** + * Set upsert column names. Names can be provided as array, set of parameters or comma + * separated string. + * + * Examples: + * $upsert->columns(["name", "email"]); + * $upsert->columns("name", "email"); + * $upsert->columns("name, email"); + */ + public function columns(array|string ...$columns): self + { + $this->columns = $this->fetchIdentifiers($columns); + + return $this; + } + + /** + * Set upsert rowset values or multiple rowsets. Values can be provided in multiple forms + * (method parameters, array of values, array of rowsets). Columns names will be automatically + * fetched (if not already specified) from first provided rowset based on rowset keys. + * + * Examples: + * $upsert->columns("name", "balance")->values("Wolfy-J", 10); + * $upsert->values([ + * "name" => "Wolfy-J", + * "balance" => 10 + * ]); + * $upsert->values([ + * [ + * "name" => "Wolfy-J", + * "balance" => 10 + * ], + * [ + * "name" => "Ben", + * "balance" => 20 + * ] + * ]); + */ + public function values(mixed $rowsets): self + { + if (!\is_array($rowsets)) { + return $this->values(\func_get_args()); + } + + if ($rowsets === []) { + return $this; + } + + //Checking if provided set is array of multiple + \reset($rowsets); + + if (!\is_array($rowsets[\key($rowsets)])) { + if ($this->columns === []) { + $this->columns = \array_keys($rowsets); + } + + $this->values[] = new Parameter(\array_values($rowsets)); + } else { + if ($this->columns === []) { + $this->columns = \array_keys($rowsets[\key($rowsets)]); + } + + foreach ($rowsets as $values) { + $this->values[] = new Parameter(\array_values($values)); + } + } + + return $this; + } + + /** + * Set upsert conflicting index column names. Names can be provided as array, set of parameters or comma + * separated string. + * + * Examples: + * $upsert->conflicts(["identifier", "email"]); + * $upsert->conflicts("identifier", "email"); + * $upsert->conflicts("identifier, email"); + */ + public function conflicts(array|string ...$conflicts): self + { + $this->conflicts = $this->fetchIdentifiers($conflicts); + + return $this; + } + + /** + * Run the query and return last insert id. + * Returns an assoc array of values if multiple columns were specified as returning columns. + * + * @return array|int|non-empty-string|null + */ + public function run(): mixed + { + $params = new QueryParameters(); + $queryString = $this->sqlStatement($params); + + $this->driver->execute( + $queryString, + $params->getParameters(), + ); + + $lastID = $this->driver->lastInsertID(); + if (\is_numeric($lastID)) { + return (int) $lastID; + } + + return $lastID; + } + + public function getType(): int + { + return CompilerInterface::UPSERT_QUERY; + } + + public function getTokens(): array + { + return [ + 'table' => $this->table, + 'columns' => $this->columns, + 'values' => $this->values, + 'conflicts' => $this->conflicts, + ]; + } +} diff --git a/src/Table.php b/src/Table.php index ba5b3884..4153e848 100644 --- a/src/Table.php +++ b/src/Table.php @@ -16,6 +16,7 @@ use Cycle\Database\Query\InsertQuery; use Cycle\Database\Query\SelectQuery; use Cycle\Database\Query\UpdateQuery; +use Cycle\Database\Query\UpsertQuery; use Cycle\Database\Schema\AbstractTable; /** @@ -126,6 +127,41 @@ public function insertMultiple(array $columns = [], array $rowsets = []): void ->run(); } + /** + * Upsert one fieldset into table and return last inserted id. + * + * Example: + * $table->upsertOne(["name" => "Wolfy-J", "balance" => 10]); + * + * @throws BuilderException + */ + public function upsertOne(array $rowset = []): int|string|null + { + return $this->database + ->upsert($this->name) + ->values($rowset) + ->run(); + } + + /** + * Perform batch upsert into table, every rowset should have identical amount of values matched + * with column names provided in first argument. Method will return lastInsertID on success. + * + * Example: + * $table->insertMultiple(["name", "balance"], array(["Bob", 10], ["Jack", 20])) + * + * @param array $columns Array of columns. + * @param array $rowsets Array of rowsets. + */ + public function upsertMultiple(array $columns = [], array $rowsets = []): void + { + $this->database + ->upsert($this->name) + ->columns($columns) + ->values($rowsets) + ->run(); + } + /** * Get insert builder specific to current table. */ @@ -135,6 +171,15 @@ public function insert(): InsertQuery ->insert($this->name); } + /** + * Get upsert builder specific to current table. + */ + public function upsert(): UpsertQuery + { + return $this->database + ->upsert($this->name); + } + /** * Get SelectQuery builder with pre-populated from tables. */ diff --git a/tests/Database/Functional/Driver/Common/Query/UpsertQueryTest.php b/tests/Database/Functional/Driver/Common/Query/UpsertQueryTest.php new file mode 100644 index 00000000..7ac4a286 --- /dev/null +++ b/tests/Database/Functional/Driver/Common/Query/UpsertQueryTest.php @@ -0,0 +1,163 @@ +assertInstanceOf(static::QUERY_INSTANCE, $this->database->upsert()); + $this->assertInstanceOf(static::QUERY_INSTANCE, $this->database->table->upsert()); + } + + public function testNoConflictsThrowsException(): void + { + if (static::QUERY_REQUIRES_CONFLICTS) { + $this->expectException(CompilerException::class); + $this->expectExceptionMessage('Upsert query must define conflicting index column names'); + + $this->db()->upsert('table') + ->values( + [ + 'email' => 'adam@email.com', + 'name' => 'Adam', + ], + )->__toString(); + } else { + $this->assertFalse(static::QUERY_REQUIRES_CONFLICTS); + } + } + + public function testNoColumnsThrowsException(): void + { + $this->expectException(CompilerException::class); + $this->expectExceptionMessage('Upsert query must define at least one column'); + + $this->db()->upsert('table') + ->conflicts('email') + ->values([])->__toString(); + } + + public function testQueryWithValues(): void + { + $upsert = $this->db()->upsert('table') + ->conflicts('email') + ->values( + [ + 'email' => 'adam@email.com', + 'name' => 'Adam', + ], + ); + + $this->assertSameQuery(static::QUERY_WITH_VALUES, $upsert); + $this->assertSameParameters(['adam@email.com', 'Adam'], $upsert); + } + + public function testQueryWithStatesValues(): void + { + $upsert = $this->database->upsert('table') + ->conflicts('email') + ->columns('email', 'name') + ->values('adam@email.com', 'Adam'); + + $this->assertSameQuery(static::QUERY_WITH_STATES_VALUES, $upsert); + $this->assertSameParameters(['adam@email.com', 'Adam'], $upsert); + } + + public function testQueryWithMultipleRows(): void + { + $upsert = $this->database->upsert('table') + ->conflicts('email') + ->columns('email', 'name') + ->values('adam@email.com', 'Adam') + ->values('bill@email.com', 'Bill'); + + $this->assertSameQuery(static::QUERY_WITH_MULTIPLE_ROWS, $upsert); + $this->assertSameParameters(['adam@email.com', 'Adam', 'bill@email.com', 'Bill'], $upsert); + } + + public function testQueryWithMultipleRowsAsArray(): void + { + $upsert = $this->database->upsert('table') + ->conflicts('email') + ->values([ + ['email' => 'adam@email.com', 'name' => 'Adam'], + ['email' => 'bill@email.com', 'name' => 'Bill'], + ]); + + $this->assertSameQuery(static::QUERY_WITH_MULTIPLE_ROWS, $upsert); + $this->assertSameParameters(['adam@email.com', 'Adam', 'bill@email.com', 'Bill'], $upsert); + } + + public function testQueryWithExpressions(): void + { + $upsert = $this->database->upsert('table') + ->conflicts('email') + ->values([ + 'email' => 'adam@email.com', + 'name' => 'Adam', + 'created_at' => new Expression('NOW()'), + 'updated_at' => new Expression('NOW()'), + 'deleted_at' => null, + ]); + + $this->assertSameQuery(static::QUERY_WITH_EXPRESSIONS, $upsert); + $this->assertSameParameters(['adam@email.com', 'Adam', null], $upsert); + } + + public function testQueryWithFragments(): void + { + $upsert = $this->database->upsert('table') + ->conflicts('email') + ->values([ + 'email' => 'adam@email.com', + 'name' => 'Adam', + 'created_at' => new Fragment('NOW()'), + 'updated_at' => new Fragment('datetime(\'now\')'), + 'deleted_at' => null, + ]); + + $this->assertSameQuery(static::QUERY_WITH_FRAGMENTS, $upsert); + $this->assertSameParameters(['adam@email.com', 'Adam', null], $upsert); + } + + public function testQueryWithCustomFragment(): void + { + $fragment = $this->createMock(FragmentInterface::class); + $fragment->method('getType')->willReturn(CompilerInterface::FRAGMENT); + $fragment->method('getTokens')->willReturn([ + 'fragment' => 'NOW()', + 'parameters' => [], + ]); + + $upsert = $this->database->upsert('table') + ->conflicts('email') + ->values([ + 'email' => 'adam@email.com', + 'name' => 'Adam', + 'expired_at' => $fragment, + ]); + + $this->assertSameQuery(static::QUERY_WITH_CUSTOM_FRAGMENT, $upsert); + $this->assertSameParameters(['adam@email.com', 'Adam'], $upsert); + } +} diff --git a/tests/Database/Functional/Driver/MySQL/Query/UpsertQueryTest.php b/tests/Database/Functional/Driver/MySQL/Query/UpsertQueryTest.php new file mode 100644 index 00000000..d0a1eca5 --- /dev/null +++ b/tests/Database/Functional/Driver/MySQL/Query/UpsertQueryTest.php @@ -0,0 +1,24 @@ +