1<?php
2
3namespace Icinga\Module\Director\Objects;
4
5use Icinga\Application\Benchmark;
6use Icinga\Exception\NotFoundError;
7use Icinga\Module\Director\Data\Db\DbObjectWithSettings;
8use Icinga\Module\Director\Db;
9use Icinga\Module\Director\DirectorObject\Automation\ExportInterface;
10use Icinga\Module\Director\Exception\DuplicateKeyException;
11use Icinga\Module\Director\Hook\PropertyModifierHook;
12use Icinga\Module\Director\Import\Import;
13use Icinga\Module\Director\Import\SyncUtils;
14use InvalidArgumentException;
15use Exception;
16
17class ImportSource extends DbObjectWithSettings implements ExportInterface
18{
19    protected $table = 'import_source';
20
21    protected $keyName = 'source_name';
22
23    protected $autoincKeyName = 'id';
24
25    protected $protectAutoinc = false;
26
27    protected $defaultProperties = [
28        'id'                 => null,
29        'source_name'        => null,
30        'provider_class'     => null,
31        'key_column'         => null,
32        'import_state'       => 'unknown',
33        'last_error_message' => null,
34        'last_attempt'       => null,
35        'description'        => null,
36    ];
37
38    protected $stateProperties = [
39        'import_state',
40        'last_error_message',
41        'last_attempt',
42    ];
43
44    protected $settingsTable = 'import_source_setting';
45
46    protected $settingsRemoteId = 'source_id';
47
48    private $rowModifiers;
49
50    private $newRowModifiers;
51
52    /**
53     * @return \stdClass
54     */
55    public function export()
56    {
57        $plain = $this->getProperties();
58        $plain['originalId'] = $plain['id'];
59        unset($plain['id']);
60
61        foreach ($this->stateProperties as $key) {
62            unset($plain[$key]);
63        }
64
65        $plain['settings'] = (object) $this->getSettings();
66        $plain['modifiers'] = $this->exportRowModifiers();
67        ksort($plain);
68
69        return (object) $plain;
70    }
71
72    /**
73     * @param $plain
74     * @param Db $db
75     * @param bool $replace
76     * @return ImportSource
77     * @throws DuplicateKeyException
78     * @throws NotFoundError
79     */
80    public static function import($plain, Db $db, $replace = false)
81    {
82        $properties = (array) $plain;
83        if (isset($properties['originalId'])) {
84            $id = $properties['originalId'];
85            unset($properties['originalId']);
86        } else {
87            $id = null;
88        }
89        $name = $properties['source_name'];
90
91        if ($replace && static::existsWithNameAndId($name, $id, $db)) {
92            $object = static::loadWithAutoIncId($id, $db);
93        } elseif ($replace && static::exists($name, $db)) {
94            $object = static::load($name, $db);
95        } elseif (static::existsWithName($name, $db)) {
96            throw new DuplicateKeyException(
97                'Import Source %s already exists',
98                $name
99            );
100        } else {
101            $object = static::create([], $db);
102        }
103
104        $object->newRowModifiers = $properties['modifiers'];
105        unset($properties['modifiers']);
106        $object->setProperties($properties);
107        if ($id !== null) {
108            $object->reallySet('id', $id);
109        }
110
111        return $object;
112    }
113
114    public function getUniqueIdentifier()
115    {
116        return $this->get('source_name');
117    }
118
119    /**
120     * @param $name
121     * @param Db $connection
122     * @return ImportSource
123     * @throws NotFoundError
124     */
125    public static function loadByName($name, Db $connection)
126    {
127        $db = $connection->getDbAdapter();
128        $properties = $db->fetchRow(
129            $db->select()->from('import_source')->where('source_name = ?', $name)
130        );
131        if ($properties === false) {
132            throw new NotFoundError(sprintf(
133                'There is no such Import Source: "%s"',
134                $name
135            ));
136        }
137
138        return static::create([], $connection)->setDbProperties($properties);
139    }
140
141    public static function existsWithName($name, Db $connection)
142    {
143        $db = $connection->getDbAdapter();
144
145        return (string) $name === (string) $db->fetchOne(
146            $db->select()
147                ->from('import_source', 'source_name')
148                ->where('source_name = ?', $name)
149        );
150    }
151
152    /**
153     * @param string $name
154     * @param int $id
155     * @param Db $connection
156     * @api internal
157     * @return bool
158     */
159    protected static function existsWithNameAndId($name, $id, Db $connection)
160    {
161        $db = $connection->getDbAdapter();
162        $dummy = new static;
163        $idCol = $dummy->autoincKeyName;
164        $keyCol = $dummy->keyName;
165
166        return (string) $id === (string) $db->fetchOne(
167            $db->select()
168                ->from($dummy->table, $idCol)
169                ->where("$idCol = ?", $id)
170                ->where("$keyCol = ?", $name)
171        );
172    }
173
174    protected function exportRowModifiers()
175    {
176        $modifiers = [];
177        foreach ($this->fetchRowModifiers() as $modifier) {
178            $modifiers[] = $modifier->export();
179        }
180
181        return $modifiers;
182    }
183
184    /**
185     * @param bool $required
186     * @return ImportRun|null
187     * @throws NotFoundError
188     */
189    public function fetchLastRun($required = false)
190    {
191        return $this->fetchLastRunBefore(time() + 1, $required);
192    }
193
194    /**
195     * @throws DuplicateKeyException
196     */
197    protected function onStore()
198    {
199        parent::onStore();
200        if ($this->newRowModifiers !== null) {
201            $connection = $this->getConnection();
202            $db = $connection->getDbAdapter();
203            $myId = $this->get('id');
204            if ($this->hasBeenLoadedFromDb()) {
205                $db->delete(
206                    'import_row_modifier',
207                    $db->quoteInto('source_id = ?', $myId)
208                );
209            }
210
211            foreach ($this->newRowModifiers as $modifier) {
212                $modifier = ImportRowModifier::create((array) $modifier, $connection);
213                $modifier->set('source_id', $myId);
214                $modifier->store();
215            }
216        }
217    }
218
219    /**
220     * @param $timestamp
221     * @param bool $required
222     * @return ImportRun|null
223     * @throws NotFoundError
224     */
225    public function fetchLastRunBefore($timestamp, $required = false)
226    {
227        if (! $this->hasBeenLoadedFromDb()) {
228            return $this->nullUnlessRequired($required);
229        }
230
231        if ($timestamp === null) {
232            $timestamp = time();
233        }
234
235        $db = $this->getDb();
236        $query = $db->select()->from(
237            ['ir' => 'import_run'],
238            'ir.id'
239        )->where('ir.source_id = ?', $this->get('id'))
240        ->where('ir.start_time < ?', date('Y-m-d H:i:s', $timestamp))
241        ->order('ir.start_time DESC')
242        ->limit(1);
243
244        $runId = $db->fetchOne($query);
245
246        if ($runId) {
247            return ImportRun::load($runId, $this->getConnection());
248        } else {
249            return $this->nullUnlessRequired($required);
250        }
251    }
252
253    /**
254     * @param $required
255     * @return null
256     * @throws NotFoundError
257     */
258    protected function nullUnlessRequired($required)
259    {
260        if ($required) {
261            throw new NotFoundError(
262                'No data has been imported for "%s" yet',
263                $this->get('source_name')
264            );
265        }
266
267        return null;
268    }
269
270    public function applyModifiers(& $data)
271    {
272        $modifiers = $this->fetchFlatRowModifiers();
273
274        if (empty($modifiers)) {
275            return $this;
276        }
277
278
279        foreach ($modifiers as $modPair) {
280            /** @var PropertyModifierHook $modifier */
281            list($property, $modifier) = $modPair;
282            $rejected = [];
283            foreach ($data as $key => $row) {
284                $this->applyPropertyModifierToRow($modifier, $property, $row);
285                if ($modifier->rejectsRow()) {
286                    $rejected[] = $key;
287                    $modifier->rejectRow(false);
288                }
289            }
290
291            foreach ($rejected as $key) {
292                unset($data[$key]);
293            }
294        }
295
296        return $this;
297    }
298
299    public function getObjectName()
300    {
301        return $this->get('source_name');
302    }
303
304    public static function getKeyColumnName()
305    {
306        return 'source_name';
307    }
308
309    protected function applyPropertyModifierToRow(PropertyModifierHook $modifier, $key, $row)
310    {
311        if ($modifier->requiresRow()) {
312            $modifier->setRow($row);
313        }
314
315        if (property_exists($row, $key)) {
316            $value = $row->$key;
317        } elseif (strpos($key, '.') !== false) {
318            $value = SyncUtils::getSpecificValue($row, $key);
319        } else {
320            $value = null;
321        }
322
323        $target = $modifier->getTargetProperty($key);
324        if (strpos($target, '.') !== false) {
325            throw new InvalidArgumentException(
326                'Cannot set value for nested key "%s"',
327                $target
328            );
329        }
330
331        if (is_array($value) && ! $modifier->hasArraySupport()) {
332            $new = [];
333            foreach ($value as $k => $v) {
334                $new[$k] = $modifier->transform($v);
335            }
336            $row->$target = $new;
337        } else {
338            $row->$target = $modifier->transform($value);
339        }
340    }
341
342    public function getRowModifiers()
343    {
344        if ($this->rowModifiers === null) {
345            $this->prepareRowModifiers();
346        }
347
348        return $this->rowModifiers;
349    }
350
351    public function hasRowModifiers()
352    {
353        return count($this->getRowModifiers()) > 0;
354    }
355
356    /**
357     * @return ImportRowModifier[]
358     */
359    public function fetchRowModifiers()
360    {
361        $db = $this->getDb();
362
363        $modifiers = ImportRowModifier::loadAll(
364            $this->getConnection(),
365            $db->select()
366               ->from('import_row_modifier')
367               ->where('source_id = ?', $this->get('id'))
368               ->order('priority ASC')
369        );
370
371        return $modifiers;
372    }
373
374    protected function fetchFlatRowModifiers()
375    {
376        $mods = [];
377        foreach ($this->fetchRowModifiers() as $mod) {
378            $mods[] = [$mod->get('property_name'), $mod->getInstance()];
379        }
380
381        return $mods;
382    }
383
384    protected function prepareRowModifiers()
385    {
386        $modifiers = [];
387
388        foreach ($this->fetchRowModifiers() as $mod) {
389            $name = $mod->get('property_name');
390            if (! array_key_exists($name, $modifiers)) {
391                $modifiers[$name] = [];
392            }
393
394            $modifiers[$name][] = $mod->getInstance();
395        }
396
397        $this->rowModifiers = $modifiers;
398    }
399
400    public function listModifierTargetProperties()
401    {
402        $list = [];
403        foreach ($this->getRowModifiers() as $rowMods) {
404            /** @var PropertyModifierHook $mod */
405            foreach ($rowMods as $mod) {
406                if ($mod->hasTargetProperty()) {
407                    $list[$mod->getTargetProperty()] = true;
408                }
409            }
410        }
411
412        return array_keys($list);
413    }
414
415    /**
416     * @param bool $runImport
417     * @return bool
418     * @throws DuplicateKeyException
419     */
420    public function checkForChanges($runImport = false)
421    {
422        $hadChanges = false;
423
424        $name = $this->get('source_name');
425        Benchmark::measure("Starting with import $name");
426        try {
427            $import = new Import($this);
428            $this->set('last_attempt', date('Y-m-d H:i:s'));
429            if ($import->providesChanges()) {
430                Benchmark::measure("Found changes for $name");
431                $hadChanges = true;
432                $this->set('import_state', 'pending-changes');
433
434                if ($runImport && $import->run()) {
435                    Benchmark::measure("Import succeeded for $name");
436                    $this->set('import_state', 'in-sync');
437                }
438            } else {
439                $this->set('import_state', 'in-sync');
440            }
441
442            $this->set('last_error_message', null);
443        } catch (Exception $e) {
444            $this->set('import_state', 'failing');
445            Benchmark::measure("Import failed for $name");
446            $this->set('last_error_message', $e->getMessage());
447        }
448
449        if ($this->hasBeenModified()) {
450            $this->store();
451        }
452
453        return $hadChanges;
454    }
455
456    /**
457     * @return bool
458     * @throws DuplicateKeyException
459     */
460    public function runImport()
461    {
462        return $this->checkForChanges(true);
463    }
464}
465