Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.38% covered (success)
99.38%
160 / 161
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
RelationMmManager
100.00% covered (success)
100.00%
160 / 160
100.00% covered (success)
100.00%
7 / 7
26
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 resolveMmRelation
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 fetchRelatedRecords
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
4
 fetchAvailableRecordsToLink
100.00% covered (success)
100.00%
76 / 76
100.00% covered (success)
100.00%
1 / 1
11
 linkRecords
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 unlinkRecords
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
 resolveForeignKeyColumn
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Engine\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Application\Service\Relation\RelationMmFieldResolver;
12use App\Core\Engine\Application\Service\Relation\RelationMmQueryResolver;
13use App\Core\Engine\Domain\Model\FieldMetadata;
14use App\Core\Engine\Domain\Model\ModuleMetadata;
15use App\Core\Engine\Domain\Model\RelationMmMetadata;
16use App\Core\Engine\Domain\Repository\MetadataRepositoryInterface;
17use PDO;
18
19/**
20 * M:M Relation Application Service.
21 *
22 * Manages Many-to-Many association operations across modules:
23 * - Resolving M:M relation definitions and active links
24 * - Fetching related target records for record detail tabs
25 * - Searching available target records to link
26 * - Linking and unlinking associations in intermediate tables
27 *
28 * @package App\Core\Engine\Application\Service
29 */
30final readonly class RelationMmManager
31{
32    private const string COL_TARGET_ID = 't.`id`';
33    private const string FMT_TARGET_COL = 't.`%s`';
34
35    private RelationMmFieldResolver $fieldResolver;
36    private RelationMmQueryResolver $queryResolver;
37
38    /**
39     * RelationMmManager constructor.
40     *
41     * @param MetadataRepositoryInterface  $metadata      Metadata repository.
42     * @param PDO                          $pdo           Database connection.
43     * @param RelationMmFieldResolver|null $fieldResolver Field resolution helper.
44     * @param RelationMmQueryResolver|null $queryResolver Query clause helper.
45     */
46    public function __construct(
47        private MetadataRepositoryInterface $metadata,
48        private PDO                         $pdo,
49        ?RelationMmFieldResolver            $fieldResolver = null,
50        ?RelationMmQueryResolver            $queryResolver = null,
51    ) {
52        $this->fieldResolver = $fieldResolver ?? new RelationMmFieldResolver();
53        $this->queryResolver = $queryResolver ?? new RelationMmQueryResolver();
54    }
55
56    /**
57     * Resolves the M:M relation definition between source module and target module.
58     *
59     * @param string $sourceModuleName Source module machine name.
60     * @param string $targetModuleName Target module machine name.
61     * @return RelationMmMetadata|null Matching M:M relation or null if not found.
62     */
63    public function resolveMmRelation(string $sourceModuleName, string $targetModuleName): ?RelationMmMetadata
64    {
65        $sourceModule = $this->metadata->findModule($sourceModuleName);
66        $relations = $this->metadata->findMmRelationsBySourceModule($sourceModule->id);
67
68        foreach ($relations as $rel) {
69            if ($rel->targetModuleName === $targetModuleName) {
70                return $rel;
71            }
72        }
73
74        return null;
75    }
76
77    /**
78     * Fetches linked records for a given source record and target module.
79     *
80     * @param string $sourceModuleName Source module machine name.
81     * @param int    $sourceRecordId   Source record primary key.
82     * @param string $targetModuleName Target module machine name.
83     * @return array{
84     *     target_module: ModuleMetadata,
85     *     fields: array<FieldMetadata>,
86     *     records: array<int, array<string, mixed>>,
87     *     total: int
88     * } Related records result payload.
89     */
90    public function fetchRelatedRecords(
91        string $sourceModuleName,
92        int    $sourceRecordId,
93        string $targetModuleName,
94    ): array {
95        $sourceModule = $this->metadata->findModule($sourceModuleName);
96        $targetModule = $this->metadata->findModule($targetModuleName);
97        $relation     = $this->resolveMmRelation($sourceModuleName, $targetModuleName);
98
99        if ($relation === null || $relation->intermediateTable === '') {
100            return [
101                'target_module' => $targetModule,
102                'fields'        => [],
103                'records'       => [],
104                'total'         => 0,
105            ];
106        }
107
108        $sourceCol = $this->fieldResolver->resolveForeignKeyColumn($sourceModule->name);
109        $targetCol = $this->fieldResolver->resolveForeignKeyColumn($targetModule->name);
110
111        $fields = $this->metadata->findFields($targetModule->id);
112        $visibleFields = $this->fieldResolver->resolveVisibleRelationFields($targetModule, $fields, $relation);
113
114        $selectCols = [self::COL_TARGET_ID, 'rel.`created_at` AS `linked_at`'];
115        foreach ($visibleFields as $f) {
116            $selectCols[] = sprintf(self::FMT_TARGET_COL, $f->fieldKey);
117        }
118        $selectSql = implode(', ', array_unique($selectCols));
119
120        $sql = sprintf(
121            'SELECT %s FROM `%s` AS rel '
122            . 'JOIN `%s` AS t ON t.`id` = rel.`%s` '
123            . 'WHERE rel.`%s` = :source_id '
124            . 'ORDER BY rel.`created_at` DESC, t.`id` DESC',
125            $selectSql,
126            $relation->intermediateTable,
127            $targetModule->tableName,
128            $targetCol,
129            $sourceCol
130        );
131
132        $stmt = $this->pdo->prepare($sql);
133        $stmt->execute([':source_id' => $sourceRecordId]);
134        /** @var array<int, array<string, mixed>> $records */
135        $records = $stmt->fetchAll(PDO::FETCH_ASSOC);
136
137        return [
138            'target_module' => $targetModule,
139            'fields'        => $visibleFields,
140            'records'       => $records,
141            'total'         => count($records),
142        ];
143    }
144
145    /**
146     * Searches target module records available for linking (excluding already linked records).
147     *
148     * @param string               $sourceModuleName Source module machine name.
149     * @param int                  $sourceRecordId   Source record primary key.
150     * @param string               $targetModuleName Target module machine name.
151     * @param array<string, mixed> $options          Search, pagination, and sorting options.
152     * @return array<string, mixed> Structured result containing target module, fields, records, and pagination.
153     */
154    public function fetchAvailableRecordsToLink(
155        string $sourceModuleName,
156        int    $sourceRecordId,
157        string $targetModuleName,
158        array  $options = [],
159    ): array {
160        $sourceModule = $this->metadata->findModule($sourceModuleName);
161        $targetModule = $this->metadata->findModule($targetModuleName);
162        $relation     = $this->resolveMmRelation($sourceModuleName, $targetModuleName);
163        $perPage      = max(1, min(100, (int) ($options['per_page'] ?? 15)));
164
165        if ($relation === null || $relation->intermediateTable === '') {
166            return $this->queryResolver->buildEmptyAvailablePayload($relation, $targetModule, $perPage);
167        }
168
169        $sourceCol = $this->fieldResolver->resolveForeignKeyColumn($sourceModule->name);
170        $targetCol = $this->fieldResolver->resolveForeignKeyColumn($targetModule->name);
171
172        $fields = $this->metadata->findFields($targetModule->id);
173        $visibleFields = $this->fieldResolver->resolveVisibleRelationFields($targetModule, $fields);
174
175        $validKeys = ['id' => true];
176        $searchableCols = [];
177        foreach ($fields as $f) {
178            $validKeys[$f->fieldKey] = true;
179            if (in_array($f->uitypeName, ['string_input', 'text_area', 'email_input', 'phone', 'url'], true)) {
180                $searchableCols[] = sprintf(self::FMT_TARGET_COL, $f->fieldKey);
181            }
182        }
183        if ($searchableCols === []) {
184            $searchableCols = [self::COL_TARGET_ID];
185        }
186
187        $sortField = (string) ($options['sort_field'] ?? 'id');
188        $sortOrder = (string) ($options['sort_order'] ?? 'DESC');
189        $safeSortField = isset($validKeys[$sortField]) ? $sortField : 'id';
190        $safeSortOrder = strtoupper($sortOrder) === 'ASC' ? 'ASC' : 'DESC';
191
192        $columnFilters = (array) ($options['column_filters'] ?? []);
193        $generalQuery = trim((string) ($options['general_query'] ?? ''));
194
195        [$whereSql, $params] = $this->queryResolver->buildAvailableRecordsWhere(
196            [
197                'table'      => $relation->intermediateTable,
198                'source_col' => $sourceCol,
199                'target_col' => $targetCol,
200                'source_id'  => $sourceRecordId,
201            ],
202            $columnFilters,
203            $validKeys,
204            $searchableCols,
205            $generalQuery
206        );
207
208        $countSql = sprintf('SELECT COUNT(*) FROM `%s` AS t %s', $targetModule->tableName, $whereSql);
209        $stmtCount = $this->pdo->prepare($countSql);
210        $stmtCount->execute($params);
211        $total = (int) $stmtCount->fetchColumn();
212
213        $page = max(1, (int) ($options['page'] ?? 1));
214        $totalPages = max(1, (int) ceil($total / $perPage));
215        if ($page > $totalPages && $total > 0) {
216            $page = $totalPages;
217        }
218        $offset = ($page - 1) * $perPage;
219
220        $selectCols = [self::COL_TARGET_ID];
221        foreach ($visibleFields as $f) {
222            $selectCols[] = sprintf(self::FMT_TARGET_COL, $f->fieldKey);
223        }
224        $selectSql = implode(', ', array_unique($selectCols));
225
226        $sql = sprintf(
227            'SELECT %s FROM `%s` AS t %s ORDER BY t.`%s` %s LIMIT %d OFFSET %d',
228            $selectSql,
229            $targetModule->tableName,
230            $whereSql,
231            $safeSortField,
232            $safeSortOrder,
233            $perPage,
234            $offset
235        );
236
237        $stmt = $this->pdo->prepare($sql);
238        $stmt->execute($params);
239        /** @var array<int, array<string, mixed>> $records */
240        $records = $stmt->fetchAll(PDO::FETCH_ASSOC);
241
242        return [
243            'relation'       => $relation,
244            'target_module'  => $targetModule,
245            'fields'         => $visibleFields,
246            'records'        => $records,
247            'total'          => $total,
248            'page'           => $page,
249            'per_page'       => $perPage,
250            'total_pages'    => $totalPages,
251            'sort_field'     => $safeSortField,
252            'sort_order'     => $safeSortOrder,
253            'column_filters' => $columnFilters,
254            'query'          => $generalQuery,
255        ];
256    }
257
258    /**
259     * Links a target record to a source record in the intermediate table.
260     *
261     * @param string $sourceModuleName Source module machine name.
262     * @param int    $sourceRecordId   Source record ID.
263     * @param string $targetModuleName Target module machine name.
264     * @param int    $targetRecordId   Target record ID.
265     * @param int    $actorUserId      Authenticated user ID creating the link.
266     * @return bool True if link was inserted or already exists.
267     */
268    public function linkRecords(
269        string $sourceModuleName,
270        int    $sourceRecordId,
271        string $targetModuleName,
272        int    $targetRecordId,
273        int    $actorUserId,
274    ): bool {
275        $sourceModule = $this->metadata->findModule($sourceModuleName);
276        $targetModule = $this->metadata->findModule($targetModuleName);
277        $relation     = $this->resolveMmRelation($sourceModuleName, $targetModuleName);
278
279        if ($relation === null || $relation->intermediateTable === '') {
280            return false;
281        }
282
283        $sourceCol = $this->fieldResolver->resolveForeignKeyColumn($sourceModule->name);
284        $targetCol = $this->fieldResolver->resolveForeignKeyColumn($targetModule->name);
285
286        $sql = sprintf(
287            'INSERT IGNORE INTO `%s` (`%s`, `%s`, `created_by`) VALUES (:source_id, :target_id, :created_by)',
288            $relation->intermediateTable,
289            $sourceCol,
290            $targetCol
291        );
292
293        $stmt = $this->pdo->prepare($sql);
294        return $stmt->execute([
295            ':source_id'   => $sourceRecordId,
296            ':target_id'   => $targetRecordId,
297            ':created_by'  => $actorUserId,
298        ]);
299    }
300
301    /**
302     * Unlinks a target record from a source record in the intermediate table.
303     *
304     * @param string $sourceModuleName Source module machine name.
305     * @param int    $sourceRecordId   Source record ID.
306     * @param string $targetModuleName Target module machine name.
307     * @param int    $targetRecordId   Target record ID.
308     * @return bool True if link was deleted.
309     */
310    public function unlinkRecords(
311        string $sourceModuleName,
312        int    $sourceRecordId,
313        string $targetModuleName,
314        int    $targetRecordId,
315    ): bool {
316        $sourceModule = $this->metadata->findModule($sourceModuleName);
317        $targetModule = $this->metadata->findModule($targetModuleName);
318        $relation     = $this->resolveMmRelation($sourceModuleName, $targetModuleName);
319
320        if ($relation === null || $relation->intermediateTable === '') {
321            return false;
322        }
323
324        $sourceCol = $this->fieldResolver->resolveForeignKeyColumn($sourceModule->name);
325        $targetCol = $this->fieldResolver->resolveForeignKeyColumn($targetModule->name);
326
327        $sql = sprintf(
328            'DELETE FROM `%s` WHERE `%s` = :source_id AND `%s` = :target_id',
329            $relation->intermediateTable,
330            $sourceCol,
331            $targetCol
332        );
333
334        $stmt = $this->pdo->prepare($sql);
335        return $stmt->execute([
336            ':source_id' => $sourceRecordId,
337            ':target_id' => $targetRecordId,
338        ]);
339    }
340
341    /**
342     * Resolves foreign key column name based on module name.
343     *
344     * @param string $moduleName Machine module name (e.g. 'contacts').
345     * @return string Column name (e.g. 'contact_id').
346     */
347    public function resolveForeignKeyColumn(string $moduleName): string
348    {
349        return $this->fieldResolver->resolveForeignKeyColumn($moduleName);
350    }
351}