Skip to content

Commit

Permalink
Fluent: add ON CONFLICT for INSERT
Browse files Browse the repository at this point in the history
  • Loading branch information
forrest79 committed Jan 23, 2024
1 parent ee1c566 commit 874f881
Show file tree
Hide file tree
Showing 10 changed files with 675 additions and 38 deletions.
2 changes: 1 addition & 1 deletion docs/db.md
Original file line number Diff line number Diff line change
Expand Up @@ -785,7 +785,7 @@ dump($result2->fetchSingle()); // (string) 'Brandon'

## Transactions

There is simple transaction helper object. Call `transaction()` method on a connection and you will get the `Transaction` object. With this object, you can control transaction or use savepoints.
There is a simple transaction helper object. Call `transaction()` method on a connection and you will get the `Transaction` object. With this object, you can control transaction or use savepoints.

There are methods to control transaction `begin()`, `commit()` and `rollback()` that corresponds to SQL commands. With `begin()` method you can set isolation level - for example repeatable read: `begin('ISOLATION LEVEL REPEATABLE READ')`.

Expand Down
125 changes: 124 additions & 1 deletion docs/fluent.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ Every query is `SELECT` at first, until you call `->insert(...)`, `->update(...)

- `on(string $alias, $condition, ...$params)` - defines new `ON` condition for joins. More `ON` conditions for one join is connected with `AND`. If `$condition` is `string`, you can use `?` and parameters in `$params`. Otherwise `$condition` can be `Complex` or `Db\Sql`.


- `lateral(string $alias)` - make subquery lateral.


Expand Down Expand Up @@ -116,6 +117,15 @@ Every query is `SELECT` at first, until you call `->insert(...)`, `->update(...)
- `rows(array $rows)` - this method can be used to insert multiple rows in one query. `$rows` is an `array` of arrays. Each array is one row (the same as for the `values()` method). All rows must have the same columns. Method can be called multiple and all rows are merged.


- `onConflict($columnsOrConstraint = NULL, $where = NULL)` - this method can start `ON CONFLICT` statement for `INSERT`. When `array` is used as the `$columnsOrConstraint`, the list of columns is used, when `string` is used, constraint is used. This parameter can be completely ommited. Where condition `$where` can be defined only for the list of colums and can be simple `string` or other `Complex` or `Db\Sql`. `Db\Sql` can be used for some complex expression, where you need to use `?` and parameters.


- `doUpdate(array $set, $where = NULL)` - if conflict is detected, `UPDATE` is made instead of `INSERT`. Items od array `$set` can be defined in three ways. When only a `string` value is used (or key is an integer), this value is interpreted as `UPDATE SET value = EXCLUDED.value`. Only strings can be used without a key. When the array item has a `string` key, then `string` or `Db\Sql` value can be used, and now you must define a concrete statement to set (i.e., `['column' => 'EXCLUDED.column || source_table.column2']` is interpreted as `UPDATE SET column = EXCLUDED.column || source_table.column2`). `Db\Sql` can be used if you need to use parameters.


- `doNothing()` - if conflict is detected, nothing is done.


- `update(?string $table = NULL, ?string $alias = NULL)` - set query for update. If the main table is not set, you must set it or rewrite with the `$table` parameter. `$alias` can be provided, when you want to use `UPDATE ... FROM ...`.


Expand All @@ -134,7 +144,7 @@ Every query is `SELECT` at first, until you call `->insert(...)`, `->update(...)
- `using($dataSource, ?string $alias = NULL, $onCondition = NULL)` - set a data source for a merge command. `$dataSource` can be simple string, `Db\Sql\Query` or `Fluent\Query`. `$onCondition` can be simple `string` or other `Complex` or `Db\Sql`. `Db\Sql` can be used for some complex expression, where you need to use `?` and parameters. On condition can be added or extended with the `on()` method.


- `whenMatched($then, $onCondition = NULL)` - add matched branch to a merge command. `$then` is simple string or `Db\Sql` and `$onCondition` can be simple `string` or other `Complex` or `Db\Sql`. `Db\Sql` can be used for some complex expression, where you need to use `?` and parameters.
- `whenMatched($then, $onCondition = NULL)` - add matched branch to a merge command. `$then` is simple string or `Db\Sql` and `$onCondition` can be simple `string` or other `Complex` or `Db\Sql`. `Db\Sql` can be used for some complex expression, where you need to use `?` and parameters.


- `whenNotMatched($then, $onCondition = NULL)` - add not matched branch to a merge command. `$then` is simple string or `Db\Sql` and `$onCondition` can be simple `string` or other `Complex` or `Db\Sql`. `Db\Sql` can be used for some complex expression, where you need to use `?` and parameters.
Expand All @@ -145,7 +155,9 @@ Every query is `SELECT` at first, until you call `->insert(...)`, `->update(...)

- `prefix(string $queryPrefix/$querySuffix, ...$params)` (or `suffix(...)`) - with this, you can define univerzal query prefix or suffix. This is useful for actually not supported fluent syntax. With prefix, you can create CTE (Common Table Expression) queries. With suffix, you can create `SELECT ... FOR UPDATE` for example. Definition can be simple `string` or you can use `?` and parameters.


- `with(string $as, $query, ?string $suffix = NULL, bool $notMaterialized = FALSE)` - prepare CTE (Common Table Expression) query. `$as` is query alias/name, `$query` can be simple string, `Db\Sql\Query` or `Fluent\Query`, `$suffix` is optional definition like `SEARCH BREADTH FIRST BY ...` and `$notMaterialized` can set `WITH` branch as not materialized (materialized is default). `with()` can be called multiple times. When you use it, the query will always start with `WITH ...`.


- `recursive()` - defines `WITH` query recursive.

Expand Down Expand Up @@ -385,6 +397,117 @@ dump($insertedRows); // (integer) 2

You have to use alias `u2` when you're inserting to the same table as selecting from.

#### UPSERT

If you want to write an UPSERT command, use `onConflict()` method with `doUpdate()` or `doNothing()`.

Simple use - check column `id` for conflict update `nick` is conflict is detected.

```php
$insertedOrUpdatedRows = $connection
->insert('users')
->values([
'id' => '20',
'nick' => 'Jimmy',
])
->onConflict(['id'])
->doUpdate(['nick'])
->getAffectedRows();

dump($insertedOrUpdatedRows); // (integer) 1
```

The same with `WHERE` statement on conflicted columns.

```php
$insertedOrUpdatedWithWhereOnConflictRows = $connection
->insert('users')
->values([
'id' => '20',
'nick' => 'James',
])
->onConflict(['id'], Forrest79\PhPgSql\Fluent\Complex::createAnd()->add('users.nick != ?', 'James'))
->doUpdate(['nick'])
->getAffectedRows();

dump($insertedOrUpdatedWithWhereOnConflictRows); // (integer) 1
```
The same with `WHERE` statement on `UPDATE SET`.

```php
$insertedOrUpdatedWithWhereOnUpdateRows = $connection
->insert('users')
->values([
'id' => '20',
'nick' => 'Margaret',
])
->onConflict(['id'])
->doUpdate(['nick'], Forrest79\PhPgSql\Fluent\Complex::createAnd()->add('users.nick != ?', 'Margaret'))
->getAffectedRows();

dump($insertedOrUpdatedWithWhereOnUpdateRows); // (integer) 1
```

And to ignore conflicting inserts:

```php
$insertedOrUpdatedDoNothingRows = $connection
->insert('users')
->values([
'id' => '1',
'nick' => 'Steve',
])
->onConflict()
->doNothing()
->getAffectedRows();

dump($insertedOrUpdatedDoNothingRows); // (integer) 0
```

To use constraint name in `ON CONFLICT`:

```php
$insertedOrUpdatedWithConstraintRows = $connection
->insert('users')
->values([
'id' => '20',
'nick' => 'Jimmy',
])
->onConflict('users_pkey')
->doUpdate(['nick'])
->getAffectedRows();

dump($insertedOrUpdatedWithConstraintRows); // (integer) 1
```

And the last to use manully `SET` with string or also with parameters:

```php
$insertedOrUpdatedRows = $connection
->insert('users')
->values([
'id' => '20',
'nick' => 'Jimmy',
])
->onConflict(['id'])
->doUpdate(['nick' => 'EXCLUDED.nick || users.id'])
->getAffectedRows();

dump($insertedOrUpdatedRows); // (integer) 1

$insertedOrUpdatedRows = $connection
->insert('users')
->values([
'id' => '20',
'nick' => 'Jimmy',
])
->onConflict(['id'])
->doUpdate(['nick' => Forrest79\PhPgSql\Db\Sql\Expression::create('EXCLUDED.nick || ?', 'updated')])
->getAffectedRows();

dump($insertedOrUpdatedRows); // (integer) 1
```

### Update

You can use simple update:
Expand Down
46 changes: 40 additions & 6 deletions src/Fluent/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,40 @@ public function rows(array $rows): Query
}


/**
* @param string|list<string>|NULL $columnsOrConstraint
* @param string|Complex|Db\Sql|NULL $where
* @return QueryExecute
* @throws Exceptions\QueryException
*/
public function onConflict($columnsOrConstraint = NULL, $where = NULL): Query
{
return $this->createQuery()->onConflict($columnsOrConstraint, $where);
}


/**
* @param array<int|string, string|Db\Sql> $set
* @param string|Complex|Db\Sql|NULL $where
* @return QueryExecute
* @throws Exceptions\QueryException
*/
public function doUpdate(array $set, $where = NULL): Query
{
return $this->createQuery()->doUpdate($set, $where);
}


