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,64 @@
1
+ from __future__ import annotations
2
+
3
+ import typing as t
4
+ from dataclasses import dataclass
5
+
6
+ import sqlglot
7
+ from sqlglot import TokenType, exp
8
+
9
+ from sqlleaf import exception, mappings, util
10
+ from sqlleaf.models.query.base import Query
11
+ from sqlleaf.typing import SourceInfo, TargetInfo
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class UnloadQueryProperties:
16
+ file_format: str
17
+
18
+
19
+ class UnloadQuery(Query):
20
+ KIND = "unload"
21
+
22
+ def __init__(self, expr: exp.Command, dialect: str, object_mapping: mappings.ObjectMapping, statement_index: int):
23
+ source, target = self.get_source_and_target_expressions(expr)
24
+
25
+ source_type = self._determine_expression_type(source, dialect)
26
+ target_type = self._determine_expression_type(target, dialect)
27
+
28
+ super().__init__(
29
+ dialect=dialect,
30
+ statement=source,
31
+ statement_index=statement_index,
32
+ object_mapping=object_mapping,
33
+ source_info=SourceInfo(expression=source, type=source_type),
34
+ target_info=TargetInfo(expression=target, type=target_type),
35
+ )
36
+ self.parameters = UnloadQueryProperties(file_format="UNKNOWN")
37
+ util.qualify_and_annotate(self.source_info.expression, self.dialect, self.object_mapping)
38
+
39
+ def get_source_and_target_expressions(self, statement: exp.Command) -> t.Tuple[exp.Select, exp.Literal]:
40
+ """
41
+ Parse an UNLOAD statement for Redshift.
42
+ We parse this ourselves due to missing support in sqlglot.
43
+ """
44
+ # Syntax: "UNLOAD ('SELECT ...') TO ..."
45
+ expected_tokens = [TokenType.L_PAREN, TokenType.STRING, TokenType.R_PAREN, TokenType.VAR, TokenType.STRING]
46
+ actual_tokens = sqlglot.tokenize(statement.expression.name, dialect="redshift")
47
+
48
+ # Basic validation - ensure the token types match
49
+ for i in range(len(expected_tokens)):
50
+ if expected_tokens[i] != actual_tokens[i].token_type:
51
+ # This may be incorrect! Use the parser instead once available.
52
+ raise exception.InvalidQueryError(
53
+ message=f"Invalid syntax for UNLOAD expression: {statement.sql(dialect='redshift')}"
54
+ )
55
+
56
+ select_expr = sqlglot.parse_one(actual_tokens[1].text, dialect="redshift")
57
+ if not isinstance(select_expr, exp.Select):
58
+ raise exception.InvalidQueryError(
59
+ message=f"Invalid expression inside UNLOAD. Expected SELECT "
60
+ f"but got: {select_expr.sql(dialect='redshift')}"
61
+ )
62
+
63
+ to_location = actual_tokens[4].text
64
+ return select_expr, t.cast(exp.Literal, exp.convert(to_location))
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from sqlglot import exp
4
+
5
+ from sqlleaf import mappings, util
6
+ from sqlleaf.models.query.base import Query
7
+ from sqlleaf.typing import SourceInfo, TargetInfo
8
+
9
+
10
+ class UpdateQuery(Query):
11
+ KIND = "update"
12
+
13
+ def __init__(
14
+ self,
15
+ expr: exp.Update,
16
+ dialect: str,
17
+ object_mapping: mappings.ObjectMapping,
18
+ statement_index: int,
19
+ table: exp.Table | None = None,
20
+ ):
21
+ if not table:
22
+ table = util.get_table(expr)
23
+
24
+ if isinstance(expr, exp.OnConflict):
25
+ source = expr
26
+ else:
27
+ source = expr.args["expressions"]
28
+ target = table
29
+
30
+ source_type = self._determine_expression_type(source, dialect)
31
+ target_type = self._determine_expression_type(target, dialect)
32
+
33
+ super().__init__(
34
+ dialect=dialect,
35
+ statement=expr,
36
+ statement_index=statement_index,
37
+ object_mapping=object_mapping,
38
+ source_info=SourceInfo(expression=source, type=source_type),
39
+ target_info=TargetInfo(expression=target, type=target_type),
40
+ )
41
+ self.only = table.args.get("only", False) if table else False # Not available inside a MERGE
42
+
43
+ def get_ctes(self):
44
+ with_ = self.statement.args.get("with_", None)
45
+ return with_.expressions if with_ else []
@@ -0,0 +1,297 @@
1
+ from __future__ import annotations
2
+
3
+ import typing as t
4
+ from dataclasses import dataclass
5
+
6
+ from sqlglot import exp
7
+
8
+ from sqlleaf import mappings, util
9
+ from sqlleaf.models.query.base import Query
10
+ from sqlleaf.typing import TargetInfo
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class UserDefinedFunctionParameters:
15
+ schema_name: t.Optional[str]
16
+ function_name: str
17
+ return_type: t.Optional[exp.DataType]
18
+ language: t.Optional[str]
19
+ parameters: t.List[FunctionParam]
20
+ return_columns: t.List[exp.ColumnDef]
21
+ inner_statements: t.List[exp.Expr]
22
+
23
+ @classmethod
24
+ def from_expression(cls, expr: exp.Create, object_mapping: mappings.ObjectMapping) -> UserDefinedFunctionParameters:
25
+ """
26
+ Collect the required information from the function DDL.
27
+ """
28
+ function_name, schema_name, parameters = _extract_function_info(expr)
29
+ return_type, return_columns = _extract_return_info(expr, parameters, object_mapping)
30
+
31
+ # Filter parameters to only include those that can be passed as input
32
+ input_parameters = [p for p in parameters if p.is_input or p.is_variadic]
33
+
34
+ language = _extract_language(expr)
35
+
36
+ body_expr = expr.args.get("expression")
37
+ inner_statements = []
38
+
39
+ if body_expr:
40
+ inner_statements = util.iter_inner_statements(body_expr, object_mapping.dialect, wrap=True)
41
+
42
+ return cls(
43
+ schema_name=schema_name,
44
+ function_name=function_name,
45
+ return_type=return_type,
46
+ language=language,
47
+ parameters=input_parameters,
48
+ return_columns=return_columns,
49
+ inner_statements=inner_statements,
50
+ )
51
+
52
+
53
+ class UserDefinedFunctionQuery(Query):
54
+ KIND = "udf"
55
+
56
+ def __init__(
57
+ self,
58
+ expr: exp.Create,
59
+ dialect,
60
+ object_mapping: mappings.ObjectMapping,
61
+ statement_index: int,
62
+ ):
63
+ source = None
64
+ target = expr.this.this
65
+
66
+ target_type = self._determine_expression_type(target, dialect)
67
+
68
+ super().__init__(
69
+ dialect=dialect,
70
+ statement=expr,
71
+ statement_index=statement_index,
72
+ object_mapping=object_mapping,
73
+ source_info=source,
74
+ target_info=TargetInfo(expression=target, type=target_type),
75
+ )
76
+
77
+ self.properties = self.get_params()
78
+ self.column_defs = self.properties.return_columns
79
+
80
+ @property
81
+ def parameters(self) -> t.List[FunctionParam]:
82
+ return self.properties.parameters
83
+
84
+ @property
85
+ def schema_name(self) -> t.Optional[str]:
86
+ return self.properties.schema_name
87
+
88
+ @property
89
+ def function_name(self) -> str:
90
+ return self.properties.function_name
91
+
92
+ @property
93
+ def return_type(self) -> t.Optional[exp.DataType]:
94
+ return self.properties.return_type
95
+
96
+ @property
97
+ def language(self) -> t.Optional[str]:
98
+ return self.properties.language
99
+
100
+ @property
101
+ def return_columns(self) -> t.List[exp.ColumnDef]:
102
+ return self.properties.return_columns
103
+
104
+ @property
105
+ def inner_statements(self) -> t.List[exp.Expr]:
106
+ return self.properties.inner_statements
107
+
108
+ @property
109
+ def name(self):
110
+ return ".".join([var for var in [self.schema_name, self.function_name] if var])
111
+
112
+ def get_params(self) -> UserDefinedFunctionParameters:
113
+ return UserDefinedFunctionParameters.from_expression(self.statement, self.object_mapping)
114
+
115
+
116
+ def get_user_defined_data_type(kind: t.Optional[str | exp.Identifier | exp.Dot] = None) -> exp.DataType:
117
+ if kind:
118
+ if isinstance(kind, str):
119
+ if "." in kind:
120
+ parts = kind.split(".")
121
+ kind = exp.Dot.build([exp.Identifier(this=p, quoted=False) for p in parts])
122
+ else:
123
+ kind = exp.Identifier(this=kind, quoted=False)
124
+ return exp.DataType.build(kind, udt=True, dialect="postgres")
125
+ return exp.DataType.build("USER-DEFINED", dialect="postgres")
126
+
127
+
128
+ @dataclass
129
+ class FunctionParam:
130
+ """A parameter of a user-defined function."""
131
+
132
+ name: str
133
+ type: exp.DataType
134
+ default: t.Optional[exp.Expression] = None
135
+ is_input: bool = True
136
+ is_output: bool = False
137
+ is_variadic: bool = False
138
+
139
+
140
+ def _extract_function_info(expression: exp.Create) -> t.Tuple[str, t.Optional[str], t.List[FunctionParam]]:
141
+ """Extracts the name, schema, and parameters from a CREATE FUNCTION expression."""
142
+ target = expression.this
143
+ parameters = []
144
+
145
+ # Process the function definition which includes name and parameters
146
+ if isinstance(target, exp.UserDefinedFunction):
147
+ function_expr = target.this
148
+ # Iterate through column definitions to find parameters
149
+ for i, col_def in enumerate(target.expressions):
150
+ if isinstance(col_def, exp.ColumnDef):
151
+ param_name = col_def.this.name
152
+ param_type = col_def.kind
153
+ param_default = None
154
+ is_input = True
155
+ is_output = False
156
+ is_variadic = False
157
+
158
+ # Determine the parameter types (IN, OUT, etc)
159
+ constraints = col_def.args.get("constraints")
160
+ if constraints:
161
+ for constraint in constraints:
162
+ kind = constraint.args.get("kind")
163
+ if isinstance(kind, exp.DefaultColumnConstraint):
164
+ param_default = kind.this
165
+ elif isinstance(constraint, exp.InOutColumnConstraint):
166
+ is_input = constraint.args.get("input_")
167
+ is_output = constraint.args.get("output")
168
+ is_variadic = constraint.args.get("variadic")
169
+
170
+ # Unnamed parameter logic inside ColumnDef (e.g., VARIADIC numeric[])
171
+ if param_name.upper() == "VARIADIC":
172
+ is_variadic = True
173
+ param_name = f"${i + 1}"
174
+ # The type might be stored in a way that includes the rest of the definition
175
+ # In Postgres unnamed VARIADIC, sqlglot might parse it such that 'variadic' is the name
176
+ # and the type is numeric[]
177
+
178
+ # Handle special keywords used as parameter names by assigning positional placeholders
179
+ if param_name.upper() in ("IN", "OUT", "INOUT"):
180
+ if param_name.upper() == "OUT":
181
+ is_input = False
182
+ is_output = True
183
+ elif param_name.upper() == "INOUT":
184
+ is_input = True
185
+ is_output = True
186
+ param_name = f"${i + 1}"
187
+ parameters.append(
188
+ FunctionParam(
189
+ name=param_name,
190
+ type=param_type,
191
+ default=param_default,
192
+ is_input=is_input,
193
+ is_output=is_output,
194
+ is_variadic=is_variadic,
195
+ )
196
+ )
197
+ elif _is_unnamed_parameter(col_def):
198
+ # Handle unnamed parameters like CREATE FUNCTION hello(TEXT) or (VARIADIC numeric[])
199
+ param_name = f"${i + 1}"
200
+ is_variadic = False
201
+
202
+ type_node = col_def
203
+ if isinstance(col_def, exp.UserDefinedFunction):
204
+ is_variadic = col_def.sql().upper().startswith("variadic")
205
+ type_node = col_def.this
206
+
207
+ type_sql = type_node.this.lower()
208
+
209
+ if type_sql in ("anyelement", "anyarray"):
210
+ param_type = get_user_defined_data_type(kind=type_sql)
211
+ else:
212
+ try:
213
+ param_type = exp.DataType.build(type_node.this, dialect="postgres")
214
+ except Exception:
215
+ # e.g. for tables as parameters - CREATE FUNCTION hello(people), where 'people' is a table
216
+ param_type = get_user_defined_data_type(kind=type_node.this)
217
+
218
+ parameters.append(FunctionParam(name=param_name, type=param_type, is_variadic=is_variadic))
219
+ else:
220
+ function_expr = target.this
221
+
222
+ # Determine function name and schema from the table-like expression structure
223
+ if isinstance(function_expr, exp.Table):
224
+ name = function_expr.name
225
+ schema = function_expr.args.get("db").this if function_expr.args.get("db") else None
226
+ else:
227
+ name = function_expr.sql()
228
+ schema = None
229
+
230
+ return name, schema, parameters
231
+
232
+
233
+ def _is_unnamed_parameter(col_def: exp.ColumnDef) -> bool:
234
+ return isinstance(col_def, exp.Identifier) or (
235
+ isinstance(col_def, exp.UserDefinedFunction) and isinstance(col_def.this, exp.Identifier)
236
+ )
237
+
238
+
239
+ def _extract_return_info(
240
+ expression: exp.Create, parameters: t.List[FunctionParam], object_mapping: mappings.ObjectMapping
241
+ ) -> t.Tuple[t.Optional[exp.DataType], t.List[exp.ColumnDef]]:
242
+ """Extracts return type and return columns from a CREATE FUNCTION expression."""
243
+ return_type = None
244
+ return_columns = []
245
+
246
+ # Check for OUT/INOUT parameters that determine the return structure
247
+ out_params: t.List[FunctionParam] = [p for p in parameters if p.is_output]
248
+
249
+ if out_params:
250
+ if len(out_params) == 1:
251
+ return_type = out_params[0].type
252
+ else:
253
+ return_type = get_user_defined_data_type()
254
+ for param in out_params:
255
+ col_name = param.name
256
+ if col_name.startswith("$"):
257
+ col_name = f"column{col_name[1:]}"
258
+ return_columns.append(exp.ColumnDef(this=exp.to_identifier(col_name), kind=param.type))
259
+
260
+ # Loop through properties to find the RETURNS clause
261
+ for prop in expression.args.get("properties", {}).expressions:
262
+ if isinstance(prop, exp.ReturnsProperty):
263
+ if prop.this is None:
264
+ continue
265
+
266
+ # Check if it's a RETURNS TABLE(...) with multiple columns
267
+ if isinstance(prop.this, exp.Schema):
268
+ return_type = get_user_defined_data_type()
269
+ # Extract individual column definitions for table-returning functions
270
+ for col_def in prop.this.expressions:
271
+ if isinstance(col_def, exp.ColumnDef):
272
+ return_columns.append(col_def)
273
+ else:
274
+ # Standard return type (e.g., RETURNS TEXT, RETURNS person, RETURNS TABLE_NAME)
275
+ return_type_sql = prop.this.sql().lower()
276
+ return_type = prop.this
277
+
278
+ if return_type_sql == "void":
279
+ return_type = exp.DataType.build("NULL")
280
+ else:
281
+ target_table = exp.to_table(return_type_sql)
282
+ found_query = object_mapping.lookup_type_query(target_table, raise_on_missing=False)
283
+ if not found_query:
284
+ found_query = object_mapping.lookup_table_query(target_table, raise_on_missing=False)
285
+
286
+ if found_query:
287
+ return_columns = found_query.get_column_defs()
288
+
289
+ return return_type, return_columns
290
+
291
+
292
+ def _extract_language(expression: exp.Create) -> t.Optional[str]:
293
+ """Extracts the language from a CREATE FUNCTION expression."""
294
+ for prop in expression.args.get("properties", {}).expressions:
295
+ if isinstance(prop, exp.LanguageProperty):
296
+ return prop.this.sql().lower()
297
+ return None
@@ -0,0 +1,20 @@
1
+ from sqlglot import exp
2
+
3
+ from sqlleaf.models.query.base import Query
4
+ from sqlleaf.typing import SourceInfo, SqlObjectType, TargetInfo
5
+
6
+
7
+ class ValuesQuery(Query):
8
+ KIND = "values"
9
+
10
+ def __init__(self, expr: exp.Values, dialect: str, object_mapping, statement_index: int):
11
+ source_info = SourceInfo(expression=expr, type=SqlObjectType.VALUES)
12
+ target_info = TargetInfo(expression=None, type=SqlObjectType.NONE)
13
+ super().__init__(
14
+ dialect=dialect,
15
+ statement=expr,
16
+ statement_index=statement_index,
17
+ object_mapping=object_mapping,
18
+ source_info=source_info,
19
+ target_info=target_info,
20
+ )
@@ -0,0 +1,41 @@
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 ViewQuery(Query):
14
+ KIND = "view"
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
+ target = util.get_table(expr)
26
+
27
+ source_type = self._determine_expression_type(source, dialect)
28
+ target_type = self._determine_expression_type(target, dialect)
29
+
30
+ super().__init__(
31
+ dialect=dialect,
32
+ statement=expr,
33
+ statement_index=statement_index,
34
+ object_mapping=object_mapping,
35
+ source_info=SourceInfo(expression=source, type=source_type),
36
+ target_info=TargetInfo(expression=target, type=target_type),
37
+ )
38
+ self.column_defs: t.List[exp.ColumnDef] = columns
39
+ self.inherited_by: t.List[TableQuery] = []
40
+
41
+ self.property: str = util.find_property(expr, self.target_info.expression, dialect)
sqlleaf/path.py ADDED
@@ -0,0 +1,194 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import typing as t
5
+
6
+ import networkx as nx
7
+
8
+ from sqlleaf import exception, util
9
+ from sqlleaf.models.node import EdgeAttributes, N
10
+
11
+ logger = logging.getLogger("sqlleaf")
12
+
13
+
14
+ class LineagePath:
15
+ def __init__(self, hops: t.List[EdgeAttributes]):
16
+ self.hops = hops
17
+ self.length = len(hops)
18
+
19
+ @property
20
+ def id(self) -> str:
21
+ return "path:" + util.short_sha256_hash(":".join(self.get_edge_ids()))
22
+
23
+ def node_hops(self) -> t.List[N]:
24
+ """
25
+ Return the list of nodes on this path.
26
+ """
27
+ hops = [self.hops[0].parent]
28
+ for hop in self.hops:
29
+ hops.append(hop.child)
30
+ return hops
31
+
32
+ def get_edge_ids(self) -> list[str]:
33
+ """
34
+ In order to distinguish between multiple edges that are part of the same path,
35
+ we need to create a unique id based off data that differentiates them.
36
+ This is done using the edges' "id" attribute.
37
+ """
38
+ return [edge.id for edge in self.hops]
39
+
40
+ def to_dict(self) -> dict:
41
+ return {
42
+ "id": self.id,
43
+ "length": len(self.hops),
44
+ "hops": [edge.id for edge in self.hops],
45
+ }
46
+
47
+
48
+ def find_all_paths(graph: nx.MultiDiGraph) -> t.Generator[LineagePath]:
49
+ """
50
+ Find all the unique paths in the graph.
51
+
52
+ There are two algorithms depending on the graph's structure:
53
+ - one for those containing root node (a Rooted graph)
54
+ - one for those containing only cycles (a Circuit graph)
55
+ """
56
+
57
+ # Cycle handling logic.
58
+ # [x] Stage 1. Error: Throw an error/warning on cycles that include multiple columns.
59
+ # [ ] Stage 2. Recovery: For every cycle, print it to the console, and then
60
+ # remove all their edges; then proceed with printing the remainder.
61
+ cycles = []
62
+ cycle_errors = 0
63
+
64
+ for cycle, errors in util.get_cycles(graph):
65
+ if errors > 0:
66
+ cycle_errors += errors
67
+ else:
68
+ cycles.append(cycle)
69
+
70
+ if cycle_errors:
71
+ raise exception.GraphError(message=f"Found {cycle_errors} errors with cycles in graph. Remove these.")
72
+
73
+ cycles = sorted(cycles) # simple_cycles() underneath is non-deterministic
74
+ root_columns = util.get_root_nodes(graph)
75
+
76
+ if root_columns:
77
+ logger.debug(f"Found root columns in graph: {root_columns}")
78
+ for i, root in enumerate(root_columns):
79
+ for path in find_edges_from_root(graph, root):
80
+ if not path:
81
+ continue
82
+
83
+ if path[0].parent.id == path[0].child.id:
84
+ # Skip selfloops; these are covered in the dedicated loop below
85
+ continue
86
+
87
+ logger.debug(
88
+ "Found edge path using root: %s --- %s",
89
+ [e.id for e in path],
90
+ [(e.parent.friendly_name, e.child.friendly_name) for e in path],
91
+ )
92
+ lineage_path = LineagePath(hops=path)
93
+ yield lineage_path
94
+
95
+ # Check for selfloops (e.g. a column that goes through another node, such as a function)
96
+ for cycle in cycles:
97
+ for path in find_edges_along_cycle_path(graph, cycle):
98
+ logger.debug(
99
+ "Found edge path from cycle: %s --- %s",
100
+ [e.id for e in path],
101
+ [(e.parent.friendly_name, e.child.friendly_name) for e in path],
102
+ )
103
+ lineage_path = LineagePath(hops=path)
104
+ yield lineage_path
105
+
106
+
107
+ def find_edges_along_cycle_path(
108
+ g: nx.MultiDiGraph, cycle: t.List[str], path: t.Optional[t.List[EdgeAttributes]] = None
109
+ ) -> t.Generator[t.List[EdgeAttributes]]:
110
+ """
111
+ Given a cycle, find all the edges along it and return these as the new path.
112
+ A path must be provided so that iteration doesn't deviate into non-cycle paths.
113
+ """
114
+ if path is None:
115
+ path = []
116
+
117
+ depth = len(path)
118
+ if depth == len(cycle) - 1:
119
+ yield path
120
+ else:
121
+ node_src = cycle[depth]
122
+ node_dst = cycle[depth + 1]
123
+ edges = g.get_edge_data(node_src, node_dst)
124
+ for idx, data in edges.items():
125
+ hop = data["attrs"]
126
+ yield from find_edges_along_cycle_path(g, cycle, path + [hop])
127
+
128
+
129
+ def find_edges_from_root(
130
+ g: nx.MultiDiGraph, node: str, path: t.Optional[t.List[EdgeAttributes]] = None, seen: t.Optional[t.Set[str]] = None
131
+ ) -> t.Generator[t.List[EdgeAttributes]]:
132
+ """
133
+ Find all the complete paths in a graph by traversing the descendants of a node until we find
134
+ a node without any descendants.
135
+
136
+ This is the same as the regular find_paths(), but we iterate over all the edges between any two node
137
+ so that we include all paths.
138
+
139
+ For example, given the graph:
140
+ A -> B -> C -> D
141
+
142
+ If there are two edges between A->B and two edges between C->D:
143
+ __ __
144
+ / \\ / \
145
+ --> A B --> C D -->
146
+ \\__/ \\__/
147
+
148
+ then traversing all the edges gives:
149
+ A -> B -> C -> D
150
+ A -> B -> C -> D
151
+ A -> B -> C -> D
152
+ A -> B -> C -> D
153
+
154
+ Thus we consider each path during traversal, as each likely has slightly different attributes.
155
+ """
156
+ if path is None:
157
+ path = []
158
+ if seen is None:
159
+ seen = set()
160
+
161
+ if node in seen:
162
+ yield path
163
+ else:
164
+ # If there's a loop [.., X, .., X], discard everything outside of the Xs as the path.
165
+ if selfloop_edges := g.get_edge_data(node, node):
166
+ for idx, data in selfloop_edges.items():
167
+ hop = data["attrs"]
168
+ yield [hop]
169
+
170
+ # Get direct descendants
171
+ desc = nx.descendants_at_distance(g, node, 1)
172
+ if not desc:
173
+ yield path
174
+ else:
175
+ desc = sorted(desc) # nx.desc() above is non-deterministic
176
+ for n in desc:
177
+ yield from _traverse_path_along_edges(g, node, n, path, seen)
178
+
179
+
180
+ def _traverse_path_along_edges(
181
+ g: nx.MultiDiGraph, node_src: str, node_dst: str, path: t.Optional[t.List] = None, seen: t.Optional[t.Set] = None
182
+ ) -> t.Generator[t.List[EdgeAttributes]]:
183
+ """
184
+ Get the list of edges between two node, and find the paths for each of them.
185
+ """
186
+ if path is None:
187
+ path = []
188
+ if seen is None:
189
+ seen = set()
190
+
191
+ edges = g.get_edge_data(node_src, node_dst)
192
+ for idx, data in edges.items():
193
+ hop = data["attrs"]
194
+ yield from find_edges_from_root(g, node_dst, path + [hop], seen.union([node_src]))
File without changes
File without changes