packages/ztd-query-core/src/Shadow/Mutation/Table/SynchronizeMutation.php

1<?php
2
3declare(strict_types=1);
4
5namespace ZtdQuery\Shadow\Mutation\Table;
6
7use ZtdQuery\Exception\DuplicateKeyException;
8use ZtdQuery\Exception\NotNullViolationException;
9use ZtdQuery\Schema\TableDefinition;
10use ZtdQuery\Shadow\Mutation\DataMutation;
11use ZtdQuery\Shadow\Row\RowMatch;
12use ZtdQuery\Shadow\Row\RowMultiset;
13use ZtdQuery\Shadow\ShadowStore;
14
15/**
16 * Replaces a table with a database-evaluated complete result set.
17 *
18 * @phpstan-import-type Row from TableDefinition
19 */
20final class SynchronizeMutation implements DataMutation
21{
22    /**
23     * Binds the instance to what it will work from.
24     *
25     * @param string $tableName Table the result set stands for
26     * @param ?TableDefinition $definition What that table will and will not hold, where it is known
27     * @param string $sql Statement to name in anything it refuses
28     * @param RowMatch $match Finds a row among rows by the key that identifies it
29     * @param RowMultiset $rows Accounts for rows that repeat
30     */
31    public function __construct(
32        private readonly string $tableName,
33        private readonly ?TableDefinition $definition = null,
34        private readonly string $sql = '',
35        private readonly RowMatch $match = new RowMatch(),
36        private readonly RowMultiset $rows = new RowMultiset(),
37    ) {
38    }
39
40    /**
41     * {@inheritDoc}
42     *
43     * @throws NotNullViolationException When a row leaves null in a column that will not take one
44     * @throws DuplicateKeyException When two rows collide on a candidate key
45     */
46    public function apply(ShadowStore $store, array $rows): void
47    {
48        if ($this->definition !== null) {
49            $validated = [];
50            foreach ($rows as $row) {
51                foreach ($this->definition->notNullColumns as $column) {
52                    if (($row[$column] ?? null) === null) {
53                        throw new NotNullViolationException($this->sql, $this->tableName, $column);
54                    }
55                }
56
57                $conflict = $this->definition->candidateKeys()->findConflict($row, $validated);
58                if ($conflict !== null) {
59                    throw new DuplicateKeyException(
60                        $this->sql,
61                        $this->tableName,
62                        $conflict->keyName,
63                        $conflict->values,
64                    );
65                }
66                $validated[] = $row;
67            }
68        }
69
70        $store->set($this->tableName, $rows);
71    }
72
73    /**
74     * Table name.
75     *
76     * @return string
77     */
78    public function tableName(): string
79    {
80        return $this->tableName;
81    }
82
83    /**
84     * Count inserted, updated, and deleted rows once each.
85     *
86     * @param array<int, Row> $before
87     * @param array<int, Row> $after
88     */
89    public function affectedRowCount(array $before, array $after): int
90    {
91        $primaryKeys = $this->definition->primaryKeys ?? [];
92        if ($primaryKeys === []) {
93            return max(
94                count($this->rows->difference($before, $after)),
95                count($this->rows->difference($after, $before)),
96            );
97        }
98
99        $matchedAfter = [];
100        $count = 0;
101        foreach ($before as $beforeRow) {
102            $afterIndex = $this->match->positionOfSameKey($after, $beforeRow, $primaryKeys, $matchedAfter);
103            if ($afterIndex === null) {
104                $count++;
105                continue;
106            }
107            $matchedAfter[] = $afterIndex;
108            if ($beforeRow !== $after[$afterIndex]) {
109                $count++;
110            }
111        }
112
113        return $count + count($after) - count($matchedAfter);
114    }
115}
116