/**
* @return QueryExecute
* @throws Exceptions\QueryException
*/
public function doNothing(): Query
{
return $this->createQuery()->doNothing();
}


/**
* @return QueryExecute
* @throws Exceptions\QueryException
Expand Down Expand Up @@ -427,25 +461,25 @@ public function using($dataSource, ?string $alias = NULL, $onCondition = NULL):

/**
* @param string|Db\Sql $then
* @param string|Complex|Db\Sql|NULL $onCondition
* @param string|Complex|Db\Sql|NULL $condition
* @return QueryExecute
* @throws Exceptions\QueryException
*/
public function whenMatched($then, $onCondition = NULL): Query
public function whenMatched($then, $condition = NULL): Query
{
return $this->createQuery()->whenMatched($then, $onCondition);
return $this->createQuery()->whenMatched($then, $condition);
}


/**
* @param string|Db\Sql $then
* @param string|Complex|Db\Sql|NULL $onCondition
* @param string|Complex|Db\Sql|NULL $condition
* @return QueryExecute
* @throws Exceptions\QueryException
*/
public function whenNotMatched($then, $onCondition = NULL): Query
public function whenNotMatched($then, $condition = NULL): Query
{
return $this->createQuery()->whenNotMatched($then, $onCondition);
return $this->createQuery()->whenNotMatched($then, $condition);
}


Expand Down
33 changes: 27 additions & 6 deletions src/Fluent/Exceptions/QueryBuilderException.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ class QueryBuilderException extends Exception
public const BAD_PARAMS_COUNT = 8;
public const NO_CORRESPONDING_TABLE = 9;
public const SELECT_ALL_COLUMNS_CANT_BE_COMBINED_WITH_CONCRETE_COLUMN_FOR_INSERT_SELECT_WITH_COLUMN_DETECTION = 10;
public const NO_USING = 11;
public const NO_WHEN = 12;
public const MERGE_NO_USING = 11;
public const MERGE_NO_WHEN = 12;
public const ON_CONFLICT_NO_DO = 13;
public const ON_CONFLICT_DO_WITHOUT_DEFINITION = 14;
public const ON_CONFLICT_DO_UPDATE_SET_SINGLE_COLUMN_CAN_BE_ONLY_STRING = 15;


public static function badQueryType(string $type): self
Expand Down Expand Up @@ -81,15 +84,33 @@ public static function selectAllColumnsCantBeCombinedWithConcreteColumnForInsert
}


public static function noUsing(): self
public static function mergeNoUsing(): self
{
return new self('No USING for MERGE.', self::NO_USING);
return new self('There is missing USING statement for MERGE command.', self::MERGE_NO_USING);
}


public static function noWhen(): self
public static function mergeNoWhen(): self
{
return new self('No WHEN for MERGE.', self::NO_WHEN);
return new self('There must be at least one WHEN statement for MERGE command.', self::MERGE_NO_WHEN);
}


public static function onConflictNoDo(): self
{
return new self('There is missing DO statement for ON CONFLICT clause.', self::ON_CONFLICT_NO_DO);
}


public static function onConflictDoWithoutDefinition(): self
{
return new self('There is existing DO statement but ON CONFLICT clause is missing.', self::ON_CONFLICT_DO_WITHOUT_DEFINITION);
}


public static function onConflictDoUpdateSetSingleColumnCanBeOnlyString(): self
{
return new self('ON CONFLICT UPDATE SET array value without alias (string key) must be only string.', self::ON_CONFLICT_DO_UPDATE_SET_SINGLE_COLUMN_CAN_BE_ONLY_STRING);
}

}
13 changes: 10 additions & 3 deletions src/Fluent/Exceptions/QueryException.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ class QueryException extends Exception
public const PARAM_MUST_BE_SCALAR_OR_ENUM_OR_EXPRESSION = 5;
public const CANT_UPDATE_QUERY_AFTER_EXECUTE = 6;
public const YOU_MUST_EXECUTE_QUERY_BEFORE_THAT = 7;
public const ONLY_ONE_USING = 8;
public const MERGE_ONLY_ONE_USING = 8;
public const ON_CONFLICT_WHERE_NOT_FOR_CONSTRAINT = 9;


public static function onlyOneMainTable(): self
Expand Down Expand Up @@ -59,9 +60,15 @@ public static function youMustExecuteQueryBeforeThat(): self
}


public static function onlyOneUsing(): self
public static function mergeOnlyOneUsing(): self
{
return new self('USING can be set only once.', self::ONLY_ONE_USING);
return new self('USING can be set only once.', self::MERGE_ONLY_ONE_USING);
}


public static function onConflictWhereNotForConstraint(): self
{
return new self('ON CONFLICT with constraint can\'t have a WHERE clause.', self::ON_CONFLICT_WHERE_NOT_FOR_CONSTRAINT);
}

}
Loading

0 comments on commit 874f881

Please sign in to comment.