sql-glider 0.1.8__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.
- sql_glider-0.1.8.dist-info/METADATA +893 -0
- sql_glider-0.1.8.dist-info/RECORD +34 -0
- sql_glider-0.1.8.dist-info/WHEEL +4 -0
- sql_glider-0.1.8.dist-info/entry_points.txt +9 -0
- sql_glider-0.1.8.dist-info/licenses/LICENSE +201 -0
- sqlglider/__init__.py +3 -0
- sqlglider/_version.py +34 -0
- sqlglider/catalog/__init__.py +30 -0
- sqlglider/catalog/base.py +99 -0
- sqlglider/catalog/databricks.py +255 -0
- sqlglider/catalog/registry.py +121 -0
- sqlglider/cli.py +1589 -0
- sqlglider/dissection/__init__.py +17 -0
- sqlglider/dissection/analyzer.py +767 -0
- sqlglider/dissection/formatters.py +222 -0
- sqlglider/dissection/models.py +112 -0
- sqlglider/global_models.py +17 -0
- sqlglider/graph/__init__.py +42 -0
- sqlglider/graph/builder.py +349 -0
- sqlglider/graph/merge.py +136 -0
- sqlglider/graph/models.py +289 -0
- sqlglider/graph/query.py +287 -0
- sqlglider/graph/serialization.py +107 -0
- sqlglider/lineage/__init__.py +10 -0
- sqlglider/lineage/analyzer.py +1631 -0
- sqlglider/lineage/formatters.py +335 -0
- sqlglider/templating/__init__.py +51 -0
- sqlglider/templating/base.py +103 -0
- sqlglider/templating/jinja.py +163 -0
- sqlglider/templating/registry.py +124 -0
- sqlglider/templating/variables.py +295 -0
- sqlglider/utils/__init__.py +11 -0
- sqlglider/utils/config.py +155 -0
- sqlglider/utils/file_utils.py +38 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Output formatters for lineage results."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import json
|
|
5
|
+
from io import StringIO
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from sqlglider.lineage.analyzer import QueryLineageResult, QueryTablesResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TextFormatter:
|
|
17
|
+
"""Format lineage results as Rich tables for terminal display."""
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def format(results: List[QueryLineageResult], console: Console) -> None:
|
|
21
|
+
"""
|
|
22
|
+
Format and print lineage results as Rich tables.
|
|
23
|
+
|
|
24
|
+
Creates a styled table for each query showing output columns and their sources.
|
|
25
|
+
For column-level lineage, shows Output Column and Source Column.
|
|
26
|
+
For table-level lineage, shows Output Table and Source Table.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
results: List of QueryLineageResult objects
|
|
30
|
+
console: Rich Console instance for output
|
|
31
|
+
"""
|
|
32
|
+
if not results:
|
|
33
|
+
console.print("[yellow]No lineage results found.[/yellow]")
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
for i, result in enumerate(results):
|
|
37
|
+
# Add spacing between tables (except for first)
|
|
38
|
+
if i > 0:
|
|
39
|
+
console.print()
|
|
40
|
+
|
|
41
|
+
# Determine column headers based on level
|
|
42
|
+
if result.level == "column":
|
|
43
|
+
output_header = "Output Column"
|
|
44
|
+
source_header = "Source Column"
|
|
45
|
+
else:
|
|
46
|
+
output_header = "Output Table"
|
|
47
|
+
source_header = "Source Table"
|
|
48
|
+
|
|
49
|
+
# Create table with query info as title
|
|
50
|
+
title = (
|
|
51
|
+
f"Query {result.metadata.query_index}: {result.metadata.query_preview}"
|
|
52
|
+
)
|
|
53
|
+
table = Table(title=title, title_style="bold")
|
|
54
|
+
|
|
55
|
+
table.add_column(output_header, style="cyan")
|
|
56
|
+
table.add_column(source_header, style="green")
|
|
57
|
+
|
|
58
|
+
# Group lineage items by output_name
|
|
59
|
+
output_groups: dict[str, list[str]] = {}
|
|
60
|
+
for item in result.lineage_items:
|
|
61
|
+
if item.output_name not in output_groups:
|
|
62
|
+
output_groups[item.output_name] = []
|
|
63
|
+
if item.source_name: # Skip empty sources
|
|
64
|
+
output_groups[item.output_name].append(item.source_name)
|
|
65
|
+
|
|
66
|
+
# Add rows to table
|
|
67
|
+
row_count = 0
|
|
68
|
+
for output_name in sorted(output_groups.keys()):
|
|
69
|
+
sources = sorted(output_groups[output_name])
|
|
70
|
+
if sources:
|
|
71
|
+
# First source gets the output name
|
|
72
|
+
table.add_row(output_name, sources[0])
|
|
73
|
+
row_count += 1
|
|
74
|
+
# Additional sources get empty output column
|
|
75
|
+
for source in sources[1:]:
|
|
76
|
+
table.add_row("", source)
|
|
77
|
+
row_count += 1
|
|
78
|
+
else:
|
|
79
|
+
# No sources found for this output
|
|
80
|
+
table.add_row(output_name, Text("(no sources)", style="dim"))
|
|
81
|
+
row_count += 1
|
|
82
|
+
|
|
83
|
+
console.print(table)
|
|
84
|
+
console.print(f"[dim]Total: {row_count} row(s)[/dim]")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class JsonFormatter:
|
|
88
|
+
"""Format lineage results as JSON."""
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def format(results: List[QueryLineageResult]) -> str:
|
|
92
|
+
"""
|
|
93
|
+
Format lineage results as JSON.
|
|
94
|
+
|
|
95
|
+
Output format:
|
|
96
|
+
{
|
|
97
|
+
"queries": [
|
|
98
|
+
{
|
|
99
|
+
"query_index": 0,
|
|
100
|
+
"query_preview": "SELECT ...",
|
|
101
|
+
"level": "column",
|
|
102
|
+
"lineage": [
|
|
103
|
+
{"output_name": "table.col_a", "source_name": "src.col_x"},
|
|
104
|
+
{"output_name": "table.col_a", "source_name": "src.col_y"}
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
results: List of QueryLineageResult objects
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
JSON-formatted string
|
|
115
|
+
"""
|
|
116
|
+
queries = []
|
|
117
|
+
for result in results:
|
|
118
|
+
query_data = {
|
|
119
|
+
"query_index": result.metadata.query_index,
|
|
120
|
+
"query_preview": result.metadata.query_preview,
|
|
121
|
+
"level": result.level,
|
|
122
|
+
"lineage": [
|
|
123
|
+
{
|
|
124
|
+
"output_name": item.output_name,
|
|
125
|
+
"source_name": item.source_name,
|
|
126
|
+
}
|
|
127
|
+
for item in result.lineage_items
|
|
128
|
+
],
|
|
129
|
+
}
|
|
130
|
+
queries.append(query_data)
|
|
131
|
+
|
|
132
|
+
return json.dumps({"queries": queries}, indent=2)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class CsvFormatter:
|
|
136
|
+
"""Format lineage results as CSV."""
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def format(results: List[QueryLineageResult]) -> str:
|
|
140
|
+
"""
|
|
141
|
+
Format lineage results as CSV.
|
|
142
|
+
|
|
143
|
+
Column-level output format:
|
|
144
|
+
query_index,output_column,source_column
|
|
145
|
+
0,table.column_a,source_table.column_x
|
|
146
|
+
0,table.column_a,source_table.column_y
|
|
147
|
+
0,table.column_b,source_table2.column_z
|
|
148
|
+
|
|
149
|
+
Table-level output format:
|
|
150
|
+
query_index,output_table,source_table
|
|
151
|
+
0,query_result,customers
|
|
152
|
+
0,query_result,orders
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
results: List of QueryLineageResult objects
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
CSV-formatted string
|
|
159
|
+
"""
|
|
160
|
+
if not results:
|
|
161
|
+
return ""
|
|
162
|
+
|
|
163
|
+
output = StringIO()
|
|
164
|
+
|
|
165
|
+
# Determine column headers based on level
|
|
166
|
+
level = results[0].level if results else "column"
|
|
167
|
+
|
|
168
|
+
if level == "column":
|
|
169
|
+
headers = ["query_index", "output_column", "source_column"]
|
|
170
|
+
else: # table
|
|
171
|
+
headers = ["query_index", "output_table", "source_table"]
|
|
172
|
+
|
|
173
|
+
writer = csv.writer(output)
|
|
174
|
+
writer.writerow(headers)
|
|
175
|
+
|
|
176
|
+
# Write data rows
|
|
177
|
+
for result in results:
|
|
178
|
+
query_index = result.metadata.query_index
|
|
179
|
+
for item in result.lineage_items:
|
|
180
|
+
writer.writerow([query_index, item.output_name, item.source_name])
|
|
181
|
+
|
|
182
|
+
return output.getvalue()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class OutputWriter:
|
|
186
|
+
"""Write formatted output to file or stdout."""
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
def write(content: str, output_file: Optional[Path] = None) -> None:
|
|
190
|
+
"""
|
|
191
|
+
Write content to file or stdout.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
content: The content to write
|
|
195
|
+
output_file: Optional file path. If None, writes to stdout.
|
|
196
|
+
"""
|
|
197
|
+
if output_file:
|
|
198
|
+
output_file.write_text(content, encoding="utf-8")
|
|
199
|
+
else:
|
|
200
|
+
print(content)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class TableTextFormatter:
|
|
204
|
+
"""Format table analysis results as Rich tables for terminal display."""
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def format(results: List[QueryTablesResult], console: Console) -> None:
|
|
208
|
+
"""
|
|
209
|
+
Format and print table analysis results as Rich tables.
|
|
210
|
+
|
|
211
|
+
Creates a styled table for each query showing table names, usage, and types.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
results: List of QueryTablesResult objects
|
|
215
|
+
console: Rich Console instance for output
|
|
216
|
+
"""
|
|
217
|
+
if not results:
|
|
218
|
+
console.print("[yellow]No tables found.[/yellow]")
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
for i, result in enumerate(results):
|
|
222
|
+
# Add spacing between tables (except for first)
|
|
223
|
+
if i > 0:
|
|
224
|
+
console.print()
|
|
225
|
+
|
|
226
|
+
# Create table with query info as title
|
|
227
|
+
title = (
|
|
228
|
+
f"Query {result.metadata.query_index}: {result.metadata.query_preview}"
|
|
229
|
+
)
|
|
230
|
+
table = Table(title=title, title_style="bold")
|
|
231
|
+
|
|
232
|
+
table.add_column("Table Name", style="cyan")
|
|
233
|
+
table.add_column("Usage", style="green")
|
|
234
|
+
table.add_column("Type", style="yellow")
|
|
235
|
+
|
|
236
|
+
# Add rows
|
|
237
|
+
for table_info in result.tables:
|
|
238
|
+
table.add_row(
|
|
239
|
+
table_info.name,
|
|
240
|
+
table_info.usage.value,
|
|
241
|
+
table_info.object_type.value,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
console.print(table)
|
|
245
|
+
console.print(f"[dim]Total: {len(result.tables)} table(s)[/dim]")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class TableJsonFormatter:
|
|
249
|
+
"""Format table analysis results as JSON."""
|
|
250
|
+
|
|
251
|
+
@staticmethod
|
|
252
|
+
def format(results: List[QueryTablesResult]) -> str:
|
|
253
|
+
"""
|
|
254
|
+
Format table analysis results as JSON.
|
|
255
|
+
|
|
256
|
+
Output format:
|
|
257
|
+
{
|
|
258
|
+
"queries": [
|
|
259
|
+
{
|
|
260
|
+
"query_index": 0,
|
|
261
|
+
"query_preview": "SELECT ...",
|
|
262
|
+
"tables": [
|
|
263
|
+
{"name": "schema.table", "usage": "INPUT", "object_type": "UNKNOWN"}
|
|
264
|
+
]
|
|
265
|
+
}
|
|
266
|
+
]
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
results: List of QueryTablesResult objects
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
JSON-formatted string
|
|
274
|
+
"""
|
|
275
|
+
queries = []
|
|
276
|
+
for result in results:
|
|
277
|
+
query_data = {
|
|
278
|
+
"query_index": result.metadata.query_index,
|
|
279
|
+
"query_preview": result.metadata.query_preview,
|
|
280
|
+
"tables": [
|
|
281
|
+
{
|
|
282
|
+
"name": table_info.name,
|
|
283
|
+
"usage": table_info.usage.value,
|
|
284
|
+
"object_type": table_info.object_type.value,
|
|
285
|
+
}
|
|
286
|
+
for table_info in result.tables
|
|
287
|
+
],
|
|
288
|
+
}
|
|
289
|
+
queries.append(query_data)
|
|
290
|
+
|
|
291
|
+
return json.dumps({"queries": queries}, indent=2)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class TableCsvFormatter:
|
|
295
|
+
"""Format table analysis results as CSV."""
|
|
296
|
+
|
|
297
|
+
@staticmethod
|
|
298
|
+
def format(results: List[QueryTablesResult]) -> str:
|
|
299
|
+
"""
|
|
300
|
+
Format table analysis results as CSV.
|
|
301
|
+
|
|
302
|
+
Output format:
|
|
303
|
+
query_index,table_name,usage,object_type
|
|
304
|
+
0,schema.table,INPUT,UNKNOWN
|
|
305
|
+
0,schema.other_table,OUTPUT,TABLE
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
results: List of QueryTablesResult objects
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
CSV-formatted string
|
|
312
|
+
"""
|
|
313
|
+
if not results:
|
|
314
|
+
return ""
|
|
315
|
+
|
|
316
|
+
output = StringIO()
|
|
317
|
+
headers = ["query_index", "table_name", "usage", "object_type"]
|
|
318
|
+
|
|
319
|
+
writer = csv.writer(output)
|
|
320
|
+
writer.writerow(headers)
|
|
321
|
+
|
|
322
|
+
# Write data rows
|
|
323
|
+
for result in results:
|
|
324
|
+
query_index = result.metadata.query_index
|
|
325
|
+
for table_info in result.tables:
|
|
326
|
+
writer.writerow(
|
|
327
|
+
[
|
|
328
|
+
query_index,
|
|
329
|
+
table_info.name,
|
|
330
|
+
table_info.usage.value,
|
|
331
|
+
table_info.object_type.value,
|
|
332
|
+
]
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
return output.getvalue()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""SQL templating system for SQL Glider.
|
|
2
|
+
|
|
3
|
+
This package provides a plugin-based templating system for processing
|
|
4
|
+
SQL templates before analysis. It supports multiple templating engines
|
|
5
|
+
through a plugin architecture based on Python entry points.
|
|
6
|
+
|
|
7
|
+
Built-in templaters:
|
|
8
|
+
- `none`: No-op templater that passes SQL through unchanged
|
|
9
|
+
- `jinja`: Jinja2-based templater with full template syntax support
|
|
10
|
+
|
|
11
|
+
Example:
|
|
12
|
+
>>> from sqlglider.templating import get_templater
|
|
13
|
+
>>> templater = get_templater("jinja")
|
|
14
|
+
>>> sql = "SELECT * FROM {{ schema }}.{{ table }}"
|
|
15
|
+
>>> rendered = templater.render(sql, {"schema": "public", "table": "users"})
|
|
16
|
+
>>> print(rendered)
|
|
17
|
+
SELECT * FROM public.users
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from sqlglider.templating.base import NoOpTemplater, Templater, TemplaterError
|
|
21
|
+
from sqlglider.templating.registry import (
|
|
22
|
+
clear_registry,
|
|
23
|
+
get_templater,
|
|
24
|
+
list_templaters,
|
|
25
|
+
register_templater,
|
|
26
|
+
)
|
|
27
|
+
from sqlglider.templating.variables import (
|
|
28
|
+
load_all_variables,
|
|
29
|
+
load_env_variables,
|
|
30
|
+
load_variables_file,
|
|
31
|
+
merge_variables,
|
|
32
|
+
parse_cli_variables,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
# Base classes
|
|
37
|
+
"Templater",
|
|
38
|
+
"TemplaterError",
|
|
39
|
+
"NoOpTemplater",
|
|
40
|
+
# Registry functions
|
|
41
|
+
"get_templater",
|
|
42
|
+
"list_templaters",
|
|
43
|
+
"register_templater",
|
|
44
|
+
"clear_registry",
|
|
45
|
+
# Variable loading
|
|
46
|
+
"load_all_variables",
|
|
47
|
+
"load_variables_file",
|
|
48
|
+
"parse_cli_variables",
|
|
49
|
+
"load_env_variables",
|
|
50
|
+
"merge_variables",
|
|
51
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Base classes for SQL templating system.
|
|
2
|
+
|
|
3
|
+
This module defines the abstract interface for templaters and provides
|
|
4
|
+
a no-op implementation that passes SQL through unchanged.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TemplaterError(Exception):
|
|
13
|
+
"""Exception raised when templating fails."""
|
|
14
|
+
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Templater(ABC):
|
|
19
|
+
"""Abstract base class for SQL templaters.
|
|
20
|
+
|
|
21
|
+
All templater implementations must inherit from this class and implement
|
|
22
|
+
the required methods. Templaters are discovered via entry points and
|
|
23
|
+
can be used to process SQL files before analysis.
|
|
24
|
+
|
|
25
|
+
Example:
|
|
26
|
+
>>> class MyTemplater(Templater):
|
|
27
|
+
... @property
|
|
28
|
+
... def name(self) -> str:
|
|
29
|
+
... return "my-templater"
|
|
30
|
+
...
|
|
31
|
+
... def render(self, sql, variables=None, source_path=None):
|
|
32
|
+
... # Custom templating logic
|
|
33
|
+
... return processed_sql
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def name(self) -> str:
|
|
39
|
+
"""Return the templater name.
|
|
40
|
+
|
|
41
|
+
This name is used to identify the templater in configuration
|
|
42
|
+
and CLI options.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
The unique name of this templater.
|
|
46
|
+
"""
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def render(
|
|
51
|
+
self,
|
|
52
|
+
sql: str,
|
|
53
|
+
variables: Optional[Dict[str, Any]] = None,
|
|
54
|
+
source_path: Optional[Path] = None,
|
|
55
|
+
) -> str:
|
|
56
|
+
"""Render a SQL template string.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
sql: The SQL template string to render.
|
|
60
|
+
variables: Template variables to substitute. Keys are variable
|
|
61
|
+
names, values can be any type supported by the templater.
|
|
62
|
+
source_path: Optional path to the source file. Used for resolving
|
|
63
|
+
relative paths in includes/extends directives.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
The rendered SQL string with all template expressions evaluated.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
TemplaterError: If templating fails due to syntax errors,
|
|
70
|
+
missing variables, or other issues.
|
|
71
|
+
"""
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class NoOpTemplater(Templater):
|
|
76
|
+
"""A templater that passes SQL through unchanged.
|
|
77
|
+
|
|
78
|
+
This is the default templater when no templating is needed.
|
|
79
|
+
It simply returns the input SQL without any processing.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def name(self) -> str:
|
|
84
|
+
"""Return the templater name."""
|
|
85
|
+
return "none"
|
|
86
|
+
|
|
87
|
+
def render(
|
|
88
|
+
self,
|
|
89
|
+
sql: str,
|
|
90
|
+
variables: Optional[Dict[str, Any]] = None,
|
|
91
|
+
source_path: Optional[Path] = None,
|
|
92
|
+
) -> str:
|
|
93
|
+
"""Pass SQL through unchanged.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
sql: The SQL string.
|
|
97
|
+
variables: Ignored for no-op templater.
|
|
98
|
+
source_path: Ignored for no-op templater.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
The input SQL unchanged.
|
|
102
|
+
"""
|
|
103
|
+
return sql
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Jinja2-based SQL templater.
|
|
2
|
+
|
|
3
|
+
This module provides a Jinja2 implementation of the Templater interface,
|
|
4
|
+
supporting full Jinja2 template syntax including variables, conditionals,
|
|
5
|
+
loops, and includes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
from jinja2 import (
|
|
12
|
+
BaseLoader,
|
|
13
|
+
Environment,
|
|
14
|
+
StrictUndefined,
|
|
15
|
+
TemplateError,
|
|
16
|
+
TemplateNotFound,
|
|
17
|
+
UndefinedError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from sqlglider.templating.base import Templater, TemplaterError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RelativeFileSystemLoader(BaseLoader):
|
|
24
|
+
"""A Jinja2 loader that resolves paths relative to the source file.
|
|
25
|
+
|
|
26
|
+
This loader allows templates to include other templates using paths
|
|
27
|
+
relative to the including file's location.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, base_path: Optional[Path] = None):
|
|
31
|
+
"""Initialize the loader.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
base_path: The base path for resolving relative includes.
|
|
35
|
+
If None, includes will not work.
|
|
36
|
+
"""
|
|
37
|
+
self.base_path = base_path
|
|
38
|
+
|
|
39
|
+
def get_source(self, environment, template):
|
|
40
|
+
"""Load a template from the file system.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
environment: The Jinja2 environment.
|
|
44
|
+
template: The template name/path to load.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
A tuple of (source, filename, uptodate_func).
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
TemplateNotFound: If the template cannot be found.
|
|
51
|
+
"""
|
|
52
|
+
if self.base_path is None:
|
|
53
|
+
raise TemplateNotFound(template)
|
|
54
|
+
|
|
55
|
+
# Resolve the template path relative to base_path
|
|
56
|
+
template_path = self.base_path / template
|
|
57
|
+
|
|
58
|
+
if not template_path.exists():
|
|
59
|
+
raise TemplateNotFound(template)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
source = template_path.read_text(encoding="utf-8")
|
|
63
|
+
# Return source, filename, and a function that returns whether
|
|
64
|
+
# the template is still up to date
|
|
65
|
+
return source, str(template_path), lambda: True
|
|
66
|
+
except (OSError, IOError) as e:
|
|
67
|
+
raise TemplateNotFound(template) from e
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class JinjaTemplater(Templater):
|
|
71
|
+
"""Jinja2-based SQL templater.
|
|
72
|
+
|
|
73
|
+
Supports the full Jinja2 template syntax:
|
|
74
|
+
- Variable substitution: {{ variable }}
|
|
75
|
+
- Conditionals: {% if condition %}...{% endif %}
|
|
76
|
+
- Loops: {% for item in items %}...{% endfor %}
|
|
77
|
+
- Includes: {% include 'other.sql' %}
|
|
78
|
+
- Filters: {{ value | upper }}
|
|
79
|
+
- Comments: {# comment #}
|
|
80
|
+
|
|
81
|
+
Example:
|
|
82
|
+
>>> templater = JinjaTemplater()
|
|
83
|
+
>>> sql = '''
|
|
84
|
+
... SELECT
|
|
85
|
+
... {{ column }},
|
|
86
|
+
... {% if include_total %}SUM(amount) as total{% endif %}
|
|
87
|
+
... FROM {{ schema }}.{{ table }}
|
|
88
|
+
... {% if conditions %}WHERE {{ conditions }}{% endif %}
|
|
89
|
+
... '''
|
|
90
|
+
>>> variables = {
|
|
91
|
+
... "column": "customer_id",
|
|
92
|
+
... "include_total": True,
|
|
93
|
+
... "schema": "sales",
|
|
94
|
+
... "table": "orders",
|
|
95
|
+
... "conditions": "status = 'active'"
|
|
96
|
+
... }
|
|
97
|
+
>>> print(templater.render(sql, variables))
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def name(self) -> str:
|
|
102
|
+
"""Return the templater name."""
|
|
103
|
+
return "jinja"
|
|
104
|
+
|
|
105
|
+
def render(
|
|
106
|
+
self,
|
|
107
|
+
sql: str,
|
|
108
|
+
variables: Optional[Dict[str, Any]] = None,
|
|
109
|
+
source_path: Optional[Path] = None,
|
|
110
|
+
) -> str:
|
|
111
|
+
"""Render a SQL template using Jinja2.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
sql: The SQL template string with Jinja2 syntax.
|
|
115
|
+
variables: Template variables to substitute. If None, an empty
|
|
116
|
+
dict is used.
|
|
117
|
+
source_path: Optional path to the source file. If provided,
|
|
118
|
+
enables {% include %} directives relative to this path.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
The rendered SQL string.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
TemplaterError: If the template has syntax errors or references
|
|
125
|
+
undefined variables.
|
|
126
|
+
"""
|
|
127
|
+
variables = variables or {}
|
|
128
|
+
|
|
129
|
+
# Set up the Jinja2 environment
|
|
130
|
+
if source_path is not None:
|
|
131
|
+
# Use a loader that resolves includes relative to the source file
|
|
132
|
+
base_path = source_path.parent if source_path.is_file() else source_path
|
|
133
|
+
loader = RelativeFileSystemLoader(base_path)
|
|
134
|
+
else:
|
|
135
|
+
loader = None
|
|
136
|
+
|
|
137
|
+
env = Environment(
|
|
138
|
+
loader=loader,
|
|
139
|
+
# Keep whitespace to preserve SQL formatting
|
|
140
|
+
trim_blocks=False,
|
|
141
|
+
lstrip_blocks=False,
|
|
142
|
+
# Enable autoescape for safety (though less relevant for SQL)
|
|
143
|
+
autoescape=False,
|
|
144
|
+
# Undefined variables should raise an error by default
|
|
145
|
+
undefined=StrictUndefined,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
# Compile and render the template
|
|
150
|
+
template = env.from_string(sql)
|
|
151
|
+
return template.render(**variables)
|
|
152
|
+
|
|
153
|
+
except UndefinedError as e:
|
|
154
|
+
raise TemplaterError(f"Undefined variable in template: {e}") from e
|
|
155
|
+
|
|
156
|
+
except TemplateNotFound as e:
|
|
157
|
+
raise TemplaterError(f"Template include not found: {e}") from e
|
|
158
|
+
|
|
159
|
+
except TemplateError as e:
|
|
160
|
+
raise TemplaterError(f"Template error: {e}") from e
|
|
161
|
+
|
|
162
|
+
except Exception as e:
|
|
163
|
+
raise TemplaterError(f"Failed to render template: {e}") from e
|