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.
- sqlleaf/__init__.py +4 -0
- sqlleaf/dialects/__init__.py +0 -0
- sqlleaf/dialects/plpgsql/__init__.py +0 -0
- sqlleaf/dialects/plpgsql/p_classes.py +275 -0
- sqlleaf/dialects/plpgsql/p_dialect.py +148 -0
- sqlleaf/dialects/plpgsql/p_generator.py +538 -0
- sqlleaf/dialects/plpgsql/p_parser.py +1244 -0
- sqlleaf/dialects/plpgsql/p_tokenizer.py +41 -0
- sqlleaf/exception.py +67 -0
- sqlleaf/lineage.py +326 -0
- sqlleaf/mappings.py +376 -0
- sqlleaf/models/__init__.py +0 -0
- sqlleaf/models/context.py +153 -0
- sqlleaf/models/hook.py +9 -0
- sqlleaf/models/node/__init__.py +27 -0
- sqlleaf/models/node/base.py +148 -0
- sqlleaf/models/node/column.py +345 -0
- sqlleaf/models/node/dynamodb.py +39 -0
- sqlleaf/models/node/function.py +33 -0
- sqlleaf/models/node/interval.py +21 -0
- sqlleaf/models/node/json.py +57 -0
- sqlleaf/models/node/literal.py +17 -0
- sqlleaf/models/node/null.py +14 -0
- sqlleaf/models/node/pivot.py +34 -0
- sqlleaf/models/node/program.py +27 -0
- sqlleaf/models/node/sequence.py +18 -0
- sqlleaf/models/node/star.py +11 -0
- sqlleaf/models/node/stream.py +14 -0
- sqlleaf/models/node/user_defined_function.py +33 -0
- sqlleaf/models/node/var.py +7 -0
- sqlleaf/models/node/variable.py +8 -0
- sqlleaf/models/node/window.py +22 -0
- sqlleaf/models/query/__init__.py +33 -0
- sqlleaf/models/query/base.py +248 -0
- sqlleaf/models/query/call.py +111 -0
- sqlleaf/models/query/copy.py +144 -0
- sqlleaf/models/query/ctas.py +61 -0
- sqlleaf/models/query/database.py +48 -0
- sqlleaf/models/query/delete.py +26 -0
- sqlleaf/models/query/execute.py +66 -0
- sqlleaf/models/query/holder.py +49 -0
- sqlleaf/models/query/insert.py +69 -0
- sqlleaf/models/query/merge.py +29 -0
- sqlleaf/models/query/multitable_insert.py +42 -0
- sqlleaf/models/query/prepare.py +61 -0
- sqlleaf/models/query/procedure.py +72 -0
- sqlleaf/models/query/put.py +38 -0
- sqlleaf/models/query/replace.py +32 -0
- sqlleaf/models/query/schema.py +47 -0
- sqlleaf/models/query/select.py +27 -0
- sqlleaf/models/query/sequence.py +33 -0
- sqlleaf/models/query/set.py +74 -0
- sqlleaf/models/query/stage.py +78 -0
- sqlleaf/models/query/table.py +220 -0
- sqlleaf/models/query/trigger.py +42 -0
- sqlleaf/models/query/type.py +42 -0
- sqlleaf/models/query/unload.py +64 -0
- sqlleaf/models/query/update.py +45 -0
- sqlleaf/models/query/user_defined_function.py +297 -0
- sqlleaf/models/query/values.py +20 -0
- sqlleaf/models/query/view.py +41 -0
- sqlleaf/path.py +194 -0
- sqlleaf/processors/__init__.py +0 -0
- sqlleaf/processors/collector/__init__.py +0 -0
- sqlleaf/processors/collector/collector.py +827 -0
- sqlleaf/processors/collector/substitute.py +396 -0
- sqlleaf/processors/generator/__init__.py +0 -0
- sqlleaf/processors/generator/dialects/__init__.py +5 -0
- sqlleaf/processors/generator/dialects/athena.py +45 -0
- sqlleaf/processors/generator/dialects/base.py +572 -0
- sqlleaf/processors/generator/dialects/mysql.py +19 -0
- sqlleaf/processors/generator/dialects/postgres.py +182 -0
- sqlleaf/processors/generator/dialects/redshift.py +192 -0
- sqlleaf/processors/generator/dialects/snowflake.py +90 -0
- sqlleaf/processors/generator/generator.py +495 -0
- sqlleaf/processors/transformer/__init__.py +31 -0
- sqlleaf/processors/transformer/base.py +899 -0
- sqlleaf/processors/transformer/call.py +9 -0
- sqlleaf/processors/transformer/copy.py +63 -0
- sqlleaf/processors/transformer/ctas.py +39 -0
- sqlleaf/processors/transformer/delete.py +14 -0
- sqlleaf/processors/transformer/execute.py +9 -0
- sqlleaf/processors/transformer/expressions/__init__.py +11 -0
- sqlleaf/processors/transformer/expressions/functional_notation.py +22 -0
- sqlleaf/processors/transformer/expressions/row.py +451 -0
- sqlleaf/processors/transformer/expressions/values.py +318 -0
- sqlleaf/processors/transformer/insert.py +125 -0
- sqlleaf/processors/transformer/merge.py +14 -0
- sqlleaf/processors/transformer/multitable_insert.py +14 -0
- sqlleaf/processors/transformer/replace.py +23 -0
- sqlleaf/processors/transformer/transformer.py +141 -0
- sqlleaf/processors/transformer/udf.py +235 -0
- sqlleaf/processors/transformer/unload.py +30 -0
- sqlleaf/processors/transformer/update.py +174 -0
- sqlleaf/processors/transformer/values.py +10 -0
- sqlleaf/settings/__init__.py +19 -0
- sqlleaf/settings/athena.py +23 -0
- sqlleaf/settings/base.py +37 -0
- sqlleaf/settings/postgres.py +52 -0
- sqlleaf/system_tables/load.py +0 -0
- sqlleaf/typing.py +93 -0
- sqlleaf/util/__init__.py +34 -0
- sqlleaf/util/expression.py +282 -0
- sqlleaf/util/graph.py +116 -0
- sqlleaf/util/helpers.py +42 -0
- sqlleaf/util/iterators.py +37 -0
- sqlleaf-0.0.1.dist-info/METADATA +46 -0
- sqlleaf-0.0.1.dist-info/RECORD +111 -0
- sqlleaf-0.0.1.dist-info/WHEEL +5 -0
- sqlleaf-0.0.1.dist-info/licenses/LICENSE +21 -0
- 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
|