Passed
Push — master ( e0c5e8...7926db )
by P.R.
04:00
created

MySqlRoutineLoaderWorker::getDuplicates()   A

Complexity

Conditions 6
Paths 12

Size

Total Lines 30
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 6.105

Importance

Changes 0
Metric Value
cc 6
eloc 13
nc 12
nop 0
dl 0
loc 30
ccs 12
cts 14
cp 0.8571
crap 6.105
rs 9.2222
c 0
b 0
f 0
1
<?php
2
declare(strict_types=1);
3
4
namespace SetBased\Stratum\MySql\Backend;
5
6
use SetBased\Exception\RuntimeException;
7
use SetBased\Stratum\Backend\RoutineLoaderWorker;
8
use SetBased\Stratum\Common\Exception\RoutineLoaderException;
9
use SetBased\Stratum\Common\Helper\SourceFinderHelper;
10
use SetBased\Stratum\Middle\Exception\ResultException;
11
use SetBased\Stratum\Middle\Helper\RowSetHelper;
12
use SetBased\Stratum\Middle\NameMangler\NameMangler;
13
use SetBased\Stratum\MySql\Exception\MySqlConnectFailedException;
14
use SetBased\Stratum\MySql\Exception\MySqlDataLayerException;
15
use SetBased\Stratum\MySql\Exception\MySqlQueryErrorException;
16
use SetBased\Stratum\MySql\Helper\RoutineLoaderHelper;
17
use Symfony\Component\Console\Formatter\OutputFormatter;
18
19
/**
20
 * Command for loading stored routines into a MySQL instance from pseudo SQL files.
21
 */
