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.
@@ -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