Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased

### Added

- Support for inserting multiple rows with a single statement. `Opis\Database\Database::insert`
and `Opis\Database\SQL\InsertStatement::insert` now accept a list of rows, besides a single row.
See [issue #76](https://github.com/opis/database/issues/76)
- Additional rows may also be passed as extra arguments to `Opis\Database\Database::insert` and
`Opis\Database\SQL\InsertStatement::insert`, instead of a single list of rows in the first argument
- Added `Opis\Database\SQL\SQLStatement::addValues` method
- Added `Opis\Database\SQL\SQLStatement::getValueRows` method
- Added `Opis\Database\SQL\Compiler::handleInsertMultipleValues` method
- Added `INSERT ALL` fallback for Oracle and `UNION ALL` fallback for Firebird,
since neither supports a multi-row `VALUES` clause

### Changed

- Calling `Opis\Database\SQL\InsertStatement::insert` more than once now appends a row
instead of duplicating the column list

## v4.3.0 - 2024-09-29

### Added
Expand Down
7 changes: 4 additions & 3 deletions src/Database.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,14 @@ public function from($tables): QueryCommand
/**
* Insert new records into a table.
*
* @param array $values An array of values.
* @param array $values A single row, or a list of rows
* @param array ...$rows Additional rows, each given the same way as $values
*
* @return InsertCommand|InsertStatement
*/
public function insert(array $values): InsertCommand
public function insert(array $values, array ...$rows): InsertCommand
{
return (new InsertCommand($this->connection))->insert($values);
return (new InsertCommand($this->connection))->insert($values, ...$rows);
}

/**
Expand Down
23 changes: 22 additions & 1 deletion src/SQL/Compiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,14 @@ public function select(SQLStatement $select): string
public function insert(SQLStatement $insert): string
{
$columns = $this->handleColumns($insert->getColumns());
$rows = $insert->getValueRows();

$sql = 'INSERT INTO ';
$sql .= $this->handleTables($insert->getTables());
$sql .= ($columns === '*') ? '' : ' (' . $columns . ')';
$sql .= $this->handleInsertValues($insert->getValues());
$sql .= count($rows) > 1
? $this->handleInsertMultipleValues($rows)
: $this->handleInsertValues($rows === [] ? [] : reset($rows));

return $sql;
}
Expand Down Expand Up @@ -518,6 +521,24 @@ protected function handleInsertValues(array $values)
return ' VALUES (' . $this->params($values) . ')';
}

/**
* Handle multiple rows of insert values
*
* @param array $rows
*
* @return string
*/
protected function handleInsertMultipleValues(array $rows): string
{
$sql = [];

foreach ($rows as $row) {
$sql[] = '(' . $this->params($row) . ')';
}

return ' VALUES ' . implode(', ', $sql);
}

/**
* Handle limits
*
Expand Down
33 changes: 33 additions & 0 deletions src/SQL/Compiler/Firebird.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,37 @@ public function select(SQLStatement $select): string

return $sql;
}

/**
* Compiles an INSERT statement.
*
* Firebird has no multi-row VALUES clause, so several rows are compiled
* into a UNION ALL of single-row selects instead.
*
* @access public
* @param SQLStatement $insert
* @return string
*/
public function insert(SQLStatement $insert): string
{
$rows = $insert->getValueRows();

if (count($rows) < 2) {
return parent::insert($insert);
}

$columns = $this->handleColumns($insert->getColumns());

$sql = 'INSERT INTO ';
$sql .= $this->handleTables($insert->getTables());
$sql .= ($columns === '*') ? '' : ' (' . $columns . ')';

$selects = [];

foreach ($rows as $row) {
$selects[] = 'SELECT ' . $this->params($row) . ' FROM RDB$DATABASE';
}

return $sql . ' ' . implode(' UNION ALL ', $selects);
}
}
31 changes: 31 additions & 0 deletions src/SQL/Compiler/Oracle.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,37 @@ public function select(SQLStatement $select): string
return 'SELECT * FROM (SELECT M1.*, ROWNUM AS OPIS_ROWNUM FROM (' . $sql . ') M1 WHERE ROWNUM <= ' . $limit . ') WHERE OPIS_ROWNUM >= ' . $offset;
}

/**
* Compiles an INSERT statement.
*
* Oracle has no multi-row VALUES clause, so several rows are compiled
* into an INSERT ALL statement instead.
*
* @param SQLStatement $insert
*
* @return string
*/
public function insert(SQLStatement $insert): string
{
$rows = $insert->getValueRows();

if (count($rows) < 2) {
return parent::insert($insert);
}

$columns = $this->handleColumns($insert->getColumns());
$columns = ($columns === '*') ? '' : ' (' . $columns . ')';
$table = $this->handleTables($insert->getTables());

$sql = 'INSERT ALL';

foreach ($rows as $row) {
$sql .= ' INTO ' . $table . $columns . ' VALUES (' . $this->params($row) . ')';
}

return $sql . ' SELECT * FROM dual';
}

