Skip to content

Add BaseBuilder::upsert() and BaseBuilder::upsertBatch() #6600

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 33 commits into from
Oct 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
fb9b9cd
Add upsert(), upsertBatch(), _upsertBatch(), getCompiledUpsert() to B…
sclubricants Sep 27, 2022
34cf089
Add Upsert Tests
sclubricants Sep 27, 2022
5990476
Add Postgres _upsertBatch()
sclubricants Sep 27, 2022
a7e337e
Add SQLite _upsertBatch()
sclubricants Sep 27, 2022
d856239
Add SQLSRV _upsertBatch()
sclubricants Sep 27, 2022
f25a53d
Add OCI8 _upsertBatch()
sclubricants Sep 27, 2022
277e976
Add ON NULL to Oracle Identity statement
sclubricants Sep 27, 2022
009aeac
Add testSetBatchOneRow() to upsert tests
sclubricants Sep 27, 2022
b6a59ac
Add testUpsertNoData() test
sclubricants Sep 27, 2022
b8650d8
Add some updateBatch() tests
sclubricants Sep 27, 2022
2d5c575
Add documentation and change log for upsert
sclubricants Sep 27, 2022
f7e614e
Fix CS and phpstan
sclubricants Sep 27, 2022
e1f3c0c
Fix CS
sclubricants Sep 27, 2022
69d3b33
Fix User Guide
sclubricants Sep 27, 2022
6af1d92
Fix a few things to increase code coverage
sclubricants Sep 28, 2022
7a12704
Fix CS
sclubricants Sep 28, 2022
2e2ee51
Fix CS
sclubricants Sep 28, 2022
f4a374a
Fix Sqlite
sclubricants Sep 28, 2022
4950c7e
Update system/Database/BaseBuilder.php
sclubricants Sep 30, 2022
abf1ae1
Uncomment code
sclubricants Sep 30, 2022
8b454b2
Update sourc guide
sclubricants Sep 30, 2022
591e236
remove codecoverageignore
sclubricants Sep 30, 2022
89cd54d
Update system/Database/BaseBuilder.php
sclubricants Oct 4, 2022
4cf2c68
Fix set($data, '', false)
sclubricants Oct 7, 2022
41987bc
Add use of setData() with upsert() and allow for getCompiledUpsert() …
sclubricants Oct 7, 2022
5cac8f6
Updated CS-Fixer and rerun
sclubricants Oct 7, 2022
b3d4192
Run Rector
sclubricants Oct 7, 2022
123f477
Fix Test
sclubricants Oct 7, 2022
81e3bcf
Fix getCompiledUpsert()
sclubricants Oct 11, 2022
dd47d52
Fix upsert()
sclubricants Oct 11, 2022
978ba16
Add test with multiple uses of set()
sclubricants Oct 12, 2022
e983b4c
Fix Test
sclubricants Oct 12, 2022
bc9781c
Fix test to use assertStringContainsString()
sclubricants Oct 14, 2022
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
110 changes: 110 additions & 0 deletions system/Database/BaseBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ class BaseBuilder
*
* @phpstan-var array{
* updateFieldsAdditional?: array,
* tableIdentity?: string,
* updateFields?: array,
* constraints?: array,
* fromQuery?: string,
Expand Down Expand Up @@ -1854,6 +1855,115 @@ public function setData($set, ?bool $escape = null, string $alias = '')
return $this;
}

/**
* Compiles an upsert query and returns the sql
*
* @return string
*
* @throws DatabaseException
*/
public function getCompiledUpsert()
{
[$currentTestMode, $this->testMode] = [$this->testMode, true];

$sql = implode(";\n", $this->upsert());

$this->testMode = $currentTestMode;

return $this->compileFinalQuery($sql);
}

/**
* Converts call to batchUpsert
*
* @param array|object|null $set
*
* @return false|int|string[] Number of affected rows or FALSE on failure, SQL array when testMode
*
* @throws DatabaseException
*/
public function upsert($set = null, ?bool $escape = null)
{
// if set() has been used merge QBSet with binds and then setData()
if ($set === null && ! is_array(current($this->QBSet))) {
$set = [];

foreach ($this->QBSet as $field => $value) {
$k = trim($field, $this->db->escapeChar);
// use binds if available else use QBSet value but with RawSql to avoid escape
$set[$k] = isset($this->binds[$k]) ? $this->binds[$k][0] : new RawSql($value);
}

$this->binds = [];

$this->resetRun([
'QBSet' => [],
'QBKeys' => [],
]);

$this->setData($set, true); // unescaped items are RawSql now
} elseif ($set !== null) {
$this->setData($set, $escape);
} // else setData() has already been used and we need to do nothing

return $this->batchExecute('_upsertBatch');
}

