Skip to content

Commit

Permalink
Table gateway for tables with a primary key defined
Browse files Browse the repository at this point in the history
  • Loading branch information
sad-spirit committed Aug 17, 2023
1 parent 417237d commit c1277f5
Show file tree
Hide file tree
Showing 5 changed files with 561 additions and 0 deletions.
55 changes: 55 additions & 0 deletions src/PrimaryKeyAccess.php
@@ -0,0 +1,55 @@
<?php

/*
* This file is part of sad_spirit/pg_gateway package
*
* (c) Alexey Borzov <avb@php.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace sad_spirit\pg_gateway;

use sad_spirit\pg_wrapper\ResultSet;

/**
* Interface for gateways to tables that have a primary key defined
*/
interface PrimaryKeyAccess
{
/**
* Deletes a row with the given primary key
*
* @param mixed $primaryKey
* @return ResultSet
*/
public function deleteByPrimaryKey($primaryKey): ResultSet;

/**
* Returns an object that can SELECT a row with the given primary key
*
* @param mixed $primaryKey
* @return SelectProxy
*/
public function selectByPrimaryKey($primaryKey): SelectProxy;

/**
* Updates a row with the given primary key using the given values
*
* @param mixed $primaryKey
* @param array $set
* @return ResultSet
*/
public function updateByPrimaryKey($primaryKey, array $set): ResultSet;

/**
* Executes an "UPSERT" (INSERT ... ON CONFLICT DO UPDATE ...) query with the given values
*
* @param array $values
* @return array Primary key of the row inserted / updated
*/
public function upsert(array $values): array;
}
117 changes: 117 additions & 0 deletions src/conditions/PrimaryKeyCondition.php
@@ -0,0 +1,117 @@
<?php

/*
* This file is part of sad_spirit/pg_gateway package
*
* (c) Alexey Borzov <avb@php.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace sad_spirit\pg_gateway\conditions;

use sad_spirit\pg_gateway\{
Condition,
Fragment,
TableGateway,
TableLocator,
exceptions\InvalidArgumentException,
exceptions\UnexpectedValueException,
fragments\WhereClauseFragment,
metadata\PrimaryKey
};
use sad_spirit\pg_builder\nodes\{
ColumnReference,
ScalarExpression,
expressions\LogicalExpression,
expressions\NamedParameter,
expressions\OperatorExpression,
expressions\TypecastExpression
};
use sad_spirit\pg_builder\converters\TypeNameNodeHandler;

/**
* Generates a WHERE clause fragment for finding a table row by its primary key
*/
final class PrimaryKeyCondition extends Condition
{
private PrimaryKey $primaryKey;
private TypeNameNodeHandler $converterFactory;

public function __construct(PrimaryKey $primaryKey, TypeNameNodeHandler $converterFactory)
{
// Sanity check: primary key actually is defined
if (0 === \count($primaryKey)) {
throw new UnexpectedValueException("No columns in table's primary key");
}
$this->primaryKey = $primaryKey;
$this->converterFactory = $converterFactory;
}

public function getFragment(): Fragment
{
return new WhereClauseFragment($this, Fragment::PRIORITY_HIGHEST);
}

/**
* Possibly converts the given PK value to an array and checks that array keys are corresponding to PK columns
*
* @param mixed $value Either an array ['primary key column' => value, ...] or a value for a
* single-column primary key
* @return array Array of the format ['primary key column' => value, ...]
*/
public function normalizeValue($value): array
{
$columns = $this->primaryKey->getNames();

if (!\is_array($value)) {
if (1 === \count($columns)) {
return [$columns[0] => $value];
} else {
throw new InvalidArgumentException(\sprintf(
"Expecting an array for a composite primary key value, %s given",
\is_object($value) ? 'object(' . \get_class($value) . ')' : \gettype($value)
));
}
}

foreach ($columns as $column) {
if (!\array_key_exists($column, $value)) {
throw new InvalidArgumentException("Primary key column '$column' not found in array");
}
}
if (\count($value) > \count($columns)) {
$unknown = \array_diff(\array_keys($value), $columns);
throw new InvalidArgumentException(
"Indexes '" . \implode("', '", $unknown)
. "' in array do not correspond to primary key columns"
);
}

return $value;
}

protected function generateExpressionImpl(): ScalarExpression
{
$expression = new LogicalExpression([], LogicalExpression::AND);
foreach ($this->primaryKey as $column) {
$expression[] = new OperatorExpression(
'=',
new ColumnReference(TableGateway::ALIAS_SELF, $column->getName()),
new TypecastExpression(
new NamedParameter($column->getName()),
$this->converterFactory->createTypeNameNodeForOID($column->getTypeOID())
)
);
}
return $expression;
}

public function getKey(): ?string
{
return TableLocator::hash([self::class, $this->primaryKey->getAll()]);
}
}
155 changes: 155 additions & 0 deletions src/gateways/PrimaryKeyTableGateway.php
@@ -0,0 +1,155 @@
<?php