/**
* @param mixed $value
*
Expand Down
115 changes: 110 additions & 5 deletions src/SQL/InsertStatement.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ class InsertStatement
/** @var SQLStatement */
protected $sql;

/** @var string[]|null The column list, fixed by the first inserted row */
protected $insertColumns;

/**
* InsertStatement constructor.
* @param SQLStatement|null $statement
Expand All @@ -45,14 +48,29 @@ public function getSQLStatement(): SQLStatement
}

/**
* @param array $values
* Adds one or more rows of values to the statement.
*
* Each argument is handled independently: it may be a single row, given as a
* column-value map, or a list of such rows, exactly like a single call with
* {@see insert()} would treat it.
*
* @param array $values A single row, given as a column-value map, or a list of such rows
* @param array ...$rows Additional rows, each given the same way as $values
* @return InsertStatement
* @throws \InvalidArgumentException If a row does not match the columns of the first row
*/
public function insert(array $values): self
public function insert(array $values, array ...$rows): self
{
foreach ($values as $column => $value) {
$this->sql->addColumn($column);
$this->sql->addValue($value);
array_unshift($rows, $values);

foreach ($rows as $argument) {
if ($this->holdsMultipleRows($argument)) {
foreach ($argument as $row) {
$this->addRow($row);
}
} else {
$this->addRow($argument);
}
}

return $this;
Expand All @@ -73,4 +91,91 @@ public function __clone()
{
$this->sql = clone $this->sql;
}

/**
* Tells whether the given argument holds multiple rows instead of a single one.
*
* @param array $values
* @return bool
*/
private function holdsMultipleRows(array $values): bool
{
if ($values === []) {
return false;
}

foreach ($values as $row) {
if (!is_array($row)) {
return false;
}
}

return true;
}

/**
* Registers a single row, fixing the statement's column list on the first one.
*
* An empty row contributes nothing: no columns and no values row.
*
* @param array $row
* @return void
* @throws \InvalidArgumentException If the row does not match the fixed column list
*/
private function addRow(array $row)
{
if ($row === []) {
return;
}

if ($this->insertColumns === null) {
$this->insertColumns = array_keys($row);

foreach ($this->insertColumns as $column) {
$this->sql->addColumn($column);
}

$this->sql->addValues(array_values($row));

return;
}

$this->sql->addValues($this->alignRow($row));
}

/**
* Validates a row against the statement's column list and reorders its values accordingly.
*
* The row number reported in any thrown exception is 1-based and reflects the row's
* position within the whole statement, counting up across chained {@see insert()} calls.
*
* @param array $row
* @return array The row's values, ordered like the statement's column list
* @throws \InvalidArgumentException If the row misses a column or holds an unknown one
*/
private function alignRow(array $row): array
{
$index = count($this->sql->getValueRows()) + 1;
$values = [];

foreach ($this->insertColumns as $column) {
if (!array_key_exists($column, $row)) {
throw new \InvalidArgumentException(
sprintf('Row %d is missing the column "%s"', $index, $column)
);
}

$values[] = $row[$column];
}

foreach ($row as $column => $value) {
if (!in_array($column, $this->insertColumns, true)) {
throw new \InvalidArgumentException(
sprintf('Row %d contains an unknown column "%s"', $index, $column)
);
}
}

return $values;
}
}
45 changes: 43 additions & 2 deletions src/SQL/SQLStatement.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class SQLStatement
protected $intoTable;
protected $intoDatabase;
protected $from = [];
/** @var array[] Rows of values, each row ordered like the statement's column list */
protected $values = [];

/**
Expand Down Expand Up @@ -411,11 +412,34 @@ public function setFrom(array $from)
}

/**
* @param $value
* Appends a single value to the last row of values, starting a first row if none exists.
*
* @param mixed $value
* @return void
*/
public function addValue($value)
{
$this->values[] = $this->closureToExpression($value);
if ($this->values === []) {
$this->values[] = [];
}

$this->values[count($this->values) - 1][] = $this->closureToExpression($value);
}

/**
* Appends a whole row of values, ordered like the statement's column list.
*
* @param array $values
* @return void
*/
public function addValues(array $values)
{
foreach ($values as &$value) {
$value = $this->closureToExpression($value);
}
unset($value);

$this->values[] = array_values($values);
}

/**
Expand Down Expand Up @@ -523,9 +547,26 @@ public function getFrom(): array
}

/**
* A multi-row statement returns every row's values one after another, flattened
* into a single list. Use {@see getValueRows()} to get the values grouped by row.
*
* @return array
*/
public function getValues(): array
{
if (count($this->values) < 2) {
return $this->values === [] ? [] : reset($this->values);
}

return call_user_func_array('array_merge', $this->values);
}

/**
* Returns the rows of values to be inserted.
*
* @return array[] A list of rows, each row ordered like the statement's column list
*/
public function getValueRows(): array
{
return $this->values;
}
Expand Down
Loading