Use canonical UUID strings for PostgreSQL queries
This commit is contained in:
@@ -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')
|
||||
);
|
||||
}
|
||||
|
||||
+2
-1
@@ -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')
|
||||
);
|
||||
}
|
||||
|
||||
+1
-2
@@ -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'));
|
||||
|
||||
|
||||
+2
-2
@@ -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 {
|
||||
|
||||
+7
-2
@@ -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();
|
||||
|
||||
+1
-2
@@ -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'));
|
||||
|
||||
+8
-3
@@ -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();
|
||||
|
||||
+8
-4
@@ -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();
|
||||
|
||||
+4
-4
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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'),
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+38
-10
@@ -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);
|
||||
|
||||
+1
-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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user