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,41 @@
1
+ from enum import IntEnum
2
+
3
+ from sqlglot.tokens import TokenType
4
+ from sqlglot import tokenizer_core
5
+
6
+ # Monkey patch sqlglot's tokenizer with PL/pgSQL keywords
7
+ tt = tokenizer_core.TokenType
8
+ PLPGSQL_CUSTOM_TOKEN_NAMES = (
9
+ "ASSERT",
10
+ "BY",
11
+ "CLOSE",
12
+ "CONTINUE",
13
+ # Synthetic tokens (not keywords)
14
+ "LABEL_BEGIN", # <<
15
+ "LABEL_END", # >>
16
+ "DDOT",
17
+ "EXIT",
18
+ "FOREACH",
19
+ "IF",
20
+ "LOOP",
21
+ "MOVE",
22
+ "OPEN",
23
+ "PERFORM",
24
+ "RAISE",
25
+ "RETURN",
26
+ "REVERSE",
27
+ "WHILE",
28
+ )
29
+ PLPGSQL_KEYWORD_TOKEN_NAMES = (
30
+ *(token for token in PLPGSQL_CUSTOM_TOKEN_NAMES if token not in {"DDOT", "LABEL_BEGIN", "LABEL_END"}),
31
+ "DECLARE",
32
+ "GET",
33
+ )
34
+ token_dict = {m.name: m.value for m in TokenType}
35
+
36
+ next_value = max(token_dict.values()) + 1
37
+ for token in PLPGSQL_CUSTOM_TOKEN_NAMES:
38
+ token_dict[token] = next_value
39
+ next_value += 1
40
+
41
+ TokenType = IntEnum('TokenType', token_dict)
sqlleaf/exception.py ADDED
@@ -0,0 +1,67 @@
1
+ import logging
2
+
3
+ ERROR_LEVEL = "stop"
4
+
5
+
6
+ logger = logging.getLogger("sqlleaf")
7
+
8
+
9
+ def raise_error(clazz: type, message: str):
10
+ """
11
+ Raise a custom exception based on the error level.
12
+ """
13
+ if ERROR_LEVEL == "stop":
14
+ raise clazz(message)
15
+ elif ERROR_LEVEL == "continue":
16
+ logger.error(message)
17
+
18
+
19
+ def set_error_action(action: str) -> None:
20
+ """
21
+ Set the error level for exceptions.
22
+ """
23
+ if action not in ["continue", "stop"]:
24
+ raise ValueError("The error action must be 'continue' or 'stop'.")
25
+
26
+ global ERROR_LEVEL
27
+ ERROR_LEVEL = action
28
+
29
+
30
+ class SqlGlotException(Exception):
31
+ def __init__(self, message, table=""):
32
+ super().__init__(message)
33
+
34
+ self.message = message
35
+
36
+ def __str__(self):
37
+ return "%s" % (self.message,)
38
+
39
+
40
+ class SqlLeafException(Exception):
41
+ def __init__(self, message):
42
+ super().__init__(message)
43
+
44
+ self.message = message
45
+
46
+ def __str__(self):
47
+ return "%s" % (self.message,)
48
+
49
+
50
+ class InvalidQueryError(SqlLeafException):
51
+ pass
52
+
53
+
54
+ class UnsupportedFeatureError(SqlLeafException):
55
+ pass
56
+
57
+
58
+ class MappingError(SqlLeafException):
59
+ pass
60
+
61
+
62
+ class GeneratorError(SqlLeafException):
63
+ pass
64
+
65
+
66
+ class GraphError(SqlLeafException):
67
+ pass
sqlleaf/lineage.py ADDED
@@ -0,0 +1,326 @@
1
+ import json
2
+ import logging
3
+ import typing as t
4
+
5
+ import networkx as nx
6
+
7
+ from sqlleaf import exception, mappings, path, util
8
+ from sqlleaf.models.hook import Hook
9
+ from sqlleaf.models.node import EdgeAttributes, GraphAttributes, N
10
+ from sqlleaf.models.query import (
11
+ CopyQuery,
12
+ CTASQuery,
13
+ InsertQuery,
14
+ PutQuery,
15
+ Q,
16
+ TableQuery,
17
+ UnloadQuery,
18
+ UpdateQuery,
19
+ ViewQuery,
20
+ )
21
+ from sqlleaf.path import LineagePath
22
+ from sqlleaf.processors.collector import collector
23
+ from sqlleaf.processors.generator import generator
24
+ from sqlleaf.processors.transformer import transformer
25
+ from sqlleaf.typing import SqlObjectType
26
+
27
+ logging.getLogger("sqlglot").setLevel(logging.WARNING)
28
+ logger = logging.getLogger("sqlleaf")
29
+
30
+
31
+ class Lineage:
32
+ """
33
+ Holds the lineage as a networkx graph.
34
+ """
35
+
36
+ def __init__(self):
37
+ self.graph = new_graph() # The graph that contains all lineage
38
+ self.subgraphs: t.List[nx.MultiDiGraph] = [] # The subgraphs that make up the main graph
39
+ self.paths: t.Dict[str, t.List[LineagePath]] = {} # The paths throughout the graph
40
+ self.collected_queries: collector.CollectQueryResult | None = None
41
+ self.user_defined_hooks: list[Hook] = []
42
+ self.object_mapping: mappings.ObjectMapping | None = None
43
+
44
+ def generate(self, sql: str, dialect: str, on_error: str = "stop") -> None:
45
+ """
46
+ Generate lineage for one or more SQL statements.
47
+ """
48
+ exception.set_error_action(action=on_error)
49
+
50
+ if not self.object_mapping:
51
+ self.object_mapping = mappings.ObjectMapping(dialect=dialect)
52
+
53
+ self.collected_queries = collector.collect_queries(
54
+ text=sql, dialect=dialect, object_mapping=self.object_mapping
55
+ )
56
+
57
+ for parent_holder in self.collected_queries.queries:
58
+ # A parent holder wraps a top-level query, possibly containing downstream holders
59
+ graph = new_graph()
60
+ query_holders = parent_holder.get_all_holders()
61
+
62
+ for holder in query_holders:
63
+ transformer.transform_query(holder)
64
+
65
+ for holder in query_holders:
66
+ # Transform and produce lineage only for certain queries
67
+ if query_has_lineage(holder.original):
68
+ generator.generate_lineage_for_query(holder, graph, self.user_defined_hooks)
69
+
70
+ # Associate the query with the graph even if it has no lineage
71
+ graph.graph["attrs"].add_query_to_graph(parent_holder)
72
+ self.merge_graph(graph)
73
+ self.graph.graph["attrs"].add_query_to_graph(parent_holder)
74
+
75
+ def merge_graph(self, subgraph: nx.MultiDiGraph) -> None:
76
+ """
77
+ Merge the subgraph into the main graph, and also track the individual subgraphs.
78
+ """
79
+ self.subgraphs.append(subgraph)
80
+
81
+ for n, data in subgraph.nodes(data=True):
82
+ if self.graph.has_node(n):
83
+ old_node_attrs = self.graph.nodes[n]["attrs"]
84
+
85
+ # The incoming graph's edges must have their NodeAttributes updated to
86
+ # match the existing graph's NodeAttributes.
87
+ # This is because different graphs with identical Nodes will have different NodeAttributes models.
88
+ for par, chi, edge_data in subgraph.edges(data=True):
89
+ # Overwrite the new edge's Node to be the old Node
90
+ if edge_data["attrs"].parent.full_name == n:
91
+ edge_data["attrs"].parent = old_node_attrs
92
+ if edge_data["attrs"].child.full_name == n:
93
+ edge_data["attrs"].child = old_node_attrs
94
+ else:
95
+ self.graph.add_node(n, **data)
96
+
97
+ self.graph.add_edges_from(subgraph.edges(data=True))
98
+
99
+ def get_edges(self) -> t.List[EdgeAttributes]:
100
+ edges = [data["attrs"] for par, chi, data in self.graph.edges(data=True)]
101
+ edges = sorted(edges, key=lambda e: (e.parent.full_name, e.child.full_name))
102
+ for edge in edges:
103
+ logger.debug(f"Found edge: {edge.parent.friendly_name} -> {edge.child.friendly_name}")
104
+ return edges
105
+
106
+ def get_nodes(self) -> t.List[N]:
107
+ nodes = [data["attrs"] for n, data in self.graph.nodes(data=True)]
108
+ # TODO: sort on selected index?
109
+ nodes = sorted(
110
+ nodes, key=lambda e: (getattr(e, "catalog", ""), getattr(e, "schema", ""), getattr(e, "table", ""), e.name)
111
+ )
112
+ return nodes
113
+
114
+ def get_original_queries(self) -> t.List[Q]:
115
+ """
116
+ Get the original queries from the graph.
117
+ """
118
+ return [q.original for q in self.graph.graph["attrs"].queries]
119
+
120
+ def get_transformed_queries(self) -> t.List[Q]:
121
+ """
122
+ Get the transformed queries from the graph.
123
+ """
124
+ return [q.transformed for q in self.graph.graph["attrs"].queries]
125
+
126
+ def get_paths(self) -> t.Generator[LineagePath]:
127
+ """
128
+ paths: [
129
+ {
130
+ "id": "",
131
+ "length": 2,
132
+ "hops": [
133
+ "edge1",
134
+ "edge2
135
+ ]
136
+ }
137
+ ]
138
+ """
139
+ for p in path.find_all_paths(graph=self.graph):
140
+ yield p
141
+
142
+ def print_json(self):
143
+ """
144
+ Print the graph as JSON.
145
+ """
146
+ nodes = self.get_nodes()
147
+ edges = self.get_edges()
148
+ queries = self.get_original_queries()
149
+ paths = self.get_paths()
150
+
151
+ _nodes = [n.to_dict() for n in nodes]
152
+ _edges = [e.to_dict() for e in edges]
153
+ _queries = [q.to_dict() for q in queries]
154
+ _paths = [p.to_dict() for p in paths]
155
+
156
+ print(
157
+ json.dumps(
158
+ {
159
+ "node": _nodes,
160
+ "edges": _edges,
161
+ "queries": _queries,
162
+ "paths": _paths,
163
+ },
164
+ indent=2,
165
+ )
166
+ )
167
+
168
+ def print_tree(self, full_name=False):
169
+ """
170
+ Print from the leaves to the root (as left to right) so that the tree is displayed correctly.
171
+ For example:
172
+
173
+ INSERT INTO fruit.processed
174
+ SELECT SUBSTRING(name, 2, 4) AS name
175
+ FROM fruit.raw
176
+
177
+ Output:
178
+
179
+ column[fruit.processed.name]
180
+ └── function[SUBSTRING]
181
+ ├── column[fruit.raw.name]
182
+ ├── literal[2]
183
+ └── literal[4]
184
+ """
185
+ g = self.graph.reverse() # We print from the leaves to the roots
186
+ root_columns = util.get_root_nodes(g)
187
+ seen = set()
188
+ symbol = "└──"
189
+
190
+ attr = "full_name" if full_name else "friendly_name"
191
+
192
+ # TODO: this may not be needed since the NodeAttributes are node
193
+ for i, root in enumerate(root_columns):
194
+ for depth, edge_attrs in util.find_edges_downward(
195
+ g, root
196
+ ): # TODO: fetch edges in order of function argument index
197
+ # Swap the parent and child
198
+ parent_name = edge_attrs.child.full_name
199
+ child_name = edge_attrs.parent.full_name
200
+ num_descendents_of_child = len(nx.descendants(g, child_name))
201
+
202
+ parent_node = g.nodes[parent_name]["attrs"]
203
+ child_node = g.nodes[child_name]["attrs"]
204
+
205
+ if parent_name not in seen:
206
+ symbol = "└──"
207
+
208
+ # Print arrows
209
+ if depth > 0:
210
+ prefix = ((depth - 1) * 4 * " ") + f"{symbol} "
211
+ else:
212
+ prefix = ""
213
+ print("%s%s" % (prefix, getattr(parent_node, attr)))
214
+ seen.add(parent_name)
215
+
216
+ # Print the child if we're at the end of the path
217
+ if num_descendents_of_child == 0:
218
+ if depth == 0:
219
+ # Direct load (source -> target)
220
+ prefix = "└── "
221
+ else:
222
+ prefix = (depth * 4 * " ") + f"{symbol} "
223
+ print("%s%s" % (prefix, getattr(child_node, attr)))
224
+ seen.add(child_name)
225
+
226
+ def print_paths(self):
227
+ """
228
+ Iterate over all the paths in the graph and print each one.
229
+
230
+ Example output:
231
+ column[fruit.raw.apple] -> function[UPPER] - column[fruit.processed.apple]
232
+ """
233
+ for _path in self.get_paths():
234
+ nodes = _path.node_hops()
235
+ path = ""
236
+
237
+ for i, node in enumerate(nodes):
238
+ path += node.friendly_name
239
+ if i < len(nodes) - 1:
240
+ path += " -> "
241
+ print(path)
242
+
243
+ def register_hooks(self, hooks: list[Hook]) -> None:
244
+ """
245
+ Register user-defined hooks.
246
+
247
+ A hook allows a user to provide their own logic to control how nodes are created.
248
+ Each hook is represented by a Hook object which specifies:
249
+ - name: a unique identifier for the hook
250
+ - kind: the Node class the hook applies to
251
+ - func: a callable that accepts a Node instance and returns the Node to use, or None to skip it
252
+
253
+ A hook runs after a node has been created, but before the node is added to the graph.
254
+ This gives the user maximum control over the node's properties, exposing attributes
255
+ such as the object's name and its underlying SQL expression for inspection.
256
+
257
+ When we attempt to create the path [A -> B -> C] in the graph, if we have a hook that
258
+ prevents the creation of B, then we set A is the previous node, and C as the next,
259
+ resulting in the path [A -> C].
260
+
261
+ Examples:
262
+ ### 1. Never create a FunctionNode.
263
+ hooks = [Hook(name="skip_all_functions", kind=FunctionNode, func=lambda n: None)]
264
+
265
+ ### 2. Only create a FunctionNode if its name is 'SUM'.
266
+ def my_func(node):
267
+ return node if node.name == "SUM" else None
268
+ hooks = [Hook(name="only_sum", kind=FunctionNode, func=my_func)]
269
+ """
270
+ self.user_defined_hooks.extend(hooks)
271
+
272
+ def deregister_hooks(self, hooks: list[Hook]) -> None:
273
+ """
274
+ The inverse of `register_hooks()`: remove a hook (by name) from the list of user-defined hooks.
275
+ """
276
+ self.user_defined_hooks = [udh for udh in self.user_defined_hooks if udh not in hooks]
277
+
278
+ def new_graph() -> nx.MultiDiGraph:
279
+ """
280
+ A graph has attributes along with its node and edges.
281
+ """
282
+ return nx.MultiDiGraph(attrs=GraphAttributes())
283
+
284
+
285
+ QUERIES_WITH_LINEAGE = (
286
+ CTASQuery,
287
+ CopyQuery,
288
+ InsertQuery,
289
+ PutQuery,
290
+ TableQuery,
291
+ UnloadQuery,
292
+ UpdateQuery,
293
+ ViewQuery,
294
+ )
295
+
296
+
297
+ def query_has_lineage(query: Q) -> bool:
298
+ """
299
+ Check if a query has lineage within its expressions.
300
+
301
+ We distinguish between a query's definition and a query that is called.
302
+ That is, in order for a `CREATE FUNCTION` or `CREATE PROCEDURE` to have
303
+ lineage, it must be executed by an invoking statement, e.g. `CALL()` or `SELECT UDF()`
304
+ Simply having statements inside its definition is not sufficient to produce lineage.
305
+ """
306
+ has_lineage = True
307
+ if not isinstance(query, QUERIES_WITH_LINEAGE):
308
+ has_lineage = False
309
+ elif isinstance(query, CopyQuery) and query.source_info.type == SqlObjectType.VALUES:
310
+ # COPY TO STDOUT VALUES (..) # TODO: this should have lineage
311
+ has_lineage = False
312
+ elif isinstance(query, CopyQuery) and not query.is_query_active():
313
+ has_lineage = False
314
+ elif isinstance(query, CTASQuery) and not query.load_data:
315
+ # CREATE TABLE WITH NO DATA
316
+ has_lineage = False
317
+ elif isinstance(query, CTASQuery) and query.source_info.type == SqlObjectType.PREPARED_STATEMENT:
318
+ # CREATE TABLE AS EXECUTE
319
+ has_lineage = False
320
+ elif isinstance(query, TableQuery) and query.property != "external":
321
+ # CREATE EXTERNAL TABLE
322
+ has_lineage = False
323
+
324
+ if not has_lineage:
325
+ logger.debug(f"Query type '{query.__class__.__name__}' does NOT have lineage. Skipping.")
326
+ return has_lineage