Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/backend-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ jobs:
run: composer run-script --timeout=600 test-integration
env:
SEARCH_ENGINE: legacy
DATABASE_URL: "pgsql://postgres:postgres@localhost:${{ job.services.postgres.ports[5432] }}/testdb?server_version=10"
DATABASE_URL: "pgsql://postgres:postgres@localhost:${{ job.services.postgres.ports[5432] }}/testdb?serverVersion=11"

integration-tests-mysql:
name: MySQL integration tests
Expand Down
49 changes: 45 additions & 4 deletions src/contracts/Gateway/AbstractDoctrineDatabase.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Ibexa\CorePersistence\Gateway\ExpressionVisitor;
use Ibexa\CorePersistence\Gateway\RelationshipTypeStrategyRegistry;
use InvalidArgumentException;
use LogicException;

/**
* @internal
Expand Down Expand Up @@ -61,19 +62,59 @@ public function getMetadata(): DoctrineSchemaMetadataInterface
}

/**
* Inserts a row and returns its identifier, taking it from $data when the caller supplied it
* and from the connection otherwise.
*
* @param array<string, mixed> $data
*
* @throws \Doctrine\DBAL\Exception
*/
protected function doInsert(array $data): int
{
$metadata = $this->getMetadata();
$identifierColumns = $metadata->getIdentifierColumns();

if (count($identifierColumns) !== 1) {
throw new LogicException(sprintf(
'"%s" does not have a single identifier column to return. Use doInsertWithoutIdentity() instead.',
$metadata->getTableName(),
));
}

$this->executeInsert($data);

$identifierColumn = $identifierColumns[0];

return isset($data[$identifierColumn])
? (int)$data[$identifierColumn]
: (int)$this->connection->lastInsertId();
}

/**
* Inserts a row into a table that has no single identifier column to return, such as one keyed
* by a composite primary key.
*
* @param array<string, mixed> $data
*
* @throws \Doctrine\DBAL\Exception
*/
protected function doInsertWithoutIdentity(array $data): void
{
$this->executeInsert($data);
}

/**
* @param array<string, mixed> $data
*
* @throws \Doctrine\DBAL\Exception
*/
private function executeInsert(array $data): void
{
$metadata = $this->getMetadata();
$data = $metadata->convertToDatabaseValues($data);
$types = $metadata->getBindingTypesForData($data);

$this->connection->insert($metadata->getTableName(), $data, $types);

return (int)$this->connection->lastInsertId();
}

/**
Expand Down Expand Up @@ -358,7 +399,7 @@ private function buildCondition(QueryBuilder $qb, string $column, $value): strin
} elseif (is_array($value)) {
$parameter = $qb->createPositionalParameter(
$value,
$columnBinding + Connection::ARRAY_PARAM_OFFSET
$metadata->getArrayBindingTypeForColumn($column)
);

$subquery->andWhere($qb->expr()->in($fullColumnName, $parameter));
Expand Down Expand Up @@ -390,7 +431,7 @@ private function buildCondition(QueryBuilder $qb, string $column, $value): strin
if (is_array($value)) {
$parameter = $qb->createPositionalParameter(
$value,
$columnBinding + Connection::ARRAY_PARAM_OFFSET
$metadata->getArrayBindingTypeForColumn($column)
);

return $qb->expr()->in($fullColumnName, $parameter);
Expand Down
37 changes: 37 additions & 0 deletions src/contracts/Gateway/ArrayParameterTypeConverter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Contracts\CorePersistence\Gateway;

use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\ParameterType;

/**
* The inverse of {@see \Doctrine\DBAL\ArrayParameterType::toElementParameterType()}, which DBAL
* does not provide.
*/
final class ArrayParameterTypeConverter
{
/**
* Every ParameterType is listed rather than defaulted, so a case DBAL adds later fails here
* instead of silently binding as a string. The three without an array counterpart bind as
* string: the databases coerce string literals for them in an IN list.
*/
public static function fromParameterType(ParameterType $type): ArrayParameterType
{
return match ($type) {
ParameterType::INTEGER => ArrayParameterType::INTEGER,
ParameterType::ASCII => ArrayParameterType::ASCII,
ParameterType::BINARY => ArrayParameterType::BINARY,
ParameterType::STRING,
ParameterType::BOOLEAN,
ParameterType::LARGE_OBJECT,
ParameterType::NULL => ArrayParameterType::STRING,
};
}
}
21 changes: 19 additions & 2 deletions src/contracts/Gateway/DoctrineSchemaMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@

