Use canonical UUID strings for PostgreSQL queries

This commit is contained in:
Bernard Ngandu
2025-10-20 14:56:03 +02:00
parent c334452426
commit 5ac2fcb1fb
26 changed files with 109 additions and 66 deletions
@@ -30,7 +30,7 @@ final readonly class ArticleForExport
public static function create(array $item): self
{
return new self(
ArticleId::fromBinary($item['article_id']),
ArticleId::fromString(DataMapping::string($item, 'article_id')),
DataMapping::string($item, 'article_title'),
DataMapping::string($item, 'article_link'),
DataMapping::string($item, 'article_categories'),
@@ -26,10 +26,10 @@ final readonly class SourceStatistics
public static function create(array $item): self
{
return new self(
SourceId::fromBinary($item['source_id']),
SourceId::fromString(DataMapping::string($item, 'source_id')),
DataMapping::string($item, 'source_name'),
DataMapping::integer($item, 'articles_count'),
DataMapping::integer($item, 'article_metadata_available'),
DataMapping::integer($item, 'articles_metadata_available'),
DataMapping::nullableDatetime($item, 'source_crawled_at')
);
}
@@ -41,7 +41,8 @@ final readonly class GetArticlesForExportDbalHandler implements GetArticlesForEx
)
->from('article', 'a')
->innerJoin('a', 'source', 's', 'a.source_id = s.id')
->orderBy('a.published_at', 'DESC');
->orderBy('a.published_at', 'DESC')
->addOrderBy('a.id', 'DESC');
if ($query->source !== null) {
$qb->andWhere('s.name = :source')
@@ -44,7 +44,7 @@ final readonly class ArticleDetails
public static function create(array $item): self
{
return new self(
ArticleId::fromBinary($item['article_id']),
ArticleId::fromString(DataMapping::string($item, 'article_id')),
DataMapping::string($item, 'article_title'),
Link::from(DataMapping::string($item, 'article_link')),
explode(',', DataMapping::string($item, 'article_categories')),
@@ -33,7 +33,7 @@ final readonly class ArticleOverview
public static function create(array $item): self
{
return new self(
ArticleId::fromBinary($item['article_id']),
ArticleId::fromString(DataMapping::string($item, 'article_id')),
DataMapping::string($item, 'article_title'),
Link::from(DataMapping::string($item, 'article_link')),
explode(',', DataMapping::string($item, 'article_categories')),
@@ -28,7 +28,7 @@ final readonly class Bookmark
public static function create(array $item): self
{
return new self(
BookmarkId::fromBinary($item['bookmark_id']),
BookmarkId::fromString(DataMapping::string($item, 'bookmark_id')),
DataMapping::string($item, 'bookmark_name'),
DataMapping::datetime($item, 'bookmark_created_at'),
DataMapping::nullableString($item, 'bookmark_description'),
@@ -27,7 +27,7 @@ final readonly class Comment
public static function create(array $item): self
{
return new self(
CommentId::fromBinary($item['comment_id']),
CommentId::fromString(DataMapping::string($item, 'comment_id')),
UserReference::create($item),
DataMapping::enum($item, 'comment_sentiment', Sentiment::class),
DataMapping::string($item, 'comment_content'),
@@ -39,7 +39,7 @@ final readonly class SourceDetails
public static function create(array $item, PublicationGraph $publicationGraph, CategoryShares $categoryShares): self
{
return new self(
SourceId::fromBinary($item['source_id']),
SourceId::fromString(DataMapping::string($item, 'source_id')),
DataMapping::string($item, 'source_name'),
DataMapping::string($item, 'source_url'),
new Credibility(
@@ -27,7 +27,7 @@ final readonly class SourceOverview
public static function create(array $item): self
{
return new self(
SourceId::fromBinary($item['source_id']),
SourceId::fromString(DataMapping::string($item, 'source_id')),
DataMapping::string($item, 'source_name'),
DataMapping::string($item, 'source_url'),
DataMapping::nullableString($item, 'source_display_name'),
@@ -26,7 +26,7 @@ final readonly class SourceReference
public static function create(array $item): self
{
return new self(
SourceId::fromBinary($item['source_id']),
SourceId::fromString(DataMapping::string($item, 'source_id')),
DataMapping::string($item, 'source_name'),
DataMapping::nullableString($item, 'source_display_name'),
DataMapping::nullableString($item, 'source_image'),
@@ -23,7 +23,7 @@ final readonly class UserReference
public static function create(array $item): self
{
return new self(
UserId::fromBinary($item['user_id']),
UserId::fromString(DataMapping::string($item, 'user_id')),
DataMapping::string($item, 'user_name')
);
}
@@ -39,8 +39,7 @@ final readonly class GetArticleCommentListDbalHandler implements GetArticleComme
->from('comment', 'c')
->innerJoin('c', 'user', 'u', 'c.user_id = u.id')
->where('c.article_id = :articleId')
->orderBy('c.created_at', 'DESC')
->setParameter('articleId', $query->articleId->toRfc4122());
->setParameter('articleId', $query->articleId->toString());
$qb = $this->applyCursorPagination($qb, $query->page, new PaginatorKeyset('c.id', 'c.created_at'));
@@ -41,8 +41,8 @@ final readonly class GetArticleDetailsDbalHandler implements GetArticleDetailsHa
$qb->innerJoin('a', 'source', 's', 'a.source_id = s.id')
->from('article', 'a')
->where('a.id = :articleId')
->setParameter('articleId', $query->id->toRfc4122())
->setParameter('userId', $query->userId->toRfc4122())
->setParameter('articleId', $query->id->toString())
->setParameter('userId', $query->userId->toString())
;
try {
@@ -43,11 +43,16 @@ final readonly class GetArticleOverviewListDbalHandler implements GetArticleOver
$qb->from('article', 'a')
->innerJoin('a', 'source', 's', 'a.source_id = s.id')
//->orderBy('a.published_at', $query->filters->sortDirection->value)
->setParameter('userId', $query->userId->toRfc4122())
->setParameter('userId', $query->userId->toString())
;
$qb = $this->applyArticleFilters($qb, $query->filters);
$qb = $this->applyCursorPagination($qb, $query->page, new PaginatorKeyset('a.id', 'a.published_at'));
$qb = $this->applyCursorPagination(
$qb,
$query->page,
new PaginatorKeyset('a.id', 'a.published_at'),
$query->filters->sortDirection
);
try {
$data = $qb->executeQuery()->fetchAllAssociative();
@@ -37,8 +37,7 @@ final readonly class GetBookmarkListDbalHandler implements GetBookmarkListHandle
->leftJoin('b', 'bookmark_article', 'ba', 'ba.bookmark_id = b.id')
->where('b.user_id = :userId')
->groupBy('b.id')
->orderBy('b.id', 'DESC')
->setParameter('userId', $query->userId->toRfc4122())
->setParameter('userId', $query->userId->toString())
;
$qb = $this->applyCursorPagination($qb, $query->page, new PaginatorKeyset('b.id'));
@@ -43,12 +43,17 @@ final readonly class GetBookmarkedArticleListDbalHandler implements GetBookmarke
->innerJoin('ba', 'bookmark', 'b', 'b.id = ba.bookmark_id AND b.user_id = :userId')
->innerJoin('a', 'source', 's', 'a.source_id = s.id')
->where('b.id = :bookmarkId')
->setParameter('bookmarkId', $query->bookmarkId->toRfc4122())
->setParameter('userId', $query->userId->toRfc4122())
->setParameter('bookmarkId', $query->bookmarkId->toString())
->setParameter('userId', $query->userId->toString())
;
$qb = $this->applyArticleFilters($qb, $query->filters);
$qb = $this->applyCursorPagination($qb, $query->page, new PaginatorKeyset('a.id', 'a.published_at'));
$qb = $this->applyCursorPagination(
$qb,
$query->page,
new PaginatorKeyset('a.id', 'a.published_at'),
$query->filters->sortDirection
);
try {
$data = $qb->executeQuery()->fetchAllAssociative();
@@ -43,13 +43,17 @@ final readonly class GetSourceArticleOverviewListDbalHandler implements GetSourc
$qb->from('article', 'a')
->innerJoin('a', 'source', 's', 'a.source_id = s.id')
->where('s.id = :sourceId')
->orderBy('a.published_at', $query->filters->sortDirection->value)
->setParameter('userId', $query->userId->toRfc4122())
->setParameter('sourceId', $query->sourceId->toRfc4122())
->setParameter('userId', $query->userId->toString())
->setParameter('sourceId', $query->sourceId->toString())
;
$qb = $this->applyArticleFilters($qb, $query->filters);
$qb = $this->applyCursorPagination($qb, $query->page, new PaginatorKeyset('a.id', 'a.published_at'));
$qb = $this->applyCursorPagination(
$qb,
$query->page,
new PaginatorKeyset('a.id', 'a.published_at'),
$query->filters->sortDirection
);
try {
$data = $qb->executeQuery()->fetchAllAssociative();
@@ -49,8 +49,8 @@ final readonly class GetSourceDetailsDbalHandler implements GetSourceDetailsHand
$qb->from('source', 's')
->leftJoin('s', 'article', 'a', 'a.source_id = s.id')
->where('s.id = :sourceId')
->setParameter('sourceId', $query->sourceId->toRfc4122())
->setParameter('userId', $query->userId->toRfc4122());
->setParameter('sourceId', $query->sourceId->toString())
->setParameter('userId', $query->userId->toString());
// Aggregate columns are selected; include non-aggregated columns in GROUP BY for PostgreSQL
$qb->groupBy('s.id, s.name, s.description, s.url, s.updated_at, s.display_name, s.bias, s.reliability, s.transparency');
@@ -84,7 +84,7 @@ final readonly class GetSourceDetailsDbalHandler implements GetSourceDetailsHand
->andWhere('a.published_at BETWEEN to_timestamp(:start) AND to_timestamp(:end)')
->groupBy('day')
->orderBy('day', 'ASC')
->setParameter('sourceId', $query->sourceId->toRfc4122())
->setParameter('sourceId', $query->sourceId->toString())
->setParameter('start', $dateRange->start, ParameterType::INTEGER)
->setParameter('end', $dateRange->end, ParameterType::INTEGER)
->enableResultCache(new QueryCacheProfile(SourceCacheAttributes::CACHE_TTL, $cacheKey));
@@ -126,7 +126,7 @@ final readonly class GetSourceDetailsDbalHandler implements GetSourceDetailsHand
->from('article', 'a')
->innerJoin('a', 'source', 's', 'a.source_id = s.id')
->where('s.id = :sourceId')
->setParameter('sourceId', $query->sourceId->toRfc4122())
->setParameter('sourceId', $query->sourceId->toString())
->enableResultCache(new QueryCacheProfile(SourceCacheAttributes::CACHE_TTL, $cacheKey));
try {
@@ -36,7 +36,7 @@ final readonly class GetSourceOverviewListDbalHandler implements GetSourceOvervi
$qb = $this->addFollowedSourceExistsQuery($qb);
$qb->from('source', 's')
->setParameter('userId', $query->userId->toRfc4122())
->setParameter('userId', $query->userId->toString())
;
$qb = $this->applyCursorPagination($qb, $query->page, new PaginatorKeyset('s.id', 's.created_at'));
@@ -47,7 +47,7 @@ final readonly class GetSourceOverviewListDbalHandler implements GetSourceOvervi
throw NoResult::forQuery($qb->getSQL(), $qb->getParameters(), $e);
}
$pagination = $this->createPaginationInfo($data, $query->page, new PaginatorKeyset('source_id'));
$pagination = $this->createPaginationInfo($data, $query->page, new PaginatorKeyset('source_id', 'source_created_at'));
return SourceOverviewList::create($data, $pagination);
}
}
@@ -22,6 +22,7 @@ trait SourceQuery
"CONCAT('https://devscast.org/images/sources/', s.name, '.png') as source_image",
's.url as source_url',
's.name as source_name',
's.created_at as source_created_at',
);
}
@@ -42,8 +42,8 @@ final class FollowedSourceOrmRepository extends ServiceEntityRepository implemen
return $this->createQueryBuilder('fs')
->andWhere('IDENTITY(fs.follower) = :userId')
->andWhere('IDENTITY(fs.source) = :sourceId')
->setParameter('sourceId', $sourceId->toRfc4122())
->setParameter('userId', $userId->toRfc4122())
->setParameter('sourceId', $sourceId->toString())
->setParameter('userId', $userId->toString())
->getQuery()
->getOneOrNullResult();
}
@@ -27,7 +27,7 @@ final readonly class UserProfile
public static function create(array $item): self
{
return new self(
UserId::fromBinary($item['user_id']),
UserId::fromString(DataMapping::string($item, 'user_id')),
DataMapping::string($item, 'user_name'),
EmailAddress::from(DataMapping::string($item, 'user_email')),
DataMapping::dateTime($item, 'user_created_at'),
@@ -34,7 +34,7 @@ final readonly class GetUserProfileDbalHandler implements GetUserProfileHandler
)
->from('user', 'u')
->where('u.id = :userId')
->setParameter('userId', $query->userId->toRfc4122());
->setParameter('userId', $query->userId->toString());
/** @var array<string, mixed>|false $data */
$data = $qb->executeQuery()->fetchAssociative();
@@ -16,7 +16,7 @@ final readonly class PaginationCursor
{
public function __construct(
public UuidV7 $id,
public \DateTimeImmutable $date,
public ?\DateTimeImmutable $date = null,
) {
}
@@ -26,24 +26,15 @@ final readonly class PaginationCursor
*/
public static function encode(array $item, PaginatorKeyset $keyset): string
{
$id = DataMapping::uuid($item, $keyset->id)->toString();
$payload = [
'id' => DataMapping::string($item, $keyset->id),
];
if ($keyset->date !== null) {
$date = DataMapping::dateTime($item, $keyset->date)->format('Y-m-d H:i:s');
return base64_encode(
json_encode([
'date' => $date,
'id' => $id,
], JSON_THROW_ON_ERROR)
);
$payload['date'] = DataMapping::dateTime($item, $keyset->date)->format('Y-m-d H:i:s');
}
return base64_encode(
json_encode([
'id' => $id,
], JSON_THROW_ON_ERROR)
);
return base64_encode(json_encode($payload, JSON_THROW_ON_ERROR));
}
@@ -58,15 +49,25 @@ final readonly class PaginationCursor
}
try {
$data = json_decode(base64_decode($cursor), true, 512, JSON_THROW_ON_ERROR);
$decoded = base64_decode($cursor, true);
if ($decoded === false) {
return null;
}
if (! is_array($data) || ! isset($data['date'], $data['id'])) {
$data = json_decode($decoded, true, 512, JSON_THROW_ON_ERROR);
if (! is_array($data) || ! isset($data['id'])) {
throw new \InvalidArgumentException('Invalid cursor format');
}
$date = null;
if (isset($data['date'])) {
$date = new \DateTimeImmutable($data['date']);
}
return new self(
id: UuidV7::fromString($data['id']),
date: new \DateTimeImmutable($data['date'])
date: $date,
);
} catch (\Throwable) {
return null;
@@ -8,6 +8,7 @@ use Basango\SharedKernel\Domain\Model\Pagination\Page;
use Basango\SharedKernel\Domain\Model\Pagination\PaginationCursor;
use Basango\SharedKernel\Domain\Model\Pagination\PaginationInfo;
use Basango\SharedKernel\Domain\Model\Pagination\PaginatorKeyset;
use Basango\SharedKernel\Domain\Model\ValueObject\SortDirection;
use Doctrine\DBAL\Query\QueryBuilder;
/**
@@ -17,35 +18,62 @@ use Doctrine\DBAL\Query\QueryBuilder;
*/
trait PaginationQuery
{
public function createPaginationInfo(array $data, Page $page, PaginatorKeyset $keyset): PaginationInfo
public function createPaginationInfo(array &$data, Page $page, PaginatorKeyset $keyset): PaginationInfo
{
$paginationInfo = PaginationInfo::from($page);
if ($data === []) {
return $paginationInfo;
}
$paginationInfo->cursor = PaginationCursor::encode(array_pop($data), $keyset);
$paginationInfo->hasNext = count($data) > $page->limit;
$hasNext = count($data) > $page->limit;
if ($hasNext) {
array_pop($data);
}
$cursorSource = end($data);
if (is_array($cursorSource)) {
$paginationInfo->cursor = PaginationCursor::encode($cursorSource, $keyset);
}
$paginationInfo->hasNext = $hasNext;
reset($data);
return $paginationInfo;
}
public function applyCursorPagination(QueryBuilder $qb, Page $page, PaginatorKeyset $keyset): QueryBuilder
{
public function applyCursorPagination(
QueryBuilder $qb,
Page $page,
PaginatorKeyset $keyset,
SortDirection $direction = SortDirection::DESC
): QueryBuilder {
$orderDirection = strtoupper($direction->value);
$comparisonOperator = $direction === SortDirection::ASC ? '>' : '<';
if ($keyset->date !== null) {
$qb->addOrderBy($keyset->date, $orderDirection);
}
$qb->addOrderBy($keyset->id, $orderDirection);
$cursor = PaginationCursor::decode($page->cursor);
if (! $cursor instanceof PaginationCursor) {
return $this->applyOffsetPagination($qb, $page);
return $qb->setMaxResults($page->limit + 1);
}
if ($keyset->date === null) {
$qb
->andWhere(sprintf('%s <= :cursorLastId', $keyset->id))
->setParameter('cursorLastId', $cursor->id->toRfc4122());
->andWhere(sprintf('%s %s :cursorLastId', $keyset->id, $comparisonOperator))
->setParameter('cursorLastId', $cursor->id->toString());
} else {
if (! $cursor->date instanceof \DateTimeImmutable) {
return $qb->setMaxResults($page->limit + 1);
}
$qb
->andWhere(sprintf('(%s, %s) <= (:cursorLastDate, :cursorLastId)', $keyset->date, $keyset->id))
->andWhere(sprintf('(%s, %s) %s (:cursorLastDate, :cursorLastId)', $keyset->date, $keyset->id, $comparisonOperator))
->setParameter('cursorLastDate', $cursor->date->format('Y-m-d H:i:s'))
->setParameter('cursorLastId', $cursor->id->toRfc4122());
->setParameter('cursorLastId', $cursor->id->toString());
}
return $qb->setMaxResults($page->limit + 1);
@@ -194,7 +194,7 @@ final readonly class ImportEngine
if ($val !== null) {
// Convert BINARY(16) UUIDs to canonical RFC4122
if ($col === 'id' || str_ends_with((string) $col, '_id')) {
$params[$i++] = Uuid::fromBinary($val)->toRfc4122();
$params[$i++] = Uuid::fromBinary($val)->toString();
continue;
}