Passed
Branch master (1f5add)
by P.R.
08:44
created

RoutineLoaderHelper._get_name()   A

Complexity

Conditions 1

Size

Total Lines 8
Code Lines 3

Duplication

Lines 8
Ratio 100 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
eloc 3
dl 8
loc 8
ccs 0
cts 2
cp 0
rs 10
c 0
b 0
f 0
cc 1
nop 1
crap 2
1
import abc
2
import math
3
import os
4
import re
5
import stat
6
from typing import Dict, List, Optional, Tuple, Union
7
8
from pystratum_backend.StratumStyle import StratumStyle
9
10
from pystratum_common.DocBlockReflection import DocBlockReflection
11
from pystratum_common.exception.LoaderException import LoaderException
12
from pystratum_common.helper.DataTypeHelper import DataTypeHelper
13
14
15 View Code Duplication
class RoutineLoaderHelper(metaclass=abc.ABCMeta):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
16
    """
17
    Class for loading a single stored routine into a RDBMS instance from a (pseudo) SQL file.
18
    """
19
20
    # ------------------------------------------------------------------------------------------------------------------
21
    def __init__(self,
22
                 io: StratumStyle,
23
                 routine_filename: str,
24
                 routine_file_encoding: str,
25
                 pystratum_old_metadata: Dict,
26
                 replace_pairs: Dict[str, str],
27
                 rdbms_old_metadata: Dict):
28
        """
29
        Object constructor.
30
31
        :param PyStratumStyle io: The output decorator.
32
        :param str routine_filename: The filename of the source of the stored routine.
33
        :param str routine_file_encoding: The encoding of the source file.
34
        :param dict pystratum_old_metadata: The metadata of the stored routine from PyStratum.
35
        :param dict[str,str] replace_pairs: A map from placeholders to their actual values.
36
        :param dict rdbms_old_metadata: The old metadata of the stored routine from the RDBMS instance.
37
        """
38
        self._source_filename: str = routine_filename
39
        """
40
        The source filename holding the stored routine.
41
        """
42
43
        self._routine_file_encoding: str = routine_file_encoding
44
        """
45
        The encoding of the routine file.
46
        """
47
48
        self._pystratum_old_metadata: Dict = pystratum_old_metadata
49
        """
50
        The old metadata of the stored routine.  Note: this data comes from the metadata file.
51
        """
52
53
        self._pystratum_metadata: Dict = {}
54
        """
55
        The metadata of the stored routine. Note: this data is stored in the metadata file and is generated by
56
        pyStratum.
57
        """
58
59
        self._replace_pairs: Dict[str, str] = replace_pairs
60
        """
61
        A map from placeholders to their actual values.
62
        """
63
64
        self._rdbms_old_metadata: Dict = rdbms_old_metadata
65
        """
66
        The old information about the stored routine. Note: this data comes from the metadata of the RDBMS instance.
67
        """
68
69
        self._m_time: int = 0
70
        """
71
        The last modification time of the source file.
72
        """
73
74
        self._routine_name: Optional[str] = None
75
        """
76
        The name of the stored routine.
77
        """
78
79
        self._routine_source_code: Optional[str] = None
80
        """
81
        The source code as a single string of the stored routine.
82
        """
83
84
        self._routine_source_code_lines: List[str] = []
85
        """
86
        The source code as an array of lines string of the stored routine.
87
        """
88
89
        self._replace: Dict = {}
90
        """
91
        The replace pairs (i.e. placeholders and their actual values).
92
        """
93
94
        self._routine_type: Optional[str] = None
95
        """
96
        The stored routine type (i.e. procedure or function) of the stored routine.
97
        """
98
99
        self._designation_type: Optional[str] = None
100
        """
101
        The designation type of the stored routine.
102
        """
103
104
        self._doc_block_parts_source: Dict = dict()
105
        """
106
        All DocBlock parts as found in the source of the stored routine.
107
        """
108
109
        self._doc_block_parts_wrapper: Dict = dict()
110
        """
111
        The DocBlock parts to be used by the wrapper generator.
112
        """
113
114
        self._columns_types: Optional[List] = None
115
        """
116
        The column types of columns of the table for bulk insert of the stored routine.
117
        """
118
119
        self._fields: Optional[List] = None
120
        """
121
        The keys in the dictionary for bulk insert.
122
        """
123
124
        self._parameters: List[Dict] = []
125
        """
126
        The information about the parameters of the stored routine.
127
        """
128
129
        self._table_name: Optional[str] = None
130
        """
131
        If designation type is bulk_insert the table name for bulk insert.
132
        """
133
134
        self._columns: Optional[List] = None