22
class MySqlRoutineLoaderWorker extends MySqlWorker implements RoutineLoaderWorker
23
{
24
  //--------------------------------------------------------------------------------------------------------------------
25
  /**
26
   * The maximum column size in bytes.
27
   *
28
   */
29
  const MAX_COLUMN_SIZE = 65532;
30
31
  /**
32
   * Details of all character sets.
33
   *
34
   * @var array[]
35
   */
36
  private $characterSets;
37
38
  /**
39
   * Name of the class that contains all constants.
40
   *
41
   * @var string
42
   */
43
  private $constantClassName;
44
45
  /**
46
   * The default character set under which the stored routine will be loaded and run.
47
   *
48
   * @var string
49
   */
50
  private $defaultCharacterSet;
51
52
  /**
53
   * The default collate under which the stored routine will be loaded and run.
54
   *
55
   * @var string
56
   */
57
  private $defaultCollate;
58
59
  /**
60
   * An array with source filenames that are not loaded into MySQL.
61
   *
62
   * @var array
63
   */
64
  private $errorFilenames = [];
65
66
  /**
67
   * Class name for mangling routine and parameter names.
68
   *
69
   * @var string
70
   */
71
  private $nameMangler;
72
73
  /**
74
   * The metadata of all stored routines. Note: this data is stored in the metadata file and is generated by PhpStratum.
75
   *
76
   * @var array
77
   */
78
  private $phpStratumMetadata;
79
80
  /**
81
   * The filename of the file with the metadata of all stored routines.
82
   *
83
   * @var string
84
   */
85
  private $phpStratumMetadataFilename;
86
87
  /**
88
   * Old metadata of all stored routines. Note: this data comes from information_schema.ROUTINES.
89
   *
90
   * @var array
91
   */
92
  private $rdbmsOldMetadata;
93
94
  /**
95
   * A map from placeholders to their actual values.
96
   *
97
   * @var array
98
   */
99
  private $replacePairs = [];
100
101
  /**
102
   * Pattern where of the sources files.
103
   *
104
   * @var string
105
   */
106
  private $sourcePattern;
107
108
  /**
109
   * All sources with stored routines. Each element is an array with the following keys:
110
   * <ul>
111
   * <li> path_name    The path the source file.
112
   * <li> routine_name The name of the routine (equals the basename of the path).
113
   * <li> method_name  The name of the method in the data layer for the wrapper method of the stored routine.
114
   * </ul>
115
   *
116
   * @var array[]
117
   */
118
  private $sources = [];
119
120
  /**
121
   * The SQL mode under which the stored routine will be loaded and run.
122
   *
123
   * @var string
124
   */
125
  private $sqlMode;
126
127
  //--------------------------------------------------------------------------------------------------------------------
128
  /**
129
   * @inheritdoc
130
   *
131
   * @throws MySqlDataLayerException
132
   * @throws MySqlQueryErrorException
133
   * @throws RuntimeException
134
   * @throws MySqlConnectFailedException
135
   * @throws \ReflectionException
136
   */
137 1
  public function execute(?array $sources = null): int
138
  {
139 1
    $this->io->title('PhpStratum: MySql Loader');
140
141 1
    $this->phpStratumMetadataFilename = $this->settings->manString('loader.metadata');
142 1
    $this->sourcePattern              = $this->settings->manString('loader.sources');
143 1
    $this->sqlMode                    = $this->settings->manString('loader.sql_mode');
144 1
    $this->defaultCharacterSet        = $this->settings->manString('loader.character_set');
145 1
    $this->defaultCollate             = $this->settings->manString('loader.collate');
146 1
    $this->constantClassName          = $this->settings->optString('constants.class');
147 1
    $this->nameMangler                = $this->settings->optString('wrapper.mangler_class');
148
149 1
    $this->connect();
150
151 1
    $this->characterSets = $this->dl->allCharacterSets();
152
153 1
    if (empty($sources))
154
    {
155 1
      $this->loadAll();
156
    }
157
    else
158
    {
159
      $this->loadList($sources);
160
    }
161
162 1
    $this->logOverviewErrors();
163
164 1
    $this->disconnect();
165
166 1
    return ($this->errorFilenames) ? 1 : 0;
167
  }
168
169
  //--------------------------------------------------------------------------------------------------------------------
170
  /**
171
   * Detects stored routines that would result in duplicate wrapper method name.
172
   */
173 1
  private function detectNameConflicts(): void
174
  {
175
    // Get same method names from array
176 1
    [$sources_by_path, $sources_by_method] = $this->getDuplicates();
177
178
    // Add every not unique method name to myErrorFileNames
179 1
    foreach ($sources_by_path as $source)
180
    {
181
      $this->errorFilenames[] = $source['path_name'];
182
    }
183
184
    // Log the sources files with duplicate method names.
185 1
    foreach ($sources_by_method as $method => $sources)
186
    {
187
      $tmp = [];
188
      foreach ($sources as $source)
189
      {
190
        $tmp[] = $source['path_name'];
191
      }
192
193
      $this->io->error(sprintf("The following source files would result wrapper methods with equal name '%s'",
194
                               $method));
195
      $this->io->listing($tmp);
196
    }
197
198
    // Remove duplicates from mySources.
199 1
    foreach ($this->sources as $i => $source)
200
    {
201 1
      if (isset($sources_by_path[$source['path_name']]))
202
      {
203
        unset($this->sources[$i]);
204
      }
205
    }
206 1
  }
207
208
  //--------------------------------------------------------------------------------------------------------------------
209
  /**
210
   * Drops obsolete stored routines (i.e. stored routines that exits in the current schema but for which we don't have
211
   * a source file).
212
   *
213
   * @throws MySqlQueryErrorException
214
   */
215 1
  private function dropObsoleteRoutines(): void
216
  {
217
    // Make a lookup table from routine name to source.
218 1
    $lookup = [];
219 1
    foreach ($this->sources as $source)
220
    {
221 1
      $lookup[$source['routine_name']] = $source;
222
    }
223
224
    // Drop all routines not longer in sources.
225 1
    foreach ($this->rdbmsOldMetadata as $old_routine)
226
    {
227 1
      if (!isset($lookup[$old_routine['routine_name']]))
228
      {
229
        $this->io->logInfo('Dropping %s <dbo>%s</dbo>',
230
                           strtolower($old_routine['routine_type']),
231
                           $old_routine['routine_name']);
232
233
        $this->dl->dropRoutine($old_routine['routine_type'], $old_routine['routine_name']);
234
      }
235
    }
236 1
  }
237
238
  //--------------------------------------------------------------------------------------------------------------------
239
  /**
240
   * Searches recursively for all source files.
241
   */
242 1
  private function findSourceFiles(): void
243
  {
244 1
    $helper    = new SourceFinderHelper(dirname($this->settings->manString('stratum.config_path')));
245 1
    $filenames = $helper->findSources($this->sourcePattern);
246
247 1
    foreach ($filenames as $filename)
248
    {
249 1
      $routineName     = pathinfo($filename, PATHINFO_FILENAME);
250 1
      $this->sources[] = ['path_name'    => $filename,
251 1
                          'routine_name' => $routineName,
252 1
                          'method_name'  => $this->methodName($routineName)];
253
    }
254 1
  }
255
256
  //--------------------------------------------------------------------------------------------------------------------
257
  /**
258
   * Finds all source files that actually exists from a list of file names.
259
   *
260
   * @param string[] $sources The list of file names.
261
   */
262
  private function findSourceFilesFromList(array $sources): void
263
  {
264
    foreach ($sources as $path)
265
    {
266
      if (!file_exists($path))
267
      {
268
        $this->io->error(sprintf("File not exists: '%s'", $path));
269
        $this->errorFilenames[] = $path;
270
      }
271
      else
272
      {
273
        $routineName     = pathinfo($path, PATHINFO_FILENAME);
274
        $this->sources[] = ['path_name'    => $path,
275
                            'routine_name' => $routineName,
276
                            'method_name'  => $this->methodName($routineName)];
277
      }
278
    }
279
  }
280
281
  //--------------------------------------------------------------------------------------------------------------------
282
  /**
283
   * Gets the SQL mode in the order as preferred by MySQL.
284
   *
285
   * @throws MySqlQueryErrorException
286
   * @throws ResultException
287
   */
288 1
  private function getCorrectSqlMode(): void
289
  {
290 1
    $this->sqlMode = $this->dl->correctSqlMode($this->sqlMode);
291 1
  }
292
293
  //--------------------------------------------------------------------------------------------------------------------
294
  /**
295
   * Returns all elements in {@link $sources} with duplicate method names.
296
   *
297
   * @return array[]
298
   */
299 1
  private function getDuplicates(): array
300
  {
301
    // First pass make lookup table by method_name.
302 1
    $lookup = [];
303 1
    foreach ($this->sources as $source)
304
    {
305 1
      if (isset($source['method_name']))
306
      {
307 1
        if (!isset($lookup[$source['method_name']]))
308
        {
309 1
          $lookup[$source['method_name']] = [];
310
        }
311
312 1
        $lookup[$source['method_name']][] = $source;
313
      }
314
    }
315
316
    // Second pass find duplicate sources.
317 1
    $duplicates_sources = [];
318 1
    $duplicates_methods = [];
319 1
    foreach ($this->sources as $source)
320
    {
321 1
      if (sizeof($lookup[$source['method_name']])>1)
322
      {
323
        $duplicates_sources[$source['path_name']]   = $source;
324
        $duplicates_methods[$source['method_name']] = $lookup[$source['method_name']];
325
      }
326
    }
327
328 1
    return [$duplicates_sources, $duplicates_methods];
329
  }
330
331
  //--------------------------------------------------------------------------------------------------------------------
332
  /**
333
   * Retrieves information about all stored routines in the current schema.
334
   *
335
   * @throws MySqlQueryErrorException
336
   */
337 1
  private function getOldStoredRoutinesInfo(): void
338
  {
339 1
    $this->rdbmsOldMetadata = [];
340
341 1
    $routines = $this->dl->allRoutines();
342 1
    foreach ($routines as $routine)
343
    {
344 1
      $this->rdbmsOldMetadata[$routine['routine_name']] = $routine;
345
    }
346 1
  }
347
348
  //--------------------------------------------------------------------------------------------------------------------
349
  /**
350
   * Loads all stored routines into MySQL.
351
   *
352
   * @throws MySqlQueryErrorException
353
   * @throws RuntimeException
354
   * @throws \ReflectionException
355
   */
356 1
  private function loadAll(): void
357
  {
358 1
    $this->findSourceFiles();
359 1
    $this->detectNameConflicts();
360 1
    $this->replacePairs();
361 1
    $this->readStoredRoutineMetadata();
362 1
    $this->getOldStoredRoutinesInfo();
363 1
    $this->getCorrectSqlMode();
364
365 1
    $this->loadStoredRoutines();
366
367
    // Drop obsolete stored routines.
368 1
    $this->dropObsoleteRoutines();
369
370
    // Remove metadata of stored routines that have been removed.
371 1
    $this->removeObsoleteMetadata();
372
373 1
    $this->io->writeln('');
374
375
    // Write the metadata to file.
376 1
    $this->writeStoredRoutineMetadata();
377 1
  }
378
  //--------------------------------------------------------------------------------------------------------------------
379
  /**
380
   * Loads all stored routines in a list into MySQL.
381
   *
382
   * @param string[] $sources The list of files to be loaded.
383
   *
384
   * @throws MySqlQueryErrorException
385
   * @throws RuntimeException
386
   * @throws \ReflectionException
387
   */
388
  private function loadList(array $sources): void
389
  {
390
    $this->findSourceFilesFromList($sources);
391
    $this->detectNameConflicts();
392
    $this->replacePairs();
393
    $this->readStoredRoutineMetadata();
394
    $this->getOldStoredRoutinesInfo();
395
    $this->getCorrectSqlMode();
396
397
    $this->loadStoredRoutines();
398
399
    // Write the metadata to file.
400
    $this->writeStoredRoutineMetadata();
401
  }
402
403
  //--------------------------------------------------------------------------------------------------------------------
404
  /**
405
   * Loads all stored routines.
406
   *
407
   * @throws ResultException
408
   */
409 1
  private function loadStoredRoutines(): void
410
  {
411
    // Log an empty line.
412 1
    $this->io->writeln('');
413
414
    // Sort the sources by routine name.
415 1
    usort($this->sources, function ($a, $b) {
416 1
      return strcmp($a['routine_name'], $b['routine_name']);
417 1
    });
418
419
    // Process all sources.
420 1
    foreach ($this->sources as $filename)
421
    {
422 1
      $routineName = $filename['routine_name'];
423
424 1
      $helper = new RoutineLoaderHelper($this->dl,
425 1
                                        $this->io,
426 1
                                        $filename['path_name'],
427 1
                                        $this->phpStratumMetadata[$routineName] ?? [],
428 1
                                        $this->replacePairs,
429 1
                                        $this->rdbmsOldMetadata[$routineName] ?? [],
430 1
                                        $this->sqlMode,
431 1
                                        $this->defaultCharacterSet,
432 1
                                        $this->defaultCollate);
433
434
      try
435
      {
436 1
        $this->phpStratumMetadata[$routineName] = $helper->loadStoredRoutine();
437
      }
438
      catch (RoutineLoaderException $e)
439
      {
440
        $messages = [$e->getMessage(),
441
                     sprintf("Failed to load file '%s'", $filename['path_name'])];
442
        $this->io->error($messages);
443
444
        $this->errorFilenames[] = $filename['path_name'];
445
        unset($this->phpStratumMetadata[$routineName]);
446
      }
447
      catch (MySqlQueryErrorException $e)
448
      {
449
        // Exception is caused by a SQL error. Log the message and the SQL statement with highlighting the error.
450
        $this->io->error($e->getMessage());
451
        $this->io->text($e->styledQuery());
452
      }
453
      catch (MySqlDataLayerException $e)
454
      {
455
        $this->io->error($e->getMessage());
456
      }
457
    }
458 1
  }
459
460
  //--------------------------------------------------------------------------------------------------------------------
461
  /**
462
   * Logs the source files that were not successfully loaded into MySQL.
463
   */
464 1
  private function logOverviewErrors(): void
465
  {
466 1
    if (!empty($this->errorFilenames))
467
    {
468
      $this->io->warning('Routines in the files below are not loaded:');
469
      $this->io->listing($this->errorFilenames);
470
    }
471 1
  }
472
473
  //--------------------------------------------------------------------------------------------------------------------
474
  /**
475
   * Returns the maximum number of characters in a VARCHAR or CHAR.
476
   *
477
   * @param string $characterSetName The name of the character set of the column.
478
   *
479
   * @return int
480
   */
481 1
  private function maxCharacters(string $characterSetName): ?int
482
  {
483 1
    $key = RowSetHelper::searchInRowSet($this->characterSets, 'character_set_name', $characterSetName);
484 1
    if ($key===null) return null;
485
486 1
    $size = $this->characterSets[$key]['maxlen'];
487
488 1
    return (int)floor(self::MAX_COLUMN_SIZE  / $size);
489
  }
490
491
  //--------------------------------------------------------------------------------------------------------------------
492
  /**
493
   * Returns the method name in the wrapper for a stored routine. Returns null when name mangler is not set.
494
   *
495
   * @param string $routineName The name of the routine.
496
   *
497
   * @return null|string
498
   */
499 1
  private function methodName(string $routineName): ?string
500
  {
501 1
    if ($this->nameMangler!==null)
502
    {
503
      /** @var NameMangler $mangler */
504 1
      $mangler = $this->nameMangler;
505
506 1
      return $mangler::getMethodName($routineName);
507
    }
508
509
    return null;
510
  }
511
512
  //--------------------------------------------------------------------------------------------------------------------
513
  /**
514
   * Reads the metadata of stored routines from the metadata file.
515
   *
516
   * @throws RuntimeException
517
   */
518 1
  private function readStoredRoutineMetadata(): void
519
  {
520 1
    if (file_exists($this->phpStratumMetadataFilename))
521
    {
522 1
      $this->phpStratumMetadata = (array)json_decode(file_get_contents($this->phpStratumMetadataFilename), true);
523 1
      if (json_last_error()!=JSON_ERROR_NONE)
524
      {
525
        throw new RuntimeException("Error decoding JSON: '%s'.", json_last_error_msg());
526
      }
527
    }
528 1
  }
529
530
  //--------------------------------------------------------------------------------------------------------------------
531
  /**
532
   * Removes obsolete entries from the metadata of all stored routines.
533
   */
534 1
  private function removeObsoleteMetadata(): void
535
  {
536 1
    $clean = [];
537 1
    foreach ($this->sources as $source)
538
    {
539 1
      $routineName = $source['routine_name'];
540 1
      if (isset($this->phpStratumMetadata[$routineName]))
541
      {
542 1
        $clean[$routineName] = $this->phpStratumMetadata[$routineName];
543
      }
544
    }
545
546 1
    $this->phpStratumMetadata = $clean;
547 1
  }
548
549
  //--------------------------------------------------------------------------------------------------------------------
550
  /**
551
   * Gathers all replace pairs.
552
   *
553
   * @throws \ReflectionException
554
   * @throws MySqlQueryErrorException
555
   */
556 1
  private function replacePairs(): void
557
  {
558 1
    $this->replacePairsColumnTypes();
559 1
    $this->replacePairsConstants();
560 1
  }
561
562
  //--------------------------------------------------------------------------------------------------------------------
563
  /**
564
   * Selects schema, table, column names and the column type from MySQL and saves them as replace pairs.
565
   *
566
   * @throws MySqlQueryErrorException
567
   */
568 1
  private function replacePairsColumnTypes(): void
569
  {
570 1
    $columns = $this->dl->allTableColumns();
571
572 1
    $this->replacePairsColumnTypesExact($columns);
573 1
    $this->replacePairsColumnTypesMaxLength($columns);
574
575 1
    $this->io->text(sprintf('Selected %d column types for substitution', sizeof($columns)));
576 1
  }
577
578
  //--------------------------------------------------------------------------------------------------------------------
579
  /**
580
   * Gathers replace pairs based on exact column types.
581
   *
582
   * @param array[] $columns The details of all table columns.
583
   */
584 1
  private function replacePairsColumnTypesExact($columns): void
585
  {
586 1
    foreach ($columns as $column)
587
    {
588 1
      $key = mb_strtoupper('@'.$column['table_name'].'.'.$column['column_name'].'%type@');
589
590 1
      $value = $column['column_type'];
591
592
      // For VARCHAR and TEXT columns add character set.
593 1
      if ($column['character_set_name']!==null)
594
      {
595 1
        $value .= ' character set '.$column['character_set_name'];
596
      }
597
598 1
      $this->replacePairs[$key] = $value;
599
    }
600 1
  }
601
602
  //--------------------------------------------------------------------------------------------------------------------
603
  /**
604
   * Gathers replace pairs based on column types with maximum length.
605
   *
606
   * @param array[] $columns The details of all table columns.
607
   */
608 1
  private function replacePairsColumnTypesMaxLength($columns): void
609
  {
610 1
    foreach ($columns as $column)
611
    {
612 1
      $key = mb_strtoupper('@'.$column['table_name'].'.'.$column['column_name'].'%sort@');
613
614 1
      switch ($column['data_type'])
615
      {
616 1
        case 'char':
617 1
        case 'varchar':
618 1
          $max = $this->maxCharacters($column['character_set_name']);
619 1
          if ($max!==null)
620
          {
621 1
            $value = sprintf('%s(%d) character set %s',
622 1
                             $column['data_type'],
623
                             $max,
624 1
                             $column['character_set_name']);
625
          }
626
          else
627
          {
628
            $value = null;
629
          }
630 1
          break;
631
632 1
        case 'binary':
633 1
        case 'varbinary':
634 1
          $value = sprintf('%s(%d)', $column['data_type'], self::MAX_COLUMN_SIZE);
635 1
          break;
636
637
        default:
638 1
          $value = null;
639
      }
640
641 1
      if ($value!==null) $this->replacePairs[$key] = $value;
642
    }
643 1
  }
644
645
  //--------------------------------------------------------------------------------------------------------------------
646
  /**
647
   * Reads constants set the PHP configuration file and  adds them to the replace pairs.
648
   *
649
   * @throws \ReflectionException
650
   */
651 1
  private function replacePairsConstants(): void
652
  {
653
    // If myTargetConfigFilename is not set return immediately.
654 1
    if (!isset($this->constantClassName)) return;
655
656 1
    $reflection = new \ReflectionClass($this->constantClassName);
657
658 1
    $constants = $reflection->getConstants();
659 1
    foreach ($constants as $name => $value)
660
    {
661
      if (!is_numeric($value)) $value = "'".$value."'";
662
663
      $this->replacePairs['@'.$name.'@'] = $value;
664
    }
665
666 1
    $this->io->text(sprintf('Read %d constants for substitution from <fso>%s</fso>',
667 1
                            sizeof($constants),
668 1
                            OutputFormatter::escape($reflection->getFileName())));
669 1
  }
670
671
  //--------------------------------------------------------------------------------------------------------------------
672
  /**
673
   * Writes the metadata of all stored routines to the metadata file.
674
   *
675
   * @throws RuntimeException
676
   */
677 1
  private function writeStoredRoutineMetadata(): void
678
  {
679 1
    $json_data = json_encode($this->phpStratumMetadata, JSON_PRETTY_PRINT);
680 1
    if (json_last_error()!=JSON_ERROR_NONE)
681
    {
682
      throw new RuntimeException("Error of encoding to JSON: '%s'.", json_last_error_msg());
683
    }
684
685
    // Save the metadata.
686 1
    $this->writeTwoPhases($this->phpStratumMetadataFilename, $json_data);
687 1
  }
688
689
  //--------------------------------------------------------------------------------------------------------------------
690
}
691
692
//----------------------------------------------------------------------------------------------------------------------
693