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