135
        """
136
        The key or index columns (depending on the designation type) of the stored routine.
137
        """
138
139
        self._io: StratumStyle = io
140
        """
141
        The output decorator.
142
        """
143
144
        self.shadow_directory: Optional[str] = None
145
        """
146
        The name of the directory were copies with pure SQL of the stored routine sources must be stored.
147
        """
148
149
    # ------------------------------------------------------------------------------------------------------------------
150
    def load_stored_routine(self) -> Union[Dict[str, str], bool]:
151
        """
152
        Loads the stored routine into the instance of MySQL.
153
154
        Returns the metadata of the stored routine if the stored routine is loaded successfully. Otherwise returns
155
        False.
156
157
        :rtype: dict[str,str]|bool
158
        """
159
        try:
160
            self._routine_name = os.path.splitext(os.path.basename(self._source_filename))[0]
161
162
            if os.path.exists(self._source_filename):
163
                if os.path.isfile(self._source_filename):
164
                    self._m_time = int(os.path.getmtime(self._source_filename))
165
                else:
166
                    raise LoaderException("Unable to get mtime of file '{}'".format(self._source_filename))
167
            else:
168
                raise LoaderException("Source file '{}' does not exist".format(self._source_filename))
169
170
            if self._pystratum_old_metadata:
171
                self._pystratum_metadata = self._pystratum_old_metadata
172
173
            load = self._must_reload()
174
            if load:
175
                self.__read_source_file()
176
                self.__get_placeholders()
177
                self._get_designation_type()
178
                self._get_name()
179
                self.__substitute_replace_pairs()
180
                self._load_routine_file()
181
                if self._designation_type == 'bulk_insert':
182
                    self._get_bulk_insert_table_columns_info()
183
                self._get_routine_parameters_info()
184
                self.__get_doc_block_parts_wrapper()
185
                self.__save_shadow_copy()
186
                self.__validate_parameter_lists()
187
                self._update_metadata()
188
189
            return self._pystratum_metadata
190
191
        except Exception as exception:
192
            self._log_exception(exception)
193
            return False
194
195
    # ------------------------------------------------------------------------------------------------------------------
196
    def __validate_parameter_lists(self) -> None:
197
        """
198
        Validates the parameters found the DocBlock in the source of the stored routine against the parameters from the
199
        metadata of MySQL and reports missing and unknown parameters names.
200
        """
201
        # Make list with names of parameters used in database.
202
        database_parameters_names = []
203
        for parameter in self._parameters:
204
            database_parameters_names.append(parameter['name'])
205
206
        # Make list with names of parameters used in dock block of routine.
207
        doc_block_parameters_names = []
208
        if 'parameters' in self._doc_block_parts_source:
209
            for parameter in self._doc_block_parts_source['parameters']:
210
                doc_block_parameters_names.append(parameter['name'])
211
212
        # Check and show warning if any parameters is missing in doc block.
213
        for parameter in database_parameters_names:
214
            if parameter not in doc_block_parameters_names:
215
                self._io.warning('Parameter {} is missing in doc block'.format(parameter))
216
217
        # Check and show warning if find unknown parameters in doc block.
218
        for parameter in doc_block_parameters_names:
219
            if parameter not in database_parameters_names:
220
                self._io.warning('Unknown parameter {} found in doc block'.format(parameter))
221
222
    # ------------------------------------------------------------------------------------------------------------------
223
    def __read_source_file(self) -> None:
224
        """
225
        Reads the file with the source of the stored routine.
226
        """
227
        with open(self._source_filename, 'r', encoding=self._routine_file_encoding) as file:
228
            self._routine_source_code = file.read()
229
230
        self._routine_source_code_lines = self._routine_source_code.split("\n")
231
232
    # ------------------------------------------------------------------------------------------------------------------
233
    def __save_shadow_copy(self) -> None:
234
        """
235
        Saves a copy of the stored routine source with pure SQL (if shadow directory is set).
236
        """
237
        if not self.shadow_directory:
238
            return
239
240
        destination_filename = os.path.join(self.shadow_directory, self._routine_name) + '.sql'
241
242
        if os.path.realpath(destination_filename) == os.path.realpath(self._source_filename):
243
            raise LoaderException("Shadow copy will override routine source '{}'".format(self._source_filename))
244
245
        # Remove the (read only) shadow file if it exists.
246
        if os.path.exists(destination_filename):
247
            os.remove(destination_filename)
248
249
        # Write the shadow file.
250
        with open(destination_filename, 'wt', encoding=self._routine_file_encoding) as handle:
251
            handle.write(self._routine_source_code)
