sqlleaf 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. sqlleaf/__init__.py +4 -0
  2. sqlleaf/dialects/__init__.py +0 -0
  3. sqlleaf/dialects/plpgsql/__init__.py +0 -0
  4. sqlleaf/dialects/plpgsql/p_classes.py +275 -0
  5. sqlleaf/dialects/plpgsql/p_dialect.py +148 -0
  6. sqlleaf/dialects/plpgsql/p_generator.py +538 -0
  7. sqlleaf/dialects/plpgsql/p_parser.py +1244 -0
  8. sqlleaf/dialects/plpgsql/p_tokenizer.py +41 -0
  9. sqlleaf/exception.py +67 -0
  10. sqlleaf/lineage.py +326 -0
  11. sqlleaf/mappings.py +376 -0
  12. sqlleaf/models/__init__.py +0 -0
  13. sqlleaf/models/context.py +153 -0
  14. sqlleaf/models/hook.py +9 -0
  15. sqlleaf/models/node/__init__.py +27 -0
  16. sqlleaf/models/node/base.py +148 -0
  17. sqlleaf/models/node/column.py +345 -0
  18. sqlleaf/models/node/dynamodb.py +39 -0
  19. sqlleaf/models/node/function.py +33 -0
  20. sqlleaf/models/node/interval.py +21 -0
  21. sqlleaf/models/node/json.py +57 -0
  22. sqlleaf/models/node/literal.py +17 -0
  23. sqlleaf/models/node/null.py +14 -0
  24. sqlleaf/models/node/pivot.py +34 -0
  25. sqlleaf/models/node/program.py +27 -0
  26. sqlleaf/models/node/sequence.py +18 -0
  27. sqlleaf/models/node/star.py +11 -0
  28. sqlleaf/models/node/stream.py +14 -0
  29. sqlleaf/models/node/user_defined_function.py +33 -0
  30. sqlleaf/models/node/var.py +7 -0
  31. sqlleaf/models/node/variable.py +8 -0
  32. sqlleaf/models/node/window.py +22 -0
  33. sqlleaf/models/query/__init__.py +33 -0
  34. sqlleaf/models/query/base.py +248 -0
  35. sqlleaf/models/query/call.py +111 -0
  36. sqlleaf/models/query/copy.py +144 -0
  37. sqlleaf/models/query/ctas.py +61 -0
  38. sqlleaf/models/query/database.py +48 -0
  39. sqlleaf/models/query/delete.py +26 -0
  40. sqlleaf/models/query/execute.py +66 -0
  41. sqlleaf/models/query/holder.py +49 -0
  42. sqlleaf/models/query/insert.py +69 -0
  43. sqlleaf/models/query/merge.py +29 -0
  44. sqlleaf/models/query/multitable_insert.py +42 -0
  45. sqlleaf/models/query/prepare.py +61 -0
  46. sqlleaf/models/query/procedure.py +72 -0
  47. sqlleaf/models/query/put.py +38 -0
  48. sqlleaf/models/query/replace.py +32 -0
  49. sqlleaf/models/query/schema.py +47 -0
  50. sqlleaf/models/query/select.py +27 -0
  51. sqlleaf/models/query/sequence.py +33 -0
  52. sqlleaf/models/query/set.py +74 -0
  53. sqlleaf/models/query/stage.py +78 -0
  54. sqlleaf/models/query/table.py +220 -0
  55. sqlleaf/models/query/trigger.py +42 -0
  56. sqlleaf/models/query/type.py +42 -0
  57. sqlleaf/models/query/unload.py +64 -0
  58. sqlleaf/models/query/update.py +45 -0
  59. sqlleaf/models/query/user_defined_function.py +297 -0
  60. sqlleaf/models/query/values.py +20 -0
  61. sqlleaf/models/query/view.py +41 -0
  62. sqlleaf/path.py +194 -0
  63. sqlleaf/processors/__init__.py +0 -0
  64. sqlleaf/processors/collector/__init__.py +0 -0
  65. sqlleaf/processors/collector/collector.py +827 -0
  66. sqlleaf/processors/collector/substitute.py +396 -0
  67. sqlleaf/processors/generator/__init__.py +0 -0
  68. sqlleaf/processors/generator/dialects/__init__.py +5 -0
  69. sqlleaf/processors/generator/dialects/athena.py +45 -0
  70. sqlleaf/processors/generator/dialects/base.py +572 -0
  71. sqlleaf/processors/generator/dialects/mysql.py +19 -0
  72. sqlleaf/processors/generator/dialects/postgres.py +182 -0
  73. sqlleaf/processors/generator/dialects/redshift.py +192 -0
  74. sqlleaf/processors/generator/dialects/snowflake.py +90 -0
  75. sqlleaf/processors/generator/generator.py +495 -0
  76. sqlleaf/processors/transformer/__init__.py +31 -0
  77. sqlleaf/processors/transformer/base.py +899 -0
  78. sqlleaf/processors/transformer/call.py +9 -0
  79. sqlleaf/processors/transformer/copy.py +63 -0
  80. sqlleaf/processors/transformer/ctas.py +39 -0
  81. sqlleaf/processors/transformer/delete.py +14 -0
  82. sqlleaf/processors/transformer/execute.py +9 -0
  83. sqlleaf/processors/transformer/expressions/__init__.py +11 -0
  84. sqlleaf/processors/transformer/expressions/functional_notation.py +22 -0
  85. sqlleaf/processors/transformer/expressions/row.py +451 -0
  86. sqlleaf/processors/transformer/expressions/values.py +318 -0
  87. sqlleaf/processors/transformer/insert.py +125 -0
  88. sqlleaf/processors/transformer/merge.py +14 -0
  89. sqlleaf/processors/transformer/multitable_insert.py +14 -0
  90. sqlleaf/processors/transformer/replace.py +23 -0
  91. sqlleaf/processors/transformer/transformer.py +141 -0
  92. sqlleaf/processors/transformer/udf.py +235 -0
  93. sqlleaf/processors/transformer/unload.py +30 -0
  94. sqlleaf/processors/transformer/update.py +174 -0
  95. sqlleaf/processors/transformer/values.py +10 -0
  96. sqlleaf/settings/__init__.py +19 -0
  97. sqlleaf/settings/athena.py +23 -0
  98. sqlleaf/settings/base.py +37 -0
  99. sqlleaf/settings/postgres.py +52 -0
  100. sqlleaf/system_tables/load.py +0 -0
  101. sqlleaf/typing.py +93 -0
  102. sqlleaf/util/__init__.py +34 -0
  103. sqlleaf/util/expression.py +282 -0
  104. sqlleaf/util/graph.py +116 -0
  105. sqlleaf/util/helpers.py +42 -0
  106. sqlleaf/util/iterators.py +37 -0
  107. sqlleaf-0.0.1.dist-info/METADATA +46 -0
  108. sqlleaf-0.0.1.dist-info/RECORD +111 -0
  109. sqlleaf-0.0.1.dist-info/WHEEL +5 -0
  110. sqlleaf-0.0.1.dist-info/licenses/LICENSE +21 -0
  111. sqlleaf-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import typing as t
