|
5 | 5 | import re |
6 | 6 | from bisect import bisect_right |
7 | 7 | from collections import OrderedDict |
8 | | -from dataclasses import dataclass |
| 8 | +from collections.abc import Callable |
| 9 | +from dataclasses import dataclass, field |
9 | 10 | from pathlib import Path |
10 | 11 | from typing import cast |
11 | 12 |
|
|
52 | 53 | _MODEL_HEADER_INTEGER_PATTERN: re.Pattern[str] = re.compile(r"^[+-]?\d+$") |
53 | 54 | _MODEL_HEADER_FLOAT_PATTERN: re.Pattern[str] = re.compile(r"^[+-]?(?:\d+\.\d*|\d*\.\d+)$") |
54 | 55 | _MODEL_HEADER_TOKEN_CACHE_SIZE: int = 4096 |
| 56 | +_PROJECTION_PENDING: object = object() |
55 | 57 | _MODEL_HEADER_NESTING_ERROR_PATTERN: re.Pattern[str] = re.compile( |
56 | 58 | r"values nest deeper than (?P<limit>\d+) levels at position (?P<position>\d+)" |
57 | 59 | ) |
|
79 | 81 | _SELECT_SCAN_SPECIAL: re.Pattern[str] = re.compile(r"['\"`()sSfFuU]") |
80 | 82 |
|
81 | 83 |
|
| 84 | +@dataclass |
| 85 | +class _ProjectionFrame: |
| 86 | + """One container being projected: its native children, projected so far, and its builder.""" |
| 87 | + |
| 88 | + children: tuple[object, ...] |
| 89 | + build: Callable[[list[object]], object] |
| 90 | + projected: list[object] = field(default_factory=list) |
| 91 | + |
| 92 | + |
82 | 93 | @dataclass(frozen=True) |
83 | 94 | class _ModelHeaderToken: |
84 | 95 | kind: str |
@@ -575,17 +586,22 @@ def parse_header_values( |
575 | 586 | raise |
576 | 587 | except ModelHeaderSyntaxError as error: |
577 | 588 | nesting: re.Match[str] | None = _MODEL_HEADER_NESTING_ERROR_PATTERN.fullmatch(str(error)) |
| 589 | + nesting_help: str | None = ( |
| 590 | + None |
| 591 | + if nesting is None |
| 592 | + else f"flatten the value so it nests at most {nesting.group('limit')} levels deep" |
| 593 | + ) |
578 | 594 | if nesting is None or header_line is None: |
579 | 595 | raise error_class( |
580 | 596 | f"{statement_name}(...) in '{file_path}' contains invalid SQLBuild header syntax: " |
581 | | - f"{error}" |
| 597 | + f"{error}", |
| 598 | + help=nesting_help, |
582 | 599 | ) from error |
583 | | - limit: str = nesting.group("limit") |
584 | 600 | line: int = header_line + header.count("\n", 0, int(nesting.group("position"))) |
585 | 601 | raise error_class( |
586 | 602 | f"{statement_name}(...) in '{file_path}:{line}' contains invalid SQLBuild header " |
587 | | - f"syntax: values nest deeper than {limit} levels", |
588 | | - help=f"flatten the value so it nests at most {limit} levels deep", |
| 603 | + f"syntax: values nest deeper than {nesting.group('limit')} levels", |
| 604 | + help=nesting_help, |
589 | 605 | ) from error |
590 | 606 |
|
591 | 607 |
|
@@ -653,47 +669,98 @@ def project_native_header_values(values: dict[str, object]) -> dict[str, object] |
653 | 669 |
|
654 | 670 |
|
655 | 671 | def _project_native_header_map(values: dict[str, object]) -> dict[str, object]: |
656 | | - return {key: _project_native_header_value(value) for key, value in values.items()} |
| 672 | + return cast(dict[str, object], _project_native_header_value(values)) |
657 | 673 |
|
658 | 674 |
|
659 | 675 | def _project_native_header_value(value: object) -> object: |
| 676 | + """Project one native value depth-first with an explicit stack, never Python recursion.""" |
| 677 | + |
| 678 | + frames: list[_ProjectionFrame] = [] |
| 679 | + node: object = value |
| 680 | + while True: |
| 681 | + expansion: _ProjectionFrame | None = _projection_frame(node) |
| 682 | + if expansion is None: |
| 683 | + projected: object = _project_native_leaf(node) |
| 684 | + else: |
| 685 | + frames.append(expansion) |
| 686 | + projected = _PROJECTION_PENDING |
| 687 | + while True: |
| 688 | + if projected is not _PROJECTION_PENDING: |
| 689 | + if not frames: |
| 690 | + return projected |
| 691 | + frames[-1].projected.append(projected) |
| 692 | + frame: _ProjectionFrame = frames[-1] |
| 693 | + if len(frame.projected) < len(frame.children): |
| 694 | + node = frame.children[len(frame.projected)] |
| 695 | + break |
| 696 | + frames.pop() |
| 697 | + projected = frame.build(frame.projected) |
| 698 | + |
| 699 | + |
| 700 | +def _projection_frame(value: object) -> _ProjectionFrame | None: |
660 | 701 | if isinstance(value, dict): |
661 | | - return _project_native_header_map(cast(dict[str, object], value)) |
| 702 | + return _map_frame(values=cast(dict[str, object], value), build=dict) |
662 | 703 | if isinstance(value, list): |
663 | | - return [_project_native_header_value(item) for item in value] |
| 704 | + return _ProjectionFrame(children=tuple(value), build=list) |
664 | 705 | if ( |
665 | 706 | not isinstance(value, tuple) |
666 | 707 | or len(value) != _NATIVE_MARKER_LENGTH |
667 | 708 | or not isinstance(value[0], str) |
668 | 709 | ): |
669 | | - return value |
| 710 | + return None |
670 | 711 | kind: str = value[0] |
671 | 712 | payload: object = value[1] |
672 | | - if kind == _MODEL_HEADER_WORD_TOKEN and isinstance(payload, str): |
673 | | - return _parse_word_value(payload) |
674 | 713 | if kind == _NATIVE_SET_MARKER and isinstance(payload, list): |
675 | | - return AuthoredSqlSet(tuple(_project_native_header_value(item) for item in payload)) |
| 714 | + return _ProjectionFrame( |
| 715 | + children=tuple(payload), build=lambda items: AuthoredSqlSet(tuple(items)) |
| 716 | + ) |
676 | 717 | if kind == _NATIVE_TUPLE_MARKER and isinstance(payload, list): |
677 | | - return tuple(_project_native_header_value(item) for item in payload) |
| 718 | + return _ProjectionFrame(children=tuple(payload), build=tuple) |
678 | 719 | if kind == _NATIVE_CONSTANT_MARKER and isinstance(payload, dict): |
679 | | - return AuthoredSqlValueCall( |
680 | | - arguments=tuple(_project_native_header_map(cast(dict[str, object], payload)).items()) |
| 720 | + return _map_frame( |
| 721 | + values=cast(dict[str, object], payload), |
| 722 | + build=lambda items: AuthoredSqlValueCall(arguments=tuple(items)), |
681 | 723 | ) |
682 | | - if kind == _NATIVE_INLINE_SQL_MARKER and isinstance(payload, str): |
683 | | - return SqlHookEntry(statement=payload) |
684 | 724 | if ( |
685 | 725 | kind in {_NATIVE_SQL_MARKER, _NATIVE_PYTHON_MARKER} |
686 | 726 | and isinstance(payload, tuple) |
687 | 727 | and len(payload) == _NATIVE_MARKER_LENGTH |
688 | 728 | ): |
689 | 729 | name, kwargs = payload |
690 | 730 | if isinstance(name, str) and isinstance(kwargs, dict): |
691 | | - projected_kwargs: dict[str, object] = _project_native_header_map( |
692 | | - cast(dict[str, object], kwargs) |
| 731 | + entry_type: type[NamedSqlHookEntry] | type[PythonHookEntry] = ( |
| 732 | + NamedSqlHookEntry if kind == _NATIVE_SQL_MARKER else PythonHookEntry |
693 | 733 | ) |
694 | | - if kind == _NATIVE_SQL_MARKER: |
695 | | - return NamedSqlHookEntry(name=name, kwargs=projected_kwargs) |
696 | | - return PythonHookEntry(name=name, kwargs=projected_kwargs) |
| 734 | + return _map_frame( |
| 735 | + values=cast(dict[str, object], kwargs), |
| 736 | + build=lambda items: entry_type(name=name, kwargs=dict(items)), |
| 737 | + ) |
| 738 | + return None |
| 739 | + |
| 740 | + |
| 741 | +def _map_frame( |
| 742 | + *, values: dict[str, object], build: Callable[[list[tuple[str, object]]], object] |
| 743 | +) -> _ProjectionFrame: |
| 744 | + keys: tuple[str, ...] = tuple(values) |
| 745 | + return _ProjectionFrame( |
| 746 | + children=tuple(values.values()), |
| 747 | + build=lambda projected: build(list(zip(keys, projected, strict=True))), |
| 748 | + ) |
| 749 | + |
| 750 | + |
| 751 | +def _project_native_leaf(value: object) -> object: |
| 752 | + if ( |
| 753 | + not isinstance(value, tuple) |
| 754 | + or len(value) != _NATIVE_MARKER_LENGTH |
| 755 | + or not isinstance(value[0], str) |
| 756 | + ): |
| 757 | + return value |
| 758 | + kind: str = value[0] |
| 759 | + payload: object = value[1] |
| 760 | + if kind == _MODEL_HEADER_WORD_TOKEN and isinstance(payload, str): |
| 761 | + return _parse_word_value(payload) |
| 762 | + if kind == _NATIVE_INLINE_SQL_MARKER and isinstance(payload, str): |
| 763 | + return SqlHookEntry(statement=payload) |
697 | 764 | raise ModelHeaderSyntaxError(f"Native MODEL header parser returned invalid '{kind}' marker") |
698 | 765 |
|
699 | 766 |
|
|
0 commit comments