252
253
        # Make the file read only.
254
        mode = os.stat(self._source_filename)[stat.ST_MODE]
255
        os.chmod(destination_filename, mode & ~stat.S_IWUSR & ~stat.S_IWGRP & ~stat.S_IWOTH)
256
257
    # ------------------------------------------------------------------------------------------------------------------
258
    def __substitute_replace_pairs(self) -> None:
259
        """
260
        Substitutes all replace pairs in the source of the stored routine.
261
        """
262
        self._set_magic_constants()
263
264
        routine_source = []
265
        i = 0
266
        for line in self._routine_source_code_lines:
267
            self._replace['__LINE__'] = "'%d'" % (i + 1)
268
            for search, replace in self._replace.items():
269
                tmp = re.findall(search, line, re.IGNORECASE)
270
                if tmp:
271
                    line = line.replace(tmp[0], replace)
272
            routine_source.append(line)
273
            i += 1
274
275
        self._routine_source_code = "\n".join(routine_source)
276
277
    # ------------------------------------------------------------------------------------------------------------------
278
    def _log_exception(self, exception: Exception) -> None:
279
        """
280
        Logs an exception.
281
282
        :param Exception exception: The exception.
283
284
        :rtype: None
285
        """
286
        self._io.error(str(exception).strip().split(os.linesep))
287
288
    # ------------------------------------------------------------------------------------------------------------------
289
    @abc.abstractmethod
290
    def _must_reload(self) -> bool:
291
        """
292
        Returns True if the source file must be load or reloaded. Otherwise returns False.
293
294
        :rtype: bool
295
        """
296
        raise NotImplementedError()
297
298
    # ------------------------------------------------------------------------------------------------------------------
299
    def __get_placeholders(self) -> None:
300
        """
301
        Extracts the placeholders from the stored routine source.
302
        """
303
        pattern = re.compile('(@[A-Za-z0-9_.]+(%(max-)?type)?@)')
304
        matches = pattern.findall(self._routine_source_code)
305
306
        placeholders = []
307
308
        if len(matches) != 0:
309
            for tmp in matches:
310
                placeholder = tmp[0]
311
                if placeholder.lower() not in self._replace_pairs:
312
                    raise LoaderException("Unknown placeholder '{0}' in file {1}".
313
                                          format(placeholder, self._source_filename))
314
                if placeholder not in placeholders:
315
                    placeholders.append(placeholder)
316
317
        for placeholder in placeholders:
318
            if placeholder not in self._replace:
319
                self._replace[placeholder] = self._replace_pairs[placeholder.lower()]
320
321
    # ------------------------------------------------------------------------------------------------------------------
322
    def _get_designation_type(self) -> None:
323
        """
324
        Extracts the designation type of the stored routine.
325
        """
326
        self._get_designation_type_old()
327
        if not self._designation_type:
328
            self._get_designation_type_new()
329
330
    # ------------------------------------------------------------------------------------------------------------------
331
    def _get_designation_type_new(self) -> None:
332
        """
333
        Extracts the designation type of the stored routine.
334
        """
335
        if not self._designation_type:
336
            raise LoaderException("Unable to find the designation type of the stored routine in file {0}".
337
                                  format(self._source_filename))
338
339
    # ------------------------------------------------------------------------------------------------------------------
340
    def _get_designation_type_old(self) -> None:
341
        """
342
        Extracts the designation type of the stored routine.
343
        """
344
        positions = self._get_specification_positions()
345
        if positions[0] != -1 and positions[1] != -1:
346
            pattern = re.compile(r'^\s*--\s+type\s*:\s*(\w+)\s*(.+)?\s*', re.IGNORECASE)
347
            for line_number in range(positions[0], positions[1] + 1):
348
                matches = pattern.findall(self._routine_source_code_lines[line_number])
349
                if matches:
350
                    self._designation_type = matches[0][0].lower()
351
                    tmp = str(matches[0][1])
352
                    if self._designation_type == 'bulk_insert':
353
                        n = re.compile(r'([a-zA-Z0-9_]+)\s+([a-zA-Z0-9_,]+)', re.IGNORECASE)
354
                        info = n.findall(tmp)
355
                        if not info:
356
                            raise LoaderException('Expected: -- type: bulk_insert <table_name> <columns> in file {0}'.
357
                                                  format(self._source_filename))
358
                        self._table_name = info[0][0]
359
                        self._columns = str(info[0][1]).split(',')
360
361
                    elif self._designation_type == 'rows_with_key' or self._designation_type == 'rows_with_index':
362
                        self._columns = str(matches[0][1]).split(',')
363
                    else:
