Completed
Push — master ( 3bb391...1a7c41 )
by P.R.
03:42
created

findSourceFilesFromList()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 15
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

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