namespace Ibexa\Contracts\CorePersistence\Gateway;

use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;
use Doctrine\DBAL\Types\Type;
use Ibexa\Contracts\CorePersistence\Exception\MappingException;
use Ibexa\Contracts\CorePersistence\Exception\RuntimeMappingException;
Expand Down Expand Up @@ -206,6 +208,11 @@ public function isInheritedColumn(string $column): bool
return $this->getInheritanceMetadataWithColumn($column) !== null;
}

public function getIdentifierColumns(): array
{
return $this->identifierColumns;
}

public function getIdentifierColumn(): string
{
if (count($this->identifierColumns) > 1) {
Expand Down Expand Up @@ -250,7 +257,7 @@ public function convertToDatabaseValues(array $data): array
/**
* @param array<string, mixed> $data
*
* @return array<string, int>
* @return array<string, \Doctrine\DBAL\ParameterType>
*
* @throws \Doctrine\DBAL\Exception
*/
Expand All @@ -267,11 +274,21 @@ public function getBindingTypesForData(array $data): array
/**
* @throws \Doctrine\DBAL\Exception
*/
public function getBindingTypeForColumn(string $columnName): int
public function getBindingTypeForColumn(string $columnName): ParameterType
{
return $this->getColumnType($columnName)->getBindingType();
}

/**
* @throws \Doctrine\DBAL\Exception
*/
public function getArrayBindingTypeForColumn(string $columnName): ArrayParameterType
{
return ArrayParameterTypeConverter::fromParameterType(
$this->getBindingTypeForColumn($columnName)
);
}

public function setTranslationSchemaMetadata(TranslationDoctrineSchemaMetadataInterface $translationMetadata): void
{
$this->translationMetadata = $translationMetadata;
Expand Down
13 changes: 11 additions & 2 deletions src/contracts/Gateway/DoctrineSchemaMetadataInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@

namespace Ibexa\Contracts\CorePersistence\Gateway;

use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;
use Doctrine\DBAL\Types\Type;

/**
Expand Down Expand Up @@ -84,7 +86,7 @@ public function convertToDatabaseValues(array $data): array;
/**
* @param array<string, mixed> $data
*
* @return array<string, int>
* @return array<string, \Doctrine\DBAL\ParameterType>
*
* @throws \Ibexa\Contracts\CorePersistence\Exception\RuntimeMappingExceptionInterface
*/
Expand All @@ -95,11 +97,18 @@ public function getBindingTypesForData(array $data): array;
*/
public function getIdentifierColumn(): string;

/**
* @return array<string>
*/
public function getIdentifierColumns(): array;

/**
* @throws \Doctrine\DBAL\Exception
* @throws \Ibexa\Contracts\CorePersistence\Exception\RuntimeMappingExceptionInterface
*/
public function getBindingTypeForColumn(string $columnName): int;
public function getBindingTypeForColumn(string $columnName): ParameterType;

public function getArrayBindingTypeForColumn(string $columnName): ArrayParameterType;

/**
* @throws \Ibexa\Contracts\CorePersistence\Exception\MappingExceptionInterface
Expand Down
22 changes: 9 additions & 13 deletions src/lib/Gateway/ExpressionVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,9 @@ public function walkComparison(Comparison $comparison)
$parameterName = $column . '_' . count($this->parameters);
$placeholder = $this->getPlaceholder($parameterName);
$value = $this->walkValue($comparison->getValue());
$type = $this->schemaMetadata->getBindingTypeForColumn($column);
if (is_array($value)) {
$type += Connection::ARRAY_PARAM_OFFSET;
}
$type = is_array($value)
? $this->schemaMetadata->getArrayBindingTypeForColumn($column)
: $this->schemaMetadata->getBindingTypeForColumn($column);

if ($this->isInheritedColumn($column)) {
$inheritanceMetadata = $this->schemaMetadata->getInheritanceMetadataWithColumn($column);
Expand Down Expand Up @@ -288,11 +287,9 @@ private function handleJoinQuery(
QueryBuilder $relationshipQuery
): string {
$value = $this->walkValue($comparison->getValue());
$type = $relationshipMetadata->getBindingTypeForColumn($field);

if (is_array($value)) {
$type += Connection::ARRAY_PARAM_OFFSET;
}
$type = is_array($value)
? $relationshipMetadata->getArrayBindingTypeForColumn($field)
: $relationshipMetadata->getBindingTypeForColumn($field);

$parameter = new Parameter($parameterName, $value, $type);
$placeholder = $this->getPlaceholder($parameterName);
Expand Down Expand Up @@ -323,10 +320,9 @@ private function handleSubSelectQuery(
QueryBuilder $relationshipQuery
): string {
$value = $this->walkValue($comparison->getValue());
$type = $relationshipMetadata->getBindingTypeForColumn($field);
if (is_array($value)) {
$type += Connection::ARRAY_PARAM_OFFSET;
}
$type = is_array($value)
? $relationshipMetadata->getArrayBindingTypeForColumn($field)
: $relationshipMetadata->getBindingTypeForColumn($field);

$this->parameters[] = new Parameter($parameterName, $value, $type);

Expand Down
51 changes: 30 additions & 21 deletions src/lib/Gateway/JoinedRelationshipTypeStrategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@

namespace Ibexa\CorePersistence\Gateway;

use Doctrine\DBAL\Query\Exception\NonUniqueAlias;
use Doctrine\DBAL\Query\QueryBuilder;
use Doctrine\DBAL\Query\QueryException;
use Ibexa\Contracts\CorePersistence\Gateway\DoctrineRelationshipInterface;

/**
Expand All @@ -23,19 +25,16 @@ public function handleRelationshipType(
string $fromTable,
string $toTable
): void {
if ($this->isTableAlreadyJoined($queryBuilder, $toTable)) {
$condition = (string)$queryBuilder->expr()->eq(
$fromTable . '.' . $relationship->getForeignKeyColumn(),
$toTable . '.' . $relationship->getRelatedClassIdColumn()
);

if ($this->isAliasAlreadyTaken($queryBuilder, $fromTable, $toTable, $condition)) {
return;
}

$queryBuilder->leftJoin(
$fromTable,
$toTable,
$toTable,
$queryBuilder->expr()->eq(
$fromTable . '.' . $relationship->getForeignKeyColumn(),
$toTable . '.' . $relationship->getRelatedClassIdColumn()
)
);
$queryBuilder->leftJoin($fromTable, $toTable, $toTable, $condition);
}

public function handleRelationshipTypeQuery(
Expand All @@ -46,20 +45,30 @@ public function handleRelationshipTypeQuery(
return $queryBuilder;
}

private function isTableAlreadyJoined(
/**
* A gateway is free to join a relationship's table itself before handing the query over, so the
* only safe answer comes from the query builder rather than from what this strategy has joined.
* DBAL 4 exposes no accessor for the joins it holds, so the join is added to a copy and the copy
* is asked to build itself: an alias that is already taken is exactly what NonUniqueAlias reports.
*/
private function isAliasAlreadyTaken(
QueryBuilder $queryBuilder,
string $tableToJoin
string $fromTable,
string $toTable,
string $condition
): bool {
$joinQueryPart = $queryBuilder->getQueryPart('join');

foreach ($joinQueryPart as $joins) {
foreach ($joins as $join) {
$joinAlias = $join['joinAlias'] ?? $join['joinTable'];
$probe = clone $queryBuilder;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume there is no other way than trying cloning + checking an exception happens?

I'd like to take note of this and revisit later, we might be getting a performance penalty here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not without some shared registry accross all gateways, but that would mean going thru every gateway and strategy, and would be major pita in the future imo.

can use reflection, but doubt that this will increase performance.

$probe->leftJoin($fromTable, $toTable, $toTable, $condition);

if ($joinAlias === $tableToJoin) {
return true;
}
}
try {
$probe->getSQL();
} catch (NonUniqueAlias) {
return true;
} catch (QueryException) {
// The query cannot be built for an unrelated reason - an unknown FROM alias, or no
// SELECT yet. Nothing is duplicated, so the join still has to be made, and the real
// query builder will raise the same problem on its own terms.
return false;
}

return false;
Expand Down
9 changes: 6 additions & 3 deletions src/lib/Gateway/Parameter.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,25 @@

namespace Ibexa\CorePersistence\Gateway;

use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\ParameterType;

/**
* @internal
*/
final class Parameter
{
private string $name;

private int $type;
private ArrayParameterType|ParameterType $type;

/** @var mixed */
private $value;

/**
* @param mixed $value
*/
public function __construct(string $name, $value, int $type)
public function __construct(string $name, $value, ArrayParameterType|ParameterType $type)
{
$this->name = $name;
$this->value = $value;
Expand Down Expand Up @@ -51,7 +54,7 @@ public function getValue()
return $this->value;
}

public function getType(): int
public function getType(): ArrayParameterType|ParameterType
{
return $this->type;
}
Expand Down
Loading