364
                        if matches[0][1]:
365
                            raise LoaderException('Expected: -- type: {}'.format(self._designation_type))
366
367
    # ------------------------------------------------------------------------------------------------------------------
368
    def _get_specification_positions(self) -> Tuple[int, int]:
369
        """
370
        Returns a tuple with the start and end line numbers of the stored routine specification.
371
372
        :rtype: tuple
373
        """
374
        start = -1
375
        for (i, line) in enumerate(self._routine_source_code_lines):
376
            if self._is_start_of_stored_routine(line):
377
                start = i
378
379
        end = -1
380
        for (i, line) in enumerate(self._routine_source_code_lines):
381
            if self._is_start_of_stored_routine_body(line):
382
                end = i - 1
383
384
        return start, end
385
386
    # ------------------------------------------------------------------------------------------------------------------
387
    @abc.abstractmethod
388
    def _is_start_of_stored_routine(self, line: str) -> bool:
389
        """
390
        Returns True if a line is the start of the code of the stored routine.
391
392
        :param str line: The line with source code of the stored routine.
393
394
        :rtype: bool
395
        """
396
        raise NotImplementedError()
397
398
    # ------------------------------------------------------------------------------------------------------------------
399
    def _is_start_of_stored_routine_body(self, line: str) -> bool:
400
        """
401
        Returns True if a line is the start of the body of the stored routine.
402
403
        :param str line: The line with source code of the stored routine.
404
405
        :rtype: bool
406
        """
407
        raise NotImplementedError()
408
409
    # ------------------------------------------------------------------------------------------------------------------
410
    def __get_doc_block_lines(self) -> Tuple[int, int]:
411
        """
412
        Returns the start and end line of the DocBlock of the stored routine code.
413
        """
414
        line1 = None
415
        line2 = None
416
417
        i = 0
418
        for line in self._routine_source_code_lines:
419
            if re.match(r'\s*/\*\*', line):
420
                line1 = i
421
422
            if re.match(r'\s*\*/', line):
423
                line2 = i
424
425
            if self._is_start_of_stored_routine(line):
426
                break
427
428
            i += 1
429
430
        return line1, line2
431
432
    # ------------------------------------------------------------------------------------------------------------------
433
    def __get_doc_block_parts_source(self) -> None:
434
        """
435
        Extracts the DocBlock (in parts) from the source of the stored routine source.
436
        """
437
        line1, line2 = self.__get_doc_block_lines()
438
439
        if line1 is not None and line2 is not None and line1 <= line2:
440
            doc_block = self._routine_source_code_lines[line1:line2 - line1 + 1]
441
        else:
442
            doc_block = list()
443
444
        reflection = DocBlockReflection(doc_block)
445
446
        self._doc_block_parts_source['description'] = reflection.get_description()
447
448
        self._doc_block_parts_source['parameters'] = list()
449
        for tag in reflection.get_tags('param'):
450
            parts = re.match(r'^(@param)\s+(\w+)\s*(.+)?', tag, re.DOTALL)
451
            if parts:
452
                self._doc_block_parts_source['parameters'].append({'name':        parts.group(2),
453
                                                                   'description': parts.group(3)})
454
455
    # ------------------------------------------------------------------------------------------------------------------
456
    def __get_parameter_doc_description(self, name: str) -> str:
457
        """
458
        Returns the description by name of the parameter as found in the DocBlock of the stored routine.
459
460
        :param str name: The name of the parameter.
461
462
        :rtype: str
463
        """
464
        for param in self._doc_block_parts_source['parameters']:
465
            if param['name'] == name:
466
                return param['description']
467
468
        return ''
469
470
    # ------------------------------------------------------------------------------------------------------------------
471
    @abc.abstractmethod
472
    def _get_data_type_helper(self) -> DataTypeHelper:
473
        """
474
        Returns a data type helper object appropriate for the RDBMS.
475
476
        :rtype: DataTypeHelper
477
        """
478
        raise NotImplementedError()
479
480
    # ------------------------------------------------------------------------------------------------------------------
481
    def __get_doc_block_parts_wrapper(self) -> None:
482
        """
483
        Generates the DocBlock parts to be used by the wrapper generator.
484
        """
485
        self.__get_doc_block_parts_source()
486
487
        helper = self._get_data_type_helper()
488
489
        parameters = list()
490
        for parameter_info in self._parameters:
491
            parameters.append(
492
                    {'parameter_name':       parameter_info['name'],
493
                     'python_type':          helper.column_type_to_python_type(parameter_info),
494
                     'python_type_hint':     helper.column_type_to_python_type_hint(parameter_info),
495
                     'data_type_descriptor': parameter_info['data_type_descriptor'],
496
                     'description':          self.__get_parameter_doc_description(parameter_info['name'])})
497
498
        self._doc_block_parts_wrapper['description'] = self._doc_block_parts_source['description']
499
        self._doc_block_parts_wrapper['parameters'] = parameters
500
501
    # ------------------------------------------------------------------------------------------------------------------
502
    @abc.abstractmethod
503
    def _get_name(self) -> None:
504
        """
505
        Extracts the name of the stored routine and the stored routine type (i.e. procedure or function) source.
506
507
        :rtype: None
508
        """
509
        raise NotImplementedError()
510
511
    # ------------------------------------------------------------------------------------------------------------------
512
    @abc.abstractmethod
513
    def _load_routine_file(self) -> None:
514
        """
515
        Loads the stored routine into the RDBMS instance.
516
        """
517
        raise NotImplementedError()
518
519
    # ------------------------------------------------------------------------------------------------------------------
520
    @abc.abstractmethod
521
    def _get_bulk_insert_table_columns_info(self) -> None:
522
        """
523
        Gets the column names and column types of the current table for bulk insert.
524
        """
525
        raise NotImplementedError()
526
527
    # ------------------------------------------------------------------------------------------------------------------
528
    @abc.abstractmethod
529
    def _get_routine_parameters_info(self) -> None:
530
        """
531
        Retrieves information about the stored routine parameters from the meta data of the RDBMS.
532
        """
533
        raise NotImplementedError()
534
535
    # ------------------------------------------------------------------------------------------------------------------
536
    def _update_metadata(self) -> None:
537
        """
538
        Updates the metadata of the stored routine.
539
        """
540
        self._pystratum_metadata['routine_name'] = self._routine_name
541
        self._pystratum_metadata['designation'] = self._designation_type
542
        self._pystratum_metadata['table_name'] = self._table_name
543
        self._pystratum_metadata['parameters'] = self._parameters
544
        self._pystratum_metadata['columns'] = self._columns
545
        self._pystratum_metadata['fields'] = self._fields
546
        self._pystratum_metadata['column_types'] = self._columns_types
547
        self._pystratum_metadata['timestamp'] = self._m_time
548
        self._pystratum_metadata['replace'] = self._replace
549
        self._pystratum_metadata['pydoc'] = self._doc_block_parts_wrapper
550
551
    # ------------------------------------------------------------------------------------------------------------------
552
    @abc.abstractmethod
553
    def _drop_routine(self) -> None:
554
        """
555
        Drops the stored routine if it exists.
556
        """
557
        raise NotImplementedError()
558
559
    # ------------------------------------------------------------------------------------------------------------------
560
    def _set_magic_constants(self) -> None:
561
        """
562
        Adds magic constants to replace list.
563
        """
564
        real_path = os.path.realpath(self._source_filename)
565
566
        self._replace['__FILE__'] = "'%s'" % real_path
567
        self._replace['__ROUTINE__'] = "'%s'" % self._routine_name
568
        self._replace['__DIR__'] = "'%s'" % os.path.dirname(real_path)
569
570
    # ------------------------------------------------------------------------------------------------------------------
571
    def _unset_magic_constants(self) -> None:
572
        """
573
        Removes magic constants from current replace list.
574
        """
575
        if '__FILE__' in self._replace:
576
            del self._replace['__FILE__']
577
578
        if '__ROUTINE__' in self._replace:
579
            del self._replace['__ROUTINE__']
580
581
        if '__DIR__' in self._replace:
582
            del self._replace['__DIR__']
583
584
        if '__LINE__' in self._replace:
585
            del self._replace['__LINE__']
586
587
    # ------------------------------------------------------------------------------------------------------------------
588
    def _print_sql_with_error(self, sql: str, error_line: int) -> None:
589
        """
590
        Writes a SQL statement with an syntax error to the output. The line where the error occurs is highlighted.
591
592
        :param str sql: The SQL statement.
593
        :param int error_line: The line where the error occurs.
594
        """
595
        if os.linesep in sql:
596
            lines = sql.split(os.linesep)
597
            digits = math.ceil(math.log(len(lines) + 1, 10))
598
            i = 1
599
            for line in lines:
600
                if i == error_line:
601
                    self._io.text('<error>{0:{width}} {1}</error>'.format(i, line, width=digits, ))
602
                else:
603
                    self._io.text('{0:{width}} {1}'.format(i, line, width=digits, ))
604
                i += 1
605
        else:
606
            self._io.text(sql)
607
608
# ----------------------------------------------------------------------------------------------------------------------
609