5
+ from dataclasses import dataclass
6
+
7
+ from sqlglot import exp
8
+
9
+ from sqlleaf import exception, mappings, util
10
+ from sqlleaf.models.query.base import Query
11
+ from sqlleaf.typing import SourceExprType, SourceInfo, TargetExprType, TargetInfo
12
+
13
+ logger = logging.getLogger("sqlleaf")
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class CopyQueryProperties:
18
+ file_format: str = "TEXT"
19
+ load_data: bool = True
20
+ is_a_job: bool = False
21
+ job_action: str = ""
22
+ job_name: str = ""
23
+ job_auto_run: bool = True
24
+
25
+ @classmethod
26
+ def from_expression(cls, expr: exp.Copy, source_info: SourceInfo, target_info: TargetInfo) -> CopyQueryProperties:
27
+ """
28
+ Extract the parameters of the COPY statement.
29
+
30
+ For example,
31
+ Input: "COPY FROM ... FORMAT AS CSV NOLOAD"
32
+ Params: ["FORMAT AS CSV", "NOLOAD"]
33
+ """
34
+ params_dict: t.Dict[str, t.Any] = {}
35
+
36
+ params_list = []
37
+ # Filter for the parameters we need
38
+ for param in expr.args.get("params", []):
39
+ if isinstance(param, exp.CopyParameter) and isinstance(param.this, exp.Var):
40
+ params_list.append(param)
41
+
42
+ for i, param in enumerate(params_list):
43
+ param_name = param.this.name.upper()
44
+ if param_name == "FORMAT":
45
+ params_dict["file_format"] = str(param.expression)
46
+ elif param_name == "NOLOAD":
47
+ params_dict["load_data"] = False
48
+ elif param_name == "JOB":
49
+ params_dict["is_a_job"] = True
50
+ try:
51
+ # COPY <command> JOB CREATE <name>
52
+ job_command = params_list[i + 1].this.name.upper()
53
+ job_name = params_list[i + 1].expression.name
54
+ params_dict["job_action"] = job_command
55
+ params_dict["job_name"] = job_name
56
+ except IndexError:
57
+ message = "Missing one or more parameters to follow expression 'COPY .. JOB'"
58
+ raise exception.SqlLeafException(message=message)
59
+ elif param_name == "AUTO":
60
+ # COPY <command> JOB CREATE <name> AUTO ON | OFF
61
+ params_dict["job_auto_run"] = str(param.expression) == "ON"
62
+ return cls(**params_dict)
63
+
64
+ @property
65
+ def is_active(self) -> bool:
66
+ """
67
+ An active COPY query is one that has lineage.
68
+ It is either:
69
+ - a regular COPY query
70
+ - a JOB that is either 'RUN' or 'CREATE and AUTO=ON'
71
+ Otherwise (e.g. `COPY JOB LIST`) it is not.
72
+ """
73
+ if not self.is_a_job and self.load_data:
74
+ return True
75
+ if self.job_action == "RUN":
76
+ return True
77
+ if self.job_auto_run and self.job_action == "CREATE":
78
+ return True
79
+ return False
80
+
81
+
82
+ class CopyQuery(Query):
83
+ KIND = "copy"
84
+
85
+ def __init__(self, expr: exp.Copy, dialect: str, object_mapping: mappings.ObjectMapping, statement_index: int):
86
+ source, target = self.get_source_and_target_expressions(expr, dialect)
87
+
88
+ if dialect == "snowflake":
89
+ util.rename_if_stage(source, target)
90
+
91
+ source_type = self._determine_expression_type(source, dialect)
92
+ target_type = self._determine_expression_type(target, dialect)
93
+
94
+ super().__init__(
95
+ dialect=dialect,
96
+ statement=expr,
97
+ statement_index=statement_index,
98
+ object_mapping=object_mapping,
99
+ source_info=SourceInfo(expression=source, type=source_type),
100
+ target_info=TargetInfo(expression=target, type=target_type),
101
+ )
102
+ self.parameters = self.get_params()
103
+ util.qualify_and_annotate(self.source_info.expression, self.dialect, self.object_mapping)
104
+
105
+ def get_params(
106
+ self,
107
+ ) -> CopyQueryProperties:
108
+ return CopyQueryProperties.from_expression(self.statement, self.source_info, self.target_info)
109
+
110
+ def is_query_active(self) -> bool:
111
+ logger.debug(self.parameters)
112
+ return self.parameters.is_active
113
+
114
+ def get_source_and_target_expressions(
115
+ self, expr: exp.Copy, dialect: str
116
+ ) -> t.Tuple[SourceExprType, TargetExprType]:
117
+ """
118
+ Determine the source and target expressions of the query.
119
+ """
120
+ if dialect in ["postgres", "redshift"]:
121
+ # Postgres treats STDOUT and STDIN the same
122
+
123
+ if expr.args["kind"]:
124
+ # COPY X FROM STDOUT/STDIN
125
+ source = expr.args["files"][0]
126
+ target = expr.args["this"]
127
+ if isinstance(target, exp.Schema):
128
+ target = target.this
129
+ else:
130
+ # COPY X TO STDOUT/STDIN
131
+ source = expr.args["this"]
132
+ target = expr.args["files"][0]
133
+ if isinstance(source, exp.Schema):
134
+ source = source.this
135
+
136
+ elif dialect == "snowflake":
137
+ source = expr.args["files"][0]
138
+ target = expr.args["this"]
139
+
140
+ # It may be a subquery
141
+ source = source.unnest()
142
+ target = target.unnest()
143
+
144
+ return source, target
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import typing as t
4
+
5
+ from sqlglot import exp
6
+
7
+ from sqlleaf import mappings, util
8
+ from sqlleaf.models.query.base import Query
9
+ from sqlleaf.models.query.table import TableQuery
10
+ from sqlleaf.typing import SourceInfo, TargetInfo
11
+
12
+
13
+ class CTASQuery(Query):
14
+ KIND = "ctas"
15
+
16
+ def __init__(
17
+ self,
18
+ expr: exp.Create,
19
+ dialect: str,
20
+ object_mapping: mappings.ObjectMapping,
21
+ columns: t.List[exp.ColumnDef],
22
+ statement_index: int,
23
+ ):
24
+ source = expr.expression
25
+ if source is None:
26
+ if exec_prop := expr.find(exp.ExecuteAsProperty):
27
+ source = exec_prop.this
28
+
29
+ target = util.get_table(expr)
30
+
31
+ source_type = self._determine_expression_type(source, dialect)
32
+ target_type = self._determine_expression_type(target, dialect)
33
+
34
+ super().__init__(
35
+ dialect=dialect,
36
+ statement=expr,
37
+ statement_index=statement_index,
38
+ object_mapping=object_mapping,
39
+ source_info=SourceInfo(expression=source, type=source_type),
40
+ target_info=TargetInfo(expression=target, type=target_type),
41
+ )
42
+ self.column_defs: t.List[exp.ColumnDef] = columns
43
+ self.system_column_defs: t.List[exp.ColumnDef] = []
44
+ self.inherited_by: t.List[TableQuery] = []
45
+
46
+ self.load_data: bool = True
47
+ if props := expr.args["properties"]:
48
+ if load_data := props.find(exp.WithDataProperty):
49
+ self.load_data: bool = not load_data.args["no"]
50
+
51
+ self.property: str = util.find_property(expr, self.target_info.expression, dialect)
52
+
53
+ def get_column_defs(self, include_system: bool = False) -> t.List[exp.ColumnDef]:
54
+ return self.column_defs + self.system_column_defs if include_system else self.column_defs
55
+
56
+ def get_column_names_with_types(self, include_system: bool = False) -> t.Dict[str, str]:
57
+ """
58
+ Used by sqlglot's MappingSchema
59
+ """
60
+ columns = {col.name: str(col.kind) for col in self.get_column_defs(include_system=include_system)}
61
+ return columns
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from sqlglot import exp
6
+
7
+ from sqlleaf import mappings, util
8
+ from sqlleaf.models.query.base import Query
9
+ from sqlleaf.typing import SqlObjectType, TargetInfo
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class DatabaseQueryProperties:
14
+ location: str | None
15
+
16
+ @classmethod
17
+ def from_expression(cls, expr: exp.Create, dialect: str):
18
+ location = util.get_location_property(expr, dialect)
19
+ return cls(location=location)
20
+
21
+
22
+ class DatabaseQuery(Query):
23
+ KIND = "database"
24
+
25
+ def __init__(
26
+ self,
27
+ expr: exp.Create,
28
+ dialect: str,
29
+ object_mapping: mappings.ObjectMapping,
30
+ statement_index: int,
31
+ ):
32
+ source = None
33
+ target = expr.this
34
+
35
+ super().__init__(
36
+ dialect=dialect,
37
+ statement=expr,
38
+ statement_index=statement_index,
39
+ object_mapping=object_mapping,
40
+ source_info=source,
41
+ target_info=TargetInfo(expression=target, type=SqlObjectType.DATABASE),
42
+ )
43
+
44
+ self.properties = DatabaseQueryProperties.from_expression(expr, dialect)
45
+
46
+ @property
47
+ def location(self) -> str | None:
48
+ return self.properties.location
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from sqlglot import exp
4
+
5
+ from sqlleaf import mappings
6
+ from sqlleaf.models.query.base import Query
7
+ from sqlleaf.typing import SqlObjectType, TargetInfo
8
+
9
+
10
+ class DeleteQuery(Query):
11
+ KIND = "delete"
12
+
13
+ def __init__(self, expr: exp.Delete, dialect: str, object_mapping: mappings.ObjectMapping, statement_index: int):
14
+ super().__init__(
15
+ dialect=dialect,
16
+ statement=expr,
17
+ statement_index=statement_index,
18
+ object_mapping=object_mapping,
19
+ source_info=None,
20
+ target_info=TargetInfo(expression=expr.this, type=SqlObjectType.TABLE),
21
+ )
22
+
23
+ def get_ctes(self):
24
+ if "with_" in self.statement.args:
25
+ return self.statement.args["with_"].expressions
26
+ return []
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import typing as t
4
+ from dataclasses import dataclass
5
+
6
+ import sqlglot
7
+ from sqlglot import exp
8
+
9
+ from sqlleaf import mappings
10
+ from sqlleaf.models.query.base import Query
11
+ from sqlleaf.typing import SqlObjectType, TargetInfo
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ExecuteQueryProperties:
16
+ name: str
17
+ arguments: t.List[exp.Literal]
18
+
19
+ @classmethod
20
+ def from_expression(cls, expr: exp.Command, dialect: str):
21
+ """
22
+ EXECUTE <name>(<arguments>);
23
+ """
24
+ expression_name = expr.expression.name
25
+ parsed = sqlglot.parse_one(expression_name, read=dialect)
26
+
27
+ if isinstance(parsed, exp.Anonymous):
28
+ name = parsed.this
29
+ arguments = parsed.expressions
30
+ else:
31
+ name = parsed.name
32
+ arguments = []
33
+
34
+ return cls(name=name, arguments=arguments)
35
+
36
+
37
+ class ExecuteQuery(Query):
38
+ KIND = "execute"
39
+
40
+ def __init__(
41
+ self,
42
+ expr: exp.Command,
43
+ dialect: str,
44
+ object_mapping: mappings.ObjectMapping,
45
+ statement_index: int,
46
+ ):
47
+ self.parameters = ExecuteQueryProperties.from_expression(expr, dialect)
48
+
49
+ super().__init__(
50
+ dialect=dialect,
51
+ statement=expr,
52
+ statement_index=statement_index,
53
+ object_mapping=object_mapping,
54
+ source_info=None,
55
+ target_info=TargetInfo(
56
+ expression=exp.to_table(self.parameters.name), type=SqlObjectType.PREPARED_STATEMENT
57
+ ),
58
+ )
59
+
60
+ @property
61
+ def name(self) -> str:
62
+ return self.parameters.name
63
+
64
+ @property
65
+ def arguments(self) -> t.List[exp.Literal]:
66
+ return self.parameters.arguments
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import typing as t
4
+
5
+ if t.TYPE_CHECKING:
6
+ from sqlleaf.models.query import Q
7
+ from sqlleaf.models.query.base import Query
8
+
9
+
10
+ class QueryHolder:
11
+ """
12
+ A container that holds up to three versions of the same SQL statement
13
+ as three distinct Query instances.
14
+
15
+ - original: The parsed, unmodified Query.
16
+ - transformed: The Query after transformer.py has processed it.
17
+ - downstream_holders: The containers for downstream queries. Used both for structural
18
+ children (MERGE branches, INSERT ON CONFLICT, UDF bodies) and for
19
+ call-site substituted queries (CallQuery, ExecuteQuery, UDF call sites).
20
+ Populated entirely during the Collector phase.
21
+ """
22
+
23
+ def __init__(self, original: Query):
24
+ self.original: Query = original
25
+ self.transformed: Query | None = None
26
+ self.downstream_holders: t.List[QueryHolder] = []
27
+ self.parent_holder: QueryHolder | None = None
28
+
29
+ self.original.set_holder(self)
30
+
31
+ def add_downstream_holder(self, holder: QueryHolder) -> None:
32
+ holder.parent_holder = self
33
+ self.downstream_holders.append(holder)
34
+
35
+ def get_all_holders(self, types: t.Tuple | None = None) -> t.List[QueryHolder]:
36
+ """
37
+ Recursively collect all holders (self + children).
38
+ Optionally filter by the type of their `original` query.
39
+ """
40
+ holders = [self]
41
+ for child in self.downstream_holders:
42
+ holders.extend(child.get_all_holders(types))
43
+ if types:
44
+ holders = [h for h in holders if isinstance(h.original, types)]
45
+ return holders
46
+
47
+ def set_transformed_query(self, query: Q) -> None:
48
+ self.transformed = query
49
+ self.transformed.set_holder(self)
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlglot
4
+ from sqlglot import exp
5
+
6
+ from sqlleaf import mappings, util
7
+ from sqlleaf.models.query.base import Query
8
+ from sqlleaf.typing import SourceInfo, SqlObjectType, TargetInfo
9
+
10
+
11
+ class InsertQuery(Query):
12
+ KIND = "insert"
13
+
14
+ def __init__(
15
+ self,
16
+ expr: exp.Insert,
17
+ dialect: str,
18
+ object_mapping: mappings.ObjectMapping,
19
+ statement_index: int,
20
+ table: exp.Table | None = None,
21
+ # Whether to skip column annotation of types.
22
+ # Used to preserve column types after conversion from COPY to INSERT.
23
+ skip_annotate: bool = False,
24
+ ):
25
+ if not table:
26
+ table = util.get_table(expr)
27
+
28
+ target = table
29
+ source = expr.expression
30
+ if source:
31
+ # Subquery(Select) -> Select
32
+ source = source.unnest()
33
+ elif src := expr.args.get("source"):
34
+ # INSERT .. DEFAULT VALUES
35
+ source = src
36
+ else:
37
+ # INSERT .. (VALUES())
38
+ exprs = expr.this.expressions
39
+ if len(exprs) == 1 and (ex := exprs[0]):
40
+ if isinstance(ex, exp.Anonymous) and ex.name.upper() == "VALUES":
41
+ # sqlglot parses this incorrectly; fix it by re-parsing it to an exp.Values
42
+ source = sqlglot.parse_one(ex.sql(dialect=dialect), dialect=dialect)
43
+ expr.set("expression", source)
44
+ [e.pop() for e in (expr.this.expressions or [])]
45
+
46
+ # Set the correct source type. Hacky, but works for now
47
+ is_default_values = expr.args.get("default", False)
48
+ if is_default_values:
49
+ source_type = SqlObjectType.VALUES
50
+ elif isinstance(source, exp.Tuple):
51
+ source_type = SqlObjectType.TUPLE
52
+ elif isinstance(source, exp.SetOperation):
53
+ source_type = SqlObjectType.SELECT
54
+ else:
55
+ source_type = self._determine_expression_type(source, dialect)
56
+
57
+ target_type = self._determine_expression_type(target, dialect)
58
+
59
+ super().__init__(
60
+ dialect=dialect,
61
+ statement=expr,
62
+ statement_index=statement_index,
63
+ object_mapping=object_mapping,
64
+ source_info=SourceInfo(expression=source, type=source_type),
65
+ target_info=TargetInfo(expression=target, type=target_type),
66
+ )
67
+
68
+ def get_ctes(self):
69
+ return getattr(self.statement, "ctes", [])
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from sqlglot import exp
4
+
5
+ from sqlleaf import mappings
6
+ from sqlleaf.models.query.base import Query
7
+ from sqlleaf.typing import TargetInfo
8
+
9
+
10
+ class MergeQuery(Query):
11
+ KIND = "merge"
12
+
13
+ def __init__(self, expr: exp.Merge, dialect: str, object_mapping: mappings.ObjectMapping, statement_index: int):
14
+ source = None
15
+ target = expr.this
16
+
17
+ target_type = self._determine_expression_type(target, dialect)
18
+
19
+ super().__init__(
20
+ dialect=dialect,
21
+ statement=expr,
22
+ statement_index=statement_index,
23
+ object_mapping=object_mapping,
24
+ source_info=source,
25
+ target_info=TargetInfo(expression=target, type=target_type),
26
+ )
27
+
28
+ def get_ctes(self):
29
+ return getattr(self.statement, "ctes", [])
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ import typing as t
4
+
5
+ from sqlglot import exp
6
+
7
+ from sqlleaf import mappings
8
+ from sqlleaf.models.query.base import Query
9
+ from sqlleaf.typing import SourceInfo, SqlObjectType, TargetInfo
10
+
11
+
12
+ class MultitableInsertQuery(Query):
13
+ KIND = "multitableinserts"
14
+
15
+ def __init__(
16
+ self,
17
+ expr: exp.MultitableInserts,
18
+ dialect: str,
19
+ object_mapping: mappings.ObjectMapping,
20
+ statement_index: int,
21
+ ):
22
+ source = expr.args["source"]
23
+ source_type = SqlObjectType.SELECT
24
+
25
+ # For multi-table inserts, the target is actually multiple tables,
26
+ # but the base Query model expects a single TargetInfo.
27
+ # We'll use the first one or a dummy for the parent query.
28
+ # The children will have the actual target info.
29
+ # TODO: fix this.
30
+ target = expr.expressions[0].this.this
31
+
32
+ super().__init__(
33
+ dialect=dialect,
34
+ statement=expr,
35
+ statement_index=statement_index,
36
+ object_mapping=object_mapping,
37
+ source_info=SourceInfo(expression=source, type=source_type),
38
+ target_info=TargetInfo(expression=target, type=SqlObjectType.TABLE),
39
+ )
40
+
41
+ def get_ctes(self) -> t.List[exp.CTE]:
42
+ return getattr(self.statement, "ctes", [])
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import re
5
+ import typing as t
6
+
7
+ import sqlglot
8
+ from sqlglot import exp
9
+
10
+ from sqlleaf import exception, mappings, util
11
+ from sqlleaf.models.query.base import Query
12
+ from sqlleaf.typing import SourceInfo, SqlObjectType, TargetInfo
13
+
14
+ logger = logging.getLogger("sqlleaf")
15
+
16
+
17
+ class PrepareQuery(Query):
18
+ KIND = "prepare"
19
+
20
+ def __init__(self, expr: exp.Command, dialect: str, object_mapping: mappings.ObjectMapping, statement_index: int):
21
+ source, target = self.get_source_and_target_expressions(expr)
22
+
23
+ source_type = self._determine_expression_type(source, dialect)
24
+ target_type = SqlObjectType.PREPARED_STATEMENT
25
+
26
+ super().__init__(
27
+ dialect=dialect,
28
+ statement=source,
29
+ statement_index=statement_index,
30
+ object_mapping=object_mapping,
31
+ source_info=SourceInfo(expression=source, type=source_type),
32
+ target_info=TargetInfo(expression=target, type=target_type),
33
+ )
34
+ util.qualify_and_annotate(self.source_info.expression, self.dialect, self.object_mapping)
35
+ self.parameter_count = self._count_parameters(source)
36
+
37
+ def _count_parameters(self, source: exp.Expr) -> int:
38
+ """
39
+ Count the number of expected arguments for this prepared statement.
40
+ Positional parameters are $1, $2, etc.
41
+ """
42
+ params = [int(p.name) for p in source.find_all(exp.Parameter) if p.name.isdigit()]
43
+ return max(params) if params else 0
44
+
45
+ def get_source_and_target_expressions(self, statement: exp.Command) -> t.Tuple[exp.Expr, exp.Table]:
46
+ """
47
+ Parse a PREPARE statement for Postgres.
48
+ Syntax: PREPARE name AS statement
49
+ """
50
+ expression_name = statement.expression.name
51
+
52
+ # Find the 'AS' keyword to split name and statement
53
+ match = re.search(r"\s+AS\s+", expression_name, re.IGNORECASE)
54
+ if not match:
55
+ raise exception.SqlLeafException(message=f"Could not find 'AS' in PREPARE expression: {expression_name}")
56
+
57
+ name = expression_name[: match.start()].strip()
58
+ statement_text = expression_name[match.end() :].strip()
59
+
60
+ prepared_expr = sqlglot.parse_one(statement_text, dialect="postgres")
61
+ return prepared_expr, exp.to_table(name)
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import typing as t
4
+
5
+ from sqlglot import exp
6
+
7
+ from sqlleaf import mappings, util
8
+ from sqlleaf.models.query.base import Query
9
+ from sqlleaf.models.query.user_defined_function import _extract_function_info
10
+ from sqlleaf.typing import TargetInfo
11
+
12
+
13
+ class ProcedureQuery(Query):
14
+ """
15
+ Holds metadata related to stored procedures.
16
+ """
17
+
18
+ KIND = "procedure"
19
+
20
+ def __init__(
21
+ self,
22
+ expr: exp.Create,
23
+ dialect: str,
24
+ object_mapping: mappings.ObjectMapping,
25
+ statement_index: int,
26
+ ):
27
+ table = util.get_table(expr)
28
+ target_type = self._determine_expression_type(table, dialect)
29
+
30
+ super().__init__(
31
+ dialect=dialect,
32
+ statement=expr,
33
+ statement_index=statement_index,
34
+ object_mapping=object_mapping,
35
+ source_info=None,
36
+ target_info=TargetInfo(expression=table, type=target_type),
37
+ )
38
+ self.schema = table.db
39
+ self.procedure = table.name
40
+ self.signature = str(expr.this) # e.g. etl.my_proc(v_session_id VARCHAR)
41
+
42
+ # TODO: support 'default'
43
+ self.column_defs: t.List[exp.ColumnDef] = expr.this.expressions
44
+ _, _, self.parameters = _extract_function_info(expr)
45
+ self.args = [ # e.g. {'name': 'v_session_id', 'type': 'VARCHAR'}
46
+ {"name": p.name, "type": str(p.type)} for p in self.parameters
47
+ ]
48
+ self.inner_statements = self._extract_inner_statements(expr)
49
+
50
+ def _extract_inner_statements(self, expr: exp.Create) -> t.List[exp.Expr]:
51
+ body_expr = expr.args.get("expression")
52
+ if not body_expr:
53
+ return []
54
+
55
+ inner_statements = util.iter_inner_statements(body_expr, self.dialect, wrap=True)
56
+
57
+ # Filter out statements that do not contain lineage (e.g. END;)
58
+ return [
59
+ stmt for stmt in inner_statements if not isinstance(stmt, (exp.EndStatement, exp.Column, exp.Identifier))
60
+ ]
61
+
62
+ def to_dict(self):
63
+ return {
64
+ "id": self.id,
65
+ "name": self.name,
66
+ "signature": self.signature,
67
+ "args": self.args,
68
+ }
69
+
70
+ @property
71
+ def name(self):
72
+ return ".".join([var for var in [self.schema, self.procedure] if var])