depage-xmldb v2.3.1
XML documents in a relational database
Loading...
Searching...
No Matches
PatchPlanner.php
Go to the documentation of this file.
1<?php
2
12
13namespace Depage\XmlDb\Patch;
14
15use Depage\XmlDb\Helpers\NodeContent;
16use Depage\XmlDb\XmlNs;
17
28{
33 public const MAX_CHANGE_RATIO = 0.4;
34
35 protected $pdo;
36
44 public function __construct(
45 $pdo,
46 protected string $table,
47 protected $docId,
48 protected XmlNs $dbNs,
49 protected string $idAttribute = 'id'
50 ) {
51 $this->pdo = $pdo;
52 }
53
70 public function plan(array $nodeArray, bool $checkRoot = false): ?array
71 {
72 if (count($nodeArray) == 0) {
73 return null;
74 }
75
76 $rootId = $nodeArray[0]['id'];
77
78 if (empty($rootId)) {
79 // new subtree or the root node was replaced
80 return null;
81 }
82
83 $rootId = (int) $rootId;
84
85 // load the stored tree, children of each parent in document order
86 $query = $this->pdo->prepare(
87 "SELECT xml.id, xml.id_parent, xml.type, xml.name, xml.value, xml.subhash
88 FROM {$this->table} AS xml
89 WHERE xml.id_doc = :doc_id
90 ORDER BY xml.id_parent ASC, xml.pos ASC",
91 );
92 $query->execute([
93 'doc_id' => $this->docId,
94 ]);
95
96 $oldRows = [];
97 $oldChildren = [];
98 while ($row = $query->fetchObject()) {
99 $oldRows[(int) $row->id] = $row;
100
101 if (!is_null($row->id_parent)) {
102 $oldChildren[(int) $row->id_parent][] = $row;
103 }
104 }
105 $query->closeCursor();
106
107 if (!isset($oldRows[$rootId])) {
108 return null;
109 }
110
111 if (
112 $checkRoot
113 && !$this->nodesCompatible($this->nodeContent($nodeArray[0]['node']), $oldRows[$rootId])
114 ) {
115 // the root node replaced the node its id points to instead of
116 // being a changed version of it
117 return null;
118 }
119
120 // number of stored nodes below the subtree root; the change limit
121 // is checked against the size of the patched subtree instead of
122 // the whole document (for a document patch these are the same)
123 $oldSubtree = 0;
124 $parents = [$rootId];
125 while (!empty($parents)) {
126 $parentId = array_shift($parents);
127 $oldSubtree++;
128
129 foreach ($oldChildren[$parentId] ?? [] as $childRow) {
130 $parents[] = (int) $childRow->id;
131 }
132 }
133
134 // children of every node of the new document
135 $newChildren = [];
136 foreach ($nodeArray as $index => $node) {
137 if (!is_null($node['parent_index'])) {
138 $newChildren[$node['parent_index']][] = $index;
139 }
140 }
141
142 // canonical content and subtree hashes of the new document; the
143 // nodes are in document order, so going backwards processes the
144 // children before their parents
145 $content = [];
146 $hashes = [];
147 for ($index = count($nodeArray) - 1; $index >= 0; $index--) {
148 $content[$index] = $this->nodeContent($nodeArray[$index]['node']);
149 $content[$index][2] = \Normalizer::normalize($content[$index][2]);
150
151 $childHashes = [];
152 foreach ($newChildren[$index] ?? [] as $childIndex) {
153 $childHashes[] = $hashes[$childIndex];
154 }
155
156 $hashes[$index] = NodeContent::hash($content[$index][0], $content[$index][1], $content[$index][2], $childHashes);
157 }
158
159 $plan = [
160 'root_id' => $rootId,
161 'deletes' => [],
162 'updates' => [],
163 'inserts' => [],
164 'renumber' => [],
165 'stats' => [
166 'identical' => 0,
167 'updates' => 0,
168 'deletes' => 0,
169 'inserts' => 0,
170 'renumber' => 0,
171 'old_nodes' => $oldSubtree,
172 'new_nodes' => count($nodeArray),
173 ],
174 ];
175
176 $queue = [[0, $rootId]];
177
178 while (!empty($queue)) {
179 [$index, $oldId] = array_shift($queue);
180
181 $oldRow = $oldRows[$oldId];
182
183 if (!is_null($oldRow->subhash) && $oldRow->subhash === $hashes[$index]) {
184 // this whole subtree is identical, do not descend
185 $plan['stats']['identical']++;
186 continue;
187 }
188
189 // compare the content of the pair; this also normalizes the
190 // attribute strings of nodes that were stored by older
191 // versions of the library (which did not add the trailing
192 // space after every attribute)
193 [$type, $name, $value] = $content[$index];
194
195 if ($oldRow->type != $type || (string) ($oldRow->name ?? '') !== (string) ($name ?? '') || $oldRow->value !== $value) {
196 $plan['updates'][] = [
197 'id' => $oldId,
198 'name' => $name,
199 'value' => $value,
200 ];
201 $plan['stats']['updates']++;
202 }
203
204 $newKids = $newChildren[$index] ?? [];
205 $oldKids = $oldChildren[$oldId] ?? [];
206
207 // match the children of this parent by database id first, the
208 // leftover nodes are matched by their relative position
209 $oldPosById = [];
210 foreach ($oldKids as $pos => $row) {
211 $oldPosById[(int) $row->id] = $pos;
212 }
213
214 $pairOfNew = [];
215 $usedOld = [];
216
217 foreach ($newKids as $newIndex) {
218 $id = $nodeArray[$newIndex]['id'];
219
220 // elements without a db:id have an empty id, other node
221 // types have no id at all
222 if (!empty($id) && isset($oldPosById[(int) $id]) && !isset($usedOld[$oldPosById[(int) $id]])) {
223 $pairOfNew[$newIndex] = $oldPosById[(int) $id];
224 $usedOld[$oldPosById[(int) $id]] = true;
225 }
226 }
227
228 // positional matching for the nodes without a (recognized)
229 // id; the first pair that is not compatible counts as
230 // replacement of one node by the other
231 $restNew = array_values(array_diff($newKids, array_keys($pairOfNew)));
232 $restOld = array_values(array_diff(array_keys($oldKids), array_keys($usedOld)));
233
234 $insertNew = [];
235 $deleteOld = [];
236
237 while (!empty($restNew) && !empty($restOld)) {
238 $newIndex = array_shift($restNew);
239 $oldPos = array_shift($restOld);
240
241 if ($this->nodesCompatible($content[$newIndex], $oldKids[$oldPos])) {
242 $pairOfNew[$newIndex] = $oldPos;
243 } else {
244 $insertNew[] = $newIndex;
245 $deleteOld[] = $oldPos;
246 }
247 }
248 $insertNew = array_merge($insertNew, $restNew);
249 $deleteOld = array_merge($deleteOld, $restOld);
250
251 foreach ($deleteOld as $oldPos) {
252 $plan['deletes'][] = (int) $oldKids[$oldPos]->id;
253 }
254 $plan['stats']['deletes'] += count($deleteOld);
255
256 // final order of the children: matched nodes keep their id,
257 // the unmatched new nodes are inserted at their places
258 $layout = [];
259 $survivorsNewOrder = [];
260
261 foreach ($newKids as $newIndex) {
262 if (isset($pairOfNew[$newIndex])) {
263 $childOldId = (int) $oldKids[$pairOfNew[$newIndex]]->id;
264
265 $layout[] = ['id' => $childOldId];
266 $survivorsNewOrder[] = $childOldId;
267 } else {
268 $layout[] = ['insert' => count($plan['inserts'])];
269
270 $plan['inserts'][] = [
271 'parent_id' => $oldId,
272 'node' => $nodeArray[$newIndex]['node'],
273 ];
274 }
275 }
276 $plan['stats']['inserts'] += count($insertNew);
277
278 $pairedOld = array_values($pairOfNew);
279
280 $survivorsStoredOrder = [];
281 foreach ($oldKids as $pos => $row) {
282 if (in_array($pos, $pairedOld)) {
283 $survivorsStoredOrder[] = (int) $row->id;
284 }
285 }
286
287 if (count($insertNew) > 0 || $survivorsNewOrder != $survivorsStoredOrder) {
288 $plan['renumber'][$oldId] = $layout;
289 $plan['stats']['renumber']++;
290 }
291
292 // the matched pairs are processed with the same procedure
293 foreach ($pairOfNew as $newIndex => $oldPos) {
294 $queue[] = [$newIndex, (int) $oldKids[$oldPos]->id];
295 }
296 }
297
298 // structural changes: when more than MAX_CHANGE_RATIO of the
299 // nodes of the subtree have to be inserted or deleted, a full
300 // save is cheaper and safer than patching; updated nodes only
301 // need one row write each and do not count into this
302 $changed = count($plan['inserts']) + count($plan['deletes']);
303
304 if ($changed > self::MAX_CHANGE_RATIO * max($oldSubtree, count($nodeArray))) {
305 return null;
306 }
307
308 return $plan;
309 }
310
315 protected function nodeContent($node): array
316 {
317 $content = NodeContent::fromDomNode($node, $this->dbNs, $this->idAttribute);
318 $content[2] = \Normalizer::normalize($content[2]);
319
320 return $content;
321 }
322
328 protected function nodesCompatible(array $content, $row): bool
329 {
330 return $content[0] == $row->type && (string) ($content[1] ?? '') === (string) ($row->name ?? '');
331 }
332}
333
334/* vim:set ft=php sw=4 sts=4 fdm=marker et : */
static hash($type, $name, $value, array $childHashes)
canonical hash over one node row and the hashes of its children; the "v1" prefix pins the canonicaliz...
static fromDomNode($node, XmlNs $dbNs, string $idAttribute)
returns the (type, name, value) tupel a DOM node is saved as, in exactly the same way saveNodeToDb st...
__construct( $pdo, protected string $table, protected $docId, protected XmlNs $dbNs, protected string $idAttribute='id')
const MAX_CHANGE_RATIO
when more than this ratio of the nodes of a subtree has to be inserted or deleted,...
nodesCompatible(array $content, $row)
whether a DOM node may take the place of a stored row, i.e.
plan(array $nodeArray, bool $checkRoot=false)
compares the nodes of $nodeArray with the nodes stored in the database and collects the minimal set o...
nodeContent($node)
returns the (type, name, value) tupel a node of the flattened array is stored as, normalized the same...
A namespace prefix and the URI it is bound to.
Definition XmlNs.php:9