Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
97.89% |
93 / 95 |
|
75.00% |
6 / 8 |
CRAP | |
0.00% |
0 / 1 |
| CommentRelationHydrator | |
97.87% |
92 / 94 |
|
75.00% |
6 / 8 |
16 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| getSelectCommentsBase | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| getSelectAttachmentsBase | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| hydrateCommentsWithRelations | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
4 | |||
| loadAttachmentsForCommentIds | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
3.01 | |||
| loadParentsForRows | |
100.00% |
40 / 40 |
|
100.00% |
1 / 1 |
3 | |||
| formatAuthorName | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
2.02 | |||
| hydrateAttachment | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Modules\Comments\Infrastructure\Hydrator; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Modules\Comments\Domain\Model\Comment; |
| 12 | use App\Modules\Comments\Domain\Model\CommentAttachment; |
| 13 | use PDO; |
| 14 | |
| 15 | /** |
| 16 | * Hydrates Comment domain aggregates with attachments, author profiles, and parent threads. |
| 17 | * |
| 18 | * Encapsulates batch database eager-loading for Comment and CommentAttachment entity graphs. |
| 19 | * |
| 20 | * @package App\Modules\Comments\Infrastructure\Hydrator |
| 21 | */ |
| 22 | final readonly class CommentRelationHydrator |
| 23 | { |
| 24 | private string $tableAttachments; |
| 25 | private string $tableComments; |
| 26 | private string $tableUsers; |
| 27 | |
| 28 | /** |
| 29 | * CommentRelationHydrator constructor. |
| 30 | * |
| 31 | * @param PDO $pdo Active database connection. |
| 32 | * @param string $tablePrefix Database table prefix (default 'c_'). |
| 33 | */ |
| 34 | public function __construct( |
| 35 | private PDO $pdo, |
| 36 | private string $tablePrefix = 'c_' |
| 37 | ) { |
| 38 | $this->tableAttachments = $this->tablePrefix . 'mod_comment_attachments_records'; |
| 39 | $this->tableComments = $this->tablePrefix . 'mod_comments_records'; |
| 40 | $this->tableUsers = $this->tablePrefix . 'mod_users_records'; |
| 41 | } |
| 42 | |
| 43 | private function getSelectCommentsBase(): string |
| 44 | { |
| 45 | return 'SELECT c.id, c.parent_id, c.content, c.is_pinned, c.is_verified, ' . |
| 46 | 'c.target_module, c.target_record_id, c.related_party_ref, c.company_id, ' . |
| 47 | 'c.partner_id, c.contact_id, c.process_ref, c.project_id, c.contract_id, ' . |
| 48 | 'c.subprocess_ref, c.ticket_id, c.task_id, c.stage_id, c.owner, ' . |
| 49 | 'c.created_at, c.updated_at, u.username, u.first_name, u.last_name ' . |
| 50 | "FROM `{$this->tableComments}` c " . |
| 51 | "LEFT JOIN `{$this->tableUsers}` u ON u.id = c.owner"; |
| 52 | } |
| 53 | |
| 54 | private function getSelectAttachmentsBase(): string |
| 55 | { |
| 56 | return 'SELECT id, comment_id, file_name, file_path, file_size, ' . |
| 57 | 'file_extension, mime_type, token, created_by, created_at ' . |
| 58 | "FROM `{$this->tableAttachments}`"; |
| 59 | } |
| 60 | |
| 61 | /** |
| 62 | * Hydrates comments and batch-loads related attachments and parent threads. |
| 63 | * |
| 64 | * @param array<int, array<string, mixed>> $rows Database result rows. |
| 65 | * @return array<int, Comment> Hydrated comment domain models. |
| 66 | */ |
| 67 | public function hydrateCommentsWithRelations(array $rows): array |
| 68 | { |
| 69 | $commentIds = array_map(static fn(array $r): int => (int) $r['id'], $rows); |
| 70 | $attachmentsMap = $this->loadAttachmentsForCommentIds($commentIds); |
| 71 | $parentMap = $this->loadParentsForRows($rows); |
| 72 | |
| 73 | $comments = []; |
| 74 | foreach ($rows as $row) { |
| 75 | $id = (int) $row['id']; |
| 76 | $parentId = !empty($row['parent_id']) ? (int) $row['parent_id'] : null; |
| 77 | $parent = $parentId !== null ? ($parentMap[$parentId] ?? null) : null; |
| 78 | $row['author_name'] = $this->formatAuthorName($row); |
| 79 | |
| 80 | $comments[] = Comment::fromArray($row, $attachmentsMap[$id] ?? [], $parent); |
| 81 | } |
| 82 | |
| 83 | return $comments; |
| 84 | } |
| 85 | |
| 86 | /** |
| 87 | * Eager loads attachments for given comment IDs. |
| 88 | * |
| 89 | * @param array<int, int> $commentIds |
| 90 | * @return array<int, array<int, CommentAttachment>> |
| 91 | */ |
| 92 | public function loadAttachmentsForCommentIds(array $commentIds): array |
| 93 | { |
| 94 | if ($commentIds === []) { |
| 95 | return []; |
| 96 | } |
| 97 | |
| 98 | $placeholders = implode(',', array_fill(0, count($commentIds), '?')); |
| 99 | $sql = $this->getSelectAttachmentsBase() . " WHERE comment_id IN ({$placeholders})"; |
| 100 | $stmt = $this->pdo->prepare($sql); |
| 101 | $stmt->execute(array_values($commentIds)); |
| 102 | $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); |
| 103 | |
| 104 | $map = []; |
| 105 | foreach ($rows as $row) { |
| 106 | $cid = (int) $row['comment_id']; |
| 107 | $map[$cid][] = $this->hydrateAttachment($row); |
| 108 | } |
| 109 | |
| 110 | return $map; |
| 111 | } |
| 112 | |
| 113 | /** |
| 114 | * Eager loads parent comments for hierarchical thread display. |
| 115 | * |
| 116 | * @param array<int, array<string, mixed>> $rows |
| 117 | * @return array<int, Comment> |
| 118 | */ |
| 119 | public function loadParentsForRows(array $rows): array |
| 120 | { |
| 121 | $parentIds = array_values(array_filter(array_unique(array_map( |
| 122 | static fn(array $r): int => (int) ($r['parent_id'] ?? 0), |
| 123 | $rows |
| 124 | )))); |
| 125 | |
| 126 | if ($parentIds === []) { |
| 127 | return []; |
| 128 | } |
| 129 | |
| 130 | $placeholders = implode(',', array_fill(0, count($parentIds), '?')); |
| 131 | $sql = $this->getSelectCommentsBase() . " WHERE c.id IN ({$placeholders})"; |
| 132 | |
| 133 | $stmt = $this->pdo->prepare($sql); |
| 134 | $stmt->execute($parentIds); |
| 135 | $pRows = $stmt->fetchAll(PDO::FETCH_ASSOC); |
| 136 | |
| 137 | $map = []; |
| 138 | foreach ($pRows as $pRow) { |
| 139 | $pid = (int) $pRow['id']; |
| 140 | $map[$pid] = new Comment( |
| 141 | id: $pid, |
| 142 | parentId: null, |
| 143 | content: (string) $pRow['content'], |
| 144 | isPinned: (bool) ($pRow['is_pinned'] ?? false), |
| 145 | isVerified: (bool) ($pRow['is_verified'] ?? false), |
| 146 | targetModule: (string) $pRow['target_module'], |
| 147 | targetRecordId: (int) $pRow['target_record_id'], |
| 148 | relatedPartyRef: null, |
| 149 | companyId: null, |
| 150 | partnerId: null, |
| 151 | contactId: null, |
| 152 | processRef: null, |
| 153 | projectId: null, |
| 154 | contractId: null, |
| 155 | subprocessRef: null, |
| 156 | ticketId: null, |
| 157 | taskId: null, |
| 158 | stageId: null, |
| 159 | owner: (int) $pRow['owner'], |
| 160 | authorName: $this->formatAuthorName($pRow), |
| 161 | authorAvatar: null, |
| 162 | createdAt: (string) $pRow['created_at'], |
| 163 | updatedAt: (string) $pRow['updated_at'] |
| 164 | ); |
| 165 | } |
| 166 | |
| 167 | return $map; |
| 168 | } |
| 169 | |
| 170 | /** |
| 171 | * Formats display name from author row. |
| 172 | * |
| 173 | * @param array<string, mixed> $row |
| 174 | */ |
| 175 | public function formatAuthorName(array $row): string |
| 176 | { |
| 177 | $first = trim((string) ($row['first_name'] ?? '')); |
| 178 | $last = trim((string) ($row['last_name'] ?? '')); |
| 179 | $full = trim($first . ' ' . $last); |
| 180 | |
| 181 | if ($full !== '') { |
| 182 | return $full; |
| 183 | } |
| 184 | |
| 185 | return (string) ($row['username'] ?? 'User'); |
| 186 | } |
| 187 | |
| 188 | /** |
| 189 | * Hydrates a single database row into a CommentAttachment model. |
| 190 | * |
| 191 | * @param array<string, mixed> $row |
| 192 | */ |
| 193 | public function hydrateAttachment(array $row): CommentAttachment |
| 194 | { |
| 195 | return new CommentAttachment( |
| 196 | id: (int) $row['id'], |
| 197 | commentId: (int) $row['comment_id'], |
| 198 | fileName: (string) $row['file_name'], |
| 199 | filePath: (string) $row['file_path'], |
| 200 | fileSize: (int) $row['file_size'], |
| 201 | fileExtension: (string) $row['file_extension'], |
| 202 | mimeType: (string) $row['mime_type'], |
| 203 | token: (string) $row['token'], |
| 204 | createdBy: (int) ($row['created_by'] ?? 1), |
| 205 | createdAt: (string) ($row['created_at'] ?? date('Y-m-d H:i:s')) |
| 206 | ); |
| 207 | } |
| 208 | } |