/*
* This file is part of sad_spirit/pg_gateway package
*
* (c) Alexey Borzov <avb@php.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace sad_spirit\pg_gateway\gateways;

use sad_spirit\pg_gateway\{
conditions\ParametrizedCondition,
FragmentList,
PrimaryKeyAccess,
SelectProxy,
TableSelect,
conditions\PrimaryKeyCondition,
fragments\SetClauseFragment
};
use sad_spirit\pg_builder\Insert;
use sad_spirit\pg_builder\NativeStatement;
use sad_spirit\pg_builder\nodes\{
ColumnReference,
Identifier,
IndexElement,
IndexParameters,
OnConflictClause,
SetTargetElement,
SingleSetClause,
TargetElement,
lists\SetClauseList,
range\InsertTarget,
};
use sad_spirit\pg_wrapper\ResultSet;

/**
* Table gateway implementation for tables that have a primary key defined
*/
class PrimaryKeyTableGateway extends GenericTableGateway implements PrimaryKeyAccess
{
public function deleteByPrimaryKey($primaryKey): ResultSet
{
$list = new FragmentList($this->primaryKey($primaryKey));

return $this->createDeleteStatement($list)
->executeParams($this->getConnection(), $list->getParameters());
}

public function selectByPrimaryKey($primaryKey): SelectProxy
{
$condition = new PrimaryKeyCondition($this->getPrimaryKey(), $this->tableLocator->getTypeConverterFactory());

return new TableSelect($this->tableLocator, $this, $condition, $condition->normalizeValue($primaryKey));
}

public function updateByPrimaryKey($primaryKey, array $set): ResultSet
{
$list = new FragmentList(
new SetClauseFragment($this->getColumns(), $this->tableLocator, $set),
$this->primaryKey($primaryKey)
);

return $this->createUpdateStatement($list)
->executeParams($this->getConnection(), $list->getParameters());
}

/**
* Creates a condition on a primary key, can be used to combine with other Fragments
*
* @param mixed $value
* @return ParametrizedCondition
*/
public function primaryKey($value): ParametrizedCondition
{
$condition = new PrimaryKeyCondition($this->getPrimaryKey(), $this->tableLocator->getTypeConverterFactory());
return new ParametrizedCondition($condition, $condition->normalizeValue($value));
}

public function upsert(array $values): array
{
$valuesClause = new SetClauseFragment($this->getColumns(), $this->tableLocator, $values);
$native = $this->createUpsertStatement(new FragmentList($valuesClause));
if ([] === $native->getParameterTypes()) {
return $this->getConnection()->execute($native->getSql())->current();
} else {
return $native->executeParams(
$this->getConnection(),
$valuesClause->getParameterHolder()->getParameters()
)->current();
}
}

/**
* Generates an "UPSERT" (INSERT ... ON CONFLICT DO UPDATE ...) statement using given fragments
*
* @param FragmentList $fragments
* @return NativeStatement
*/
public function createUpsertStatement(FragmentList $fragments): NativeStatement
{
return $this->tableLocator->createNativeStatementUsingCache(
function () use ($fragments): Insert {
$insert = $this->createBaseUpsertAST();
$fragments->applyTo($insert);
return $insert;
},
$this->generateStatementKey(self::STATEMENT_UPSERT, $fragments)
);
}

/**
* Generates base AST for "INSERT ... ON CONFLICT DO UPDATE ..." statement executed by upsert()
*
* @return Insert
*/
protected function createBaseUpsertAST(): Insert
{
$insert = $this->tableLocator->getStatementFactory()->insert(new InsertTarget(
$this->getName(),
new Identifier(self::ALIAS_SELF)
));

$target = new IndexParameters();
$set = new SetClauseList();
$primaryKeyColumns = $this->getPrimaryKey()->getNames();
$nonPrimaryKey = \array_diff($this->getColumns()->getNames(), $primaryKeyColumns);

foreach ($primaryKeyColumns as $pk) {
$target[] = new IndexElement(new Identifier($pk));
$insert->returning[] = new TargetElement(new ColumnReference($pk));
}

// "DO INSTEAD NOTHING" clause leads to no rows returned by RETURNING clause, thus a fake update
if ([] === $nonPrimaryKey) {
$pkey = reset($primaryKeyColumns);
$set[] = new SingleSetClause(new SetTargetElement($pkey), new ColumnReference('excluded', $pkey));
} else {
foreach ($nonPrimaryKey as $column) {
$set[] = new SingleSetClause(
new SetTargetElement($column),
new ColumnReference('excluded', $column)
);
}
}

$insert->onConflict = new OnConflictClause(OnConflictClause::UPDATE, $target, $set);

return $insert;
}
}

0 comments on commit c1277f5

Please sign in to comment.