/**
* Compiles batch upsert strings and runs the queries
*
* @param array|object|null $set a dataset
*
* @return false|int|string[] Number of affected rows or FALSE on failure, SQL array when testMode
*
* @throws DatabaseException
*/
public function upsertBatch($set = null, ?bool $escape = null, int $batchSize = 100)
{
if ($set !== null) {
$this->setData($set, $escape);
}

return $this->batchExecute('_upsertBatch', $batchSize);
}

/**
* Generates a platform-specific upsertBatch string from the supplied data
*/
protected function _upsertBatch(string $table, array $keys, array $values): string
{
$sql = $this->QBOptions['sql'] ?? '';

// if this is the first iteration of batch then we need to build skeleton sql
if ($sql === '') {
$updateFields = $this->QBOptions['updateFields'] ?? $this->updateFields($keys)->QBOptions['updateFields'] ?? [];

$sql = 'INSERT INTO ' . $table . ' (' . implode(', ', $keys) . ')' . "\n"
. '{:_table_:}'
. 'ON DUPLICATE KEY UPDATE' . "\n"
. implode(
",\n",
array_map(
static fn ($key, $value) => $table . '.' . $key . ($value instanceof RawSql ?
' = ' . $value :
' = ' . 'VALUES(' . $value . ')'),
array_keys($updateFields),
$updateFields
)
);

$this->QBOptions['sql'] = $sql;
}

if (isset($this->QBOptions['fromQuery'])) {
$data = $this->QBOptions['fromQuery'];
} else {
$data = 'VALUES ' . implode(', ', $this->formatValues($values)) . "\n";
}

return str_replace('{:_table_:}', $data, $sql);
}

/**
* Set table alias for dataset sudo table.
*/
Expand Down
96 changes: 96 additions & 0 deletions system/Database/OCI8/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -314,4 +314,100 @@ protected function _updateBatch(string $table, array $keys, array $values): stri

return str_replace('{:_table_:}', $data, $sql);
}

/**
* Generates a platform-specific upsertBatch string from the supplied data
*
* @throws DatabaseException
*/
protected function _upsertBatch(string $table, array $keys, array $values): string
{
$sql = $this->QBOptions['sql'] ?? '';

// if this is the first iteration of batch then we need to build skeleton sql
if ($sql === '') {
$constraints = $this->QBOptions['constraints'] ?? [];

if (empty($constraints)) {
$fieldNames = array_map(static fn ($columnName) => trim($columnName, '"'), $keys);

$uniqueIndexes = array_filter($this->db->getIndexData($table), static function ($index) use ($fieldNames) {
$hasAllFields = count(array_intersect($index->fields, $fieldNames)) === count($index->fields);

return ($index->type === 'PRIMARY' || $index->type === 'UNIQUE') && $hasAllFields;
});

// only take first index
foreach ($uniqueIndexes as $index) {
$constraints = $index->fields;
break;
}

$constraints = $this->onConstraint($constraints)->QBOptions['constraints'] ?? [];
}

if (empty($constraints)) {
if ($this->db->DBDebug) {
throw new DatabaseException('No constraint found for upsert.');
}

return ''; // @codeCoverageIgnore
}

$updateFields = $this->QBOptions['updateFields'] ?? $this->updateFields($keys, false, $constraints)->QBOptions['updateFields'] ?? [];

$sql = 'MERGE INTO ' . $table . "\nUSING (\n{:_table_:}";

$sql .= ') "_upsert"' . "\nON (";

$sql .= implode(
' AND ',
array_map(
static fn ($key) => ($key instanceof RawSql ?
$key :
$table . '.' . $key . ' = ' . '"_upsert".' . $key),
$constraints
)
) . ")\n";

$sql .= "WHEN MATCHED THEN UPDATE SET\n";

$sql .= implode(
",\n",
array_map(
static fn ($key, $value) => $key . ($value instanceof RawSql ?
' = ' . $value :
' = ' . '"_upsert".' . $value),
array_keys($updateFields),
$updateFields
)
);

$sql .= "\nWHEN NOT MATCHED THEN INSERT (" . implode(', ', $keys) . ")\nVALUES ";

$sql .= (' ('
. implode(', ', array_map(static fn ($columnName) => '"_upsert".' . $columnName, $keys))
. ')');

$this->QBOptions['sql'] = $sql;
}

if (isset($this->QBOptions['fromQuery'])) {
$data = $this->QBOptions['fromQuery'];
} else {
$data = implode(
" FROM DUAL UNION ALL\n",
array_map(
static fn ($value) => 'SELECT ' . implode(', ', array_map(
static fn ($key, $index) => $index . ' ' . $key,
$keys,
$value
)),
$values
)
) . " FROM DUAL\n";
}

return str_replace('{:_table_:}', $data, $sql);
}
}
2 changes: 1 addition & 1 deletion system/Database/OCI8/Forge.php
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ protected function _attributeAutoIncrement(array &$attributes, array &$field)
&& stripos($field['type'], 'NUMBER') !== false
&& version_compare($this->db->getVersion(), '12.1', '>=')
) {
$field['auto_increment'] = ' GENERATED BY DEFAULT AS IDENTITY';
$field['auto_increment'] = ' GENERATED BY DEFAULT ON NULL AS IDENTITY';
}
}

Expand Down
91 changes: 91 additions & 0 deletions system/Database/Postgre/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -309,4 +309,95 @@ public function join(string $table, $cond, string $type = '', ?bool $escape = nu

return parent::join($table, $cond, $type, $escape);
}

/**
* Generates a platform-specific upsertBatch string from the supplied data
*
* @throws DatabaseException
*/
protected function _upsertBatch(string $table, array $keys, array $values): string
{
$sql = $this->QBOptions['sql'] ?? '';

// if this is the first iteration of batch then we need to build skeleton sql
if ($sql === '') {
$fieldNames = array_map(static fn ($columnName) => trim($columnName, '"'), $keys);

$constraints = $this->QBOptions['constraints'] ?? [];

if (empty($constraints)) {
$allIndexes = array_filter($this->db->getIndexData($table), static function ($index) use ($fieldNames) {
$hasAllFields = count(array_intersect($index->fields, $fieldNames)) === count($index->fields);

return ($index->type === 'UNIQUE' || $index->type === 'PRIMARY') && $hasAllFields;
});

foreach (array_map(static fn ($index) => $index->fields, $allIndexes) as $index) {
$constraints[] = current($index);
// only one index can be used?
break;
}

$constraints = $this->onConstraint($constraints)->QBOptions['constraints'] ?? [];
}

if (empty($constraints)) {
if ($this->db->DBDebug) {
throw new DatabaseException('No constraint found for upsert.');
}

return ''; // @codeCoverageIgnore
}

// in value set - replace null with DEFAULT where constraint is presumed not null
// autoincrement identity field must use DEFAULT and not NULL
// this could be removed in favour of leaving to developer but does make things easier and function like other DBMS
foreach ($constraints as $constraint) {
$key = array_search(trim($constraint, '"'), $fieldNames, true);

if ($key !== false) {
foreach ($values as $arrayKey => $value) {
if (strtoupper($value[$key]) === 'NULL') {
$values[$arrayKey][$key] = 'DEFAULT';
}
}
}
}

$updateFields = $this->QBOptions['updateFields'] ?? $this->updateFields($keys, false, $constraints)->QBOptions['updateFields'] ?? [];

$sql = 'INSERT INTO ' . $table . ' (';

$sql .= implode(', ', $keys);

$sql .= ")\n";

$sql .= '{:_table_:}';

$sql .= 'ON CONFLICT(' . implode(',', $constraints) . ")\n";

$sql .= "DO UPDATE SET\n";

$sql .= implode(
",\n",
array_map(
static fn ($key, $value) => $key . ($value instanceof RawSql ?
' = ' . $value :
' = ' . '"excluded".' . $value),
array_keys($updateFields),
$updateFields
)
);

$this->QBOptions['sql'] = $sql;
}

if (isset($this->QBOptions['fromQuery'])) {
$data = $this->QBOptions['fromQuery'];
} else {
$data = 'VALUES ' . implode(', ', $this->formatValues($values)) . "\n";
}

return str_replace('{:_table_:}', $data, $sql);
}
}
Loading