1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Data\Util;
6:
7: use Atk4\Core\DebugTrait;
8: use Atk4\Data\Model;
9: use Atk4\Data\Reference\HasMany;
10: use Atk4\Data\Reference\HasOne;
11:
12: /**
13: * Implements deep copying records between two models:.
14: *
15: * $dc = new DeepCopy();
16: * $dc->from($user);
17: * $dc->to(new ArchivedUser());
18: * $dc->with('AuditLog');
19: * $dc->copy();
20: */
21: class DeepCopy
22: {
23: use DebugTrait;
24:
25: public const HOOK_AFTER_COPY = self::class . '@afterCopy';
26:
27: /** Model from which we want to copy records */
28: protected Model $source;
29:
30: /** Model into which we want to copy records */
31: protected Model $destination;
32:
33: /**
34: * Containing references which we need to copy.
35: * May contain sub-arrays: ['Invoices' => ['Lines']].
36: *
37: * @var array<int, string>|array<string, array<mixed>>
38: */
39: protected $references = [];
40:
41: /**
42: * Contains array similar to references but containing list of excluded fields:
43: * e.g. ['Invoices' => ['Lines' => ['vat_rate_id']]].
44: *
45: * @var array<int, string>|array<string, array<mixed>>
46: */
47: protected $exclusions = [];
48:
49: /**
50: * Contains array similar to references but containing list of callback methods to transform fields/values:
51: * e.g. ['Invoices' => ['Lines' => function (array $data) {
52: * $data['exchanged_amount'] = $data['amount'] * getExRate($data['date'], $data['currency']);
53: * return $data;
54: * }]].
55: *
56: * @var array<0, \Closure(array<string, mixed>): array<string, mixed>>|array<string, array<mixed>>
57: */
58: protected $transforms = [];
59:
60: /**
61: * While copying, will record mapped records in format [$table => [old ID => new ID]].
62: *
63: * @var array<string, array<mixed, mixed>>
64: */
65: public $mapping = [];
66:
67: /**
68: * Set model from which to copy records.
69: *
70: * @return $this
71: */
72: public function from(Model $source)
73: {
74: $this->source = $source;
75:
76: return $this;
77: }
78:
79: /**
80: * Set model in which to copy records into.
81: *
82: * @return $this
83: */
84: public function to(Model $destination)
85: {
86: $this->destination = $destination;
87:
88: if (!$this->destination->issetPersistence()) {
89: $this->destination->setPersistence($this->source->getModel()->getPersistence());
90: }
91:
92: return $this;
93: }
94:
95: /**
96: * Set references to copy.
97: *
98: * @param array<int, string>|array<string, array<mixed>> $references
99: *
100: * @return $this
101: */
102: public function with(array $references)
103: {
104: $this->references = $references;
105:
106: return $this;
107: }
108:
109: /**
110: * Specifies which fields shouldn't be copied. May also contain arrays
111: * for related entries.
112: * ->excluding(['name', 'address_id' => ['city']]);.
113: *
114: * @param array<int, string>|array<string, array<mixed>> $exclusions
115: *
116: * @return $this
117: */
118: public function excluding(array $exclusions)
119: {
120: $this->exclusions = $exclusions;
121:
122: return $this;
123: }
124:
125: /**
126: * Specifies which models data should be transformed while copying.
127: * May also contain arrays for related entries.
128: *
129: * ->transformData(
130: * [function (array $data) { // for Client entity
131: * $data['name'] => $data['last_name'] . ' ' . $data['first_name'];
132: * unset($data['first_name']);
133: * unset($data['last_name']);
134: * return $data;
135: * }],
136: * 'Invoices' => ['Lines' => function (array $data) { // for nested Client->Invoices->Lines hasMany entity
137: * $data['exchanged_amount'] = $data['amount'] * getExRate($data['date'], $data['currency']);
138: * return $data;
139: * }]
140: * );
141: *
142: * @param array<0, \Closure(array<string, mixed>): array<string, mixed>>|array<string, array<mixed>> $transforms
143: *
144: * @return $this
145: */
146: public function transformData(array $transforms)
147: {
148: $this->transforms = $transforms;
149:
150: return $this;
151: }
152:
153: /**
154: * Will extract non-numeric keys from the array.
155: *
156: * @param array<int, string>|array<string, array<mixed>> $array
157: *
158: * @return array<string, array<int, string>>
159: */
160: protected function extractKeys(array $array): array
161: {
162: $result = [];
163: foreach ($array as $key => $val) {
164: if (is_int($key)) {
165: $result[$val] = [];
166: } else {
167: $result[$key] = $val;
168: }
169: }
170:
171: return $result;
172: }
173:
174: /**
175: * Copy records.
176: */
177: public function copy(): Model
178: {
179: return $this->destination->atomic(function () {
180: return $this->_copy(
181: $this->source,
182: $this->destination,
183: $this->references,
184: $this->exclusions,
185: $this->transforms
186: )->reload(); // TODO reload should not be needed
187: });
188: }
189:
190: /**
191: * Internal method for copying records.
192: *
193: * @param array<int, string>|array<string, array<mixed>> $references
194: * @param array<int, string>|array<string, array<mixed>> $exclusions
195: * @param array<0, \Closure(array<string, mixed>): array<string, mixed>>|array<string, array<mixed>> $transforms
196: *
197: * @return Model Destination model
198: */
199: protected function _copy(Model $source, Model $destination, array $references, array $exclusions, array $transforms): Model
200: {
201: try {
202: // perhaps source was already copied, then simply load destination model and return
203: if (isset($this->mapping[$source->table]) && isset($this->mapping[$source->table][$source->getId()])) {
204: $this->debug('Skipping ' . get_class($source));
205:
206: $destination = $destination->load($this->mapping[$source->table][$source->getId()]);
207: } else {
208: $this->debug('Copying ' . get_class($source));
209:
210: $data = $source->get();
211:
212: // exclude not needed field values
213: // see self::excluding()
214: foreach ($this->extractKeys($exclusions) as $key => $val) {
215: unset($data[$key]);
216: }
217:
218: // do data transformation from source to destination
219: // see self::transformData()
220: if (isset($transforms[0])) {
221: $data = $transforms[0]($data);
222: }
223:
224: // TODO add a way here to look for duplicates based on unique fields
225: // foreach ($destination->unique fields) { try load by
226:
227: // if we still have id field, then remove it
228: unset($data[$source->idField]);
229:
230: // copy fields as they are
231: $destination = $destination->createEntity();
232: foreach ($data as $key => $val) {
233: if ($destination->hasField($key) && $destination->getField($key)->isEditable()) {
234: $destination->set($key, $val);
235: }
236: }
237: }
238: $destination->hook(self::HOOK_AFTER_COPY, [$source]);
239:
240: // make sure references with hasOne can be mapped or copy them
241: foreach ($this->extractKeys($references) as $refKey => $refVal) {
242: $this->debug('Considering ' . $refKey);
243:
244: if ($source->hasReference($refKey) && $source->getModel(true)->getReference($refKey) instanceof HasOne) {
245: $this->debug('Proceeding with ' . $refKey);
246:
247: // load destination model through $source
248: $sourceTable = $source->refModel($refKey)->table;
249:
250: if (isset($this->mapping[$sourceTable])
251: && array_key_exists($source->get($refKey), $this->mapping[$sourceTable])
252: ) {
253: // no need to deep copy, simply alter ID
254: $destination->set($refKey, $this->mapping[$sourceTable][$source->get($refKey)]);
255: $this->debug(' already copied ' . $source->get($refKey) . ' as ' . $destination->get($refKey));
256: } else {
257: // hasOne points to null!
258: $this->debug('Value is ' . $source->get($refKey));
259: if (!$source->get($refKey)) {
260: $destination->set($refKey, $source->get($refKey));
261:
262: continue;
263: }
264:
265: // pointing to non-existent record. Would need to copy
266: try {
267: $destination->set(
268: $refKey,
269: $this->_copy(
270: $source->ref($refKey),
271: $destination->refModel($refKey),
272: $refVal,
273: $exclusions[$refKey] ?? [],
274: $transforms[$refKey] ?? []
275: )->getId()
276: );
277: $this->debug(' ... mapped into ' . $destination->get($refKey));
278: } catch (DeepCopyException $e) {
279: $this->debug('escalating a problem from ' . $refKey);
280:
281: throw $e->addDepth($refKey);
282: }
283: }
284: }
285: }
286:
287: // next copy our own data
288: $destination->save();
289:
290: // store mapping
291: $this->mapping[$source->table][$source->getId()] = $destination->getId();
292: $this->debug(' .. copied ' . get_class($source) . ' ' . $source->getId() . ' ' . $destination->getId());
293:
294: // next look for hasMany relationships and copy those too
295:
296: foreach ($this->extractKeys($references) as $refKey => $refVal) {
297: if ($source->hasReference($refKey) && $source->getModel(true)->getReference($refKey) instanceof HasMany) {
298: // no mapping, will always copy
299: foreach ($source->ref($refKey) as $refModel) {
300: $this->_copy(
301: $refModel,
302: $destination->ref($refKey),
303: $refVal,
304: $exclusions[$refKey] ?? [],
305: $transforms[$refKey] ?? []
306: );
307: }
308: }
309: }
310:
311: return $destination;
312: } catch (DeepCopyException $e) {
313: throw $e;
314: } catch (\Exception $e) {
315: $this->debug('model copy failed');
316:
317: throw (new DeepCopyException('Model copy failed', 0, $e))
318: ->addMoreInfo('source', $source)
319: ->addMoreInfo('source_info', $source->__debugInfo())
320: ->addMoreInfo('source_data', $source->get())
321: ->addMoreInfo('destination', $destination)
322: ->addMoreInfo('destination_info', $destination->__debugInfo());
323: }
324: }
325: }
326: