sqlscope-rs 0.2.0__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.
sqlscope/__init__.py ADDED
@@ -0,0 +1,303 @@
1
+ """Scope-aware SQL analysis and rewriting.
2
+
3
+ ===================== ==========================================================
4
+ Function Purpose
5
+ ===================== ==========================================================
6
+ apply_row_filter Filter every in-scope table by a predicate.
7
+ inject_ctes Prepend CTE definitions to a query's root ``WITH``.
8
+ rewrite_tables Replace table references with derived tables.
9
+ column_origins Source columns whose values reach the result (lineage).
10
+ output_columns Names of the columns a statement outputs.
11
+ referenced_columns Columns referenced anywhere, per table.
12
+ column_usages Column references with the clause they appear in.
13
+ ===================== ==========================================================
14
+
15
+ Every function accepts ``dialect`` (default ``"trino"``). Functions release the
16
+ GIL while they run and are safe to call from several threads.
17
+
18
+ The SQL engine is the sqlscope FFI shared library (``libsqlscope_ffi.so``,
19
+ ``libsqlscope_ffi.dylib`` or ``sqlscope_ffi.dll``) published with each sqlscope
20
+ release. It is loaded on first use; see :func:`load` for how it is located.
21
+ This package neither bundles nor downloads it.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass, field
27
+ from enum import Enum
28
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple, Union
29
+
30
+ from ._library import (
31
+ LIBRARY_PATH_ENV,
32
+ Error,
33
+ InternalError,
34
+ InvalidArgumentError,
35
+ LibraryNotFoundError,
36
+ ParseError,
37
+ UnsupportedError,
38
+ call,
39
+ library_file_name,
40
+ library_version,
41
+ load,
42
+ )
43
+
44
+ __version__ = "0.2.0"
45
+
46
+ __all__ = [
47
+ "Clause",
48
+ "ColumnUsage",
49
+ "CteDef",
50
+ "Error",
51
+ "InternalError",
52
+ "InvalidArgumentError",
53
+ "LIBRARY_PATH_ENV",
54
+ "LibraryNotFoundError",
55
+ "ParseError",
56
+ "Schema",
57
+ "TableRef",
58
+ "TableRewrite",
59
+ "UnionRewrite",
60
+ "UnsupportedError",
61
+ "apply_row_filter",
62
+ "column_origins",
63
+ "column_usages",
64
+ "inject_ctes",
65
+ "library_file_name",
66
+ "library_version",
67
+ "load",
68
+ "output_columns",
69
+ "referenced_columns",
70
+ "rewrite_tables",
71
+ ]
72
+
73
+ Schema = Mapping[str, Sequence[str]]
74
+ """Table name (bare, ``schema.table`` or ``catalog.schema.table``) -> ordered column names."""
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class CteDef:
79
+ """A common table expression ``name AS (query)``.
80
+
81
+ ``name`` is an identifier value, not SQL text; it is quoted as needed.
82
+ """
83
+
84
+ name: str
85
+ query: str
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class TableRef:
90
+ """A table name, optionally schema- and catalog-qualified."""
91
+
92
+ table: str
93
+ schema: Optional[str] = None
94
+ catalog: Optional[str] = None
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class UnionRewrite:
99
+ """A derived table backed by ``UNION DISTINCT`` over ``branches``."""
100
+
101
+ table_alias: str
102
+ columns: Sequence[str]
103
+ branches: Sequence[TableRef]
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class TableRewrite:
108
+ """Replaces references to ``match_key`` (``schema.table``).
109
+
110
+ Set exactly one of ``inline`` (``(SELECT * FROM inline)``) or ``union``.
111
+ """
112
+
113
+ match_key: str
114
+ inline: Optional[TableRef] = None
115
+ union: Optional[UnionRewrite] = None
116
+
117
+
118
+ class Clause(str, Enum):
119
+ """The SQL clause that contains a column reference."""
120
+
121
+ SELECT = "SELECT"
122
+ FROM = "FROM"
123
+ JOIN_ON = "JOIN_ON"
124
+ JOIN_USING = "JOIN_USING"
125
+ WHERE = "WHERE"
126
+ GROUP_BY = "GROUP_BY"
127
+ HAVING = "HAVING"
128
+ QUALIFY = "QUALIFY"
129
+ WINDOW = "WINDOW"
130
+ ORDER_BY = "ORDER_BY"
131
+ SORT_BY = "SORT_BY"
132
+ DISTRIBUTE_BY = "DISTRIBUTE_BY"
133
+ CLUSTER_BY = "CLUSTER_BY"
134
+ CONNECT_BY = "CONNECT_BY"
135
+ LATERAL_VIEW = "LATERAL_VIEW"
136
+ UPDATE_SET_TARGET = "UPDATE_SET_TARGET"
137
+ UPDATE_SET_VALUE = "UPDATE_SET_VALUE"
138
+ MERGE_ON = "MERGE_ON"
139
+ MERGE_WHEN = "MERGE_WHEN"
140
+
141
+ def __str__(self) -> str:
142
+ return self.value
143
+
144
+
145
+ @dataclass(frozen=True, order=True)
146
+ class ColumnUsage:
147
+ """One distinct use of a root table column in a clause."""
148
+
149
+ table: str
150
+ column: str
151
+ clause: Clause = field(compare=True)
152
+
153
+
154
+ def _schema(schema: Optional[Schema]) -> Optional[Dict[str, List[str]]]:
155
+ if schema is None:
156
+ return None
157
+ return {str(table): [str(column) for column in columns] for table, columns in schema.items()}
158
+
159
+
160
+ def _list(values: Optional[Iterable[str]]) -> Optional[List[str]]:
161
+ if values is None:
162
+ return None
163
+ if isinstance(values, str):
164
+ return [values]
165
+ return list(values)
166
+
167
+
168
+ def _run(operation: str, sql: str, *, dialect: Optional[str], **fields: Any) -> Any:
169
+ """Calls ``operation``; ``fields`` holds request fields and options, ``None`` meaning unset."""
170
+ options = {"dialect": dialect}
171
+ for name in ("schema", "tableNames", "tablePatterns", "defaultDb", "stripCatalogs"):
172
+ options[name] = fields.pop(name, None)
173
+ request = {"sql": sql, **fields, "options": {k: v for k, v in options.items() if v is not None}}
174
+ return call(operation, request)
175
+
176
+
177
+ def apply_row_filter(
178
+ sql: str,
179
+ predicate: str,
180
+ *,
181
+ dialect: Optional[str] = None,
182
+ table_names: Optional[Iterable[str]] = None,
183
+ table_patterns: Optional[Iterable[str]] = None,
184
+ default_db: Optional[str] = None,
185
+ ) -> str:
186
+ """Wrap every in-scope table of a query in a derived table filtered by ``predicate``.
187
+
188
+ ``SELECT * FROM a`` becomes ``SELECT * FROM (SELECT * FROM a WHERE <predicate>) AS a``.
189
+ By default every physical table is filtered; ``table_names`` (bare,
190
+ ``schema.table`` or ``catalog.schema.table``), ``table_patterns`` (regular
191
+ expressions) and ``default_db`` restrict the scope. CTE references, table
192
+ functions and ``DUAL`` are never wrapped. ``predicate`` is parsed as one
193
+ boolean expression; bind or escape its values before calling. Returns the
194
+ input unchanged when no table is in scope.
195
+ """
196
+ result: str = _run(
197
+ "apply_row_filter",
198
+ sql,
199
+ predicate=predicate,
200
+ dialect=dialect,
201
+ tableNames=_list(table_names),
202
+ tablePatterns=_list(table_patterns),
203
+ defaultDb=default_db,
204
+ )
205
+ return result
206
+
207
+
208
+ def inject_ctes(
209
+ sql: str,
210
+ ctes: Iterable[Union[CteDef, Tuple[str, str]]],
211
+ *,
212
+ dialect: Optional[str] = None,
213
+ ) -> str:
214
+ """Add CTE definitions to the root ``WITH`` of a query, before existing CTEs.
215
+
216
+ Definitions keep their order, so each may use earlier ones. A name that
217
+ repeats another definition or an existing root CTE is rejected.
218
+ """
219
+ defs = [
220
+ {"name": cte.name, "query": cte.query} if isinstance(cte, CteDef) else {"name": cte[0], "query": cte[1]}
221
+ for cte in ctes
222
+ ]
223
+ result: str = _run("inject_ctes", sql, ctes=defs, dialect=dialect)
224
+ return result
225
+
226
+
227
+ def _table(ref: TableRef) -> Dict[str, Optional[str]]:
228
+ return {"table": ref.table, "schema": ref.schema, "catalog": ref.catalog}
229
+
230
+
231
+ def rewrite_tables(
232
+ sql: str,
233
+ rewrites: Iterable[TableRewrite],
234
+ *,
235
+ dialect: Optional[str] = None,
236
+ strip_catalogs: Optional[Iterable[str]] = None,
237
+ ) -> str:
238
+ """Replace physical table references according to ``rewrites``.
239
+
240
+ Matched references become derived tables that keep their alias (or bare
241
+ name); qualified column references are rebound onto it. Catalogs listed in
242
+ ``strip_catalogs`` are transparent while matching. Returns the input
243
+ unchanged when nothing matches.
244
+ """
245
+ plan = [
246
+ {
247
+ "matchKey": rewrite.match_key,
248
+ "inline": _table(rewrite.inline) if rewrite.inline is not None else None,
249
+ "union": {
250
+ "tableAlias": rewrite.union.table_alias,
251
+ "columns": list(rewrite.union.columns),
252
+ "branches": [_table(branch) for branch in rewrite.union.branches],
253
+ }
254
+ if rewrite.union is not None
255
+ else None,
256
+ }
257
+ for rewrite in rewrites
258
+ ]
259
+ result: str = _run("rewrite_tables", sql, rewrites=plan, dialect=dialect, stripCatalogs=_list(strip_catalogs))
260
+ return result
261
+
262
+
263
+ def column_origins(
264
+ sql: str, *, dialect: Optional[str] = None, schema: Optional[Schema] = None
265
+ ) -> Dict[str, List[str]]:
266
+ """Source columns whose values flow into the result, keyed by root table.
267
+
268
+ Filter-only positions (WHERE, JOIN ON, GROUP BY, HAVING, ORDER BY) and the
269
+ right side of INTERSECT / EXCEPT are excluded. Every table read is present,
270
+ possibly with an empty list.
271
+ """
272
+ result: Dict[str, List[str]] = _run("column_origins", sql, dialect=dialect, schema=_schema(schema))
273
+ return result
274
+
275
+
276
+ def output_columns(
277
+ sql: str, *, dialect: Optional[str] = None, schema: Optional[Schema] = None
278
+ ) -> Optional[List[str]]:
279
+ """Names of the columns a statement outputs, in order.
280
+
281
+ Unaliased expressions are named ``_col{i}``; ``*`` expands from ``schema``
282
+ or stays ``"*"``. Returns ``None`` for statements without columns.
283
+ """
284
+ result: Optional[List[str]] = _run("output_columns", sql, dialect=dialect, schema=_schema(schema))
285
+ return result
286
+
287
+
288
+ def referenced_columns(
289
+ sql: str, *, dialect: Optional[str] = None, schema: Optional[Schema] = None
290
+ ) -> Dict[str, List[str]]:
291
+ """Columns referenced anywhere in a statement (including filters), keyed by root table."""
292
+ result: Dict[str, List[str]] = _run("referenced_columns", sql, dialect=dialect, schema=_schema(schema))
293
+ return result
294
+
295
+
296
+ def column_usages(
297
+ sql: str, *, dialect: Optional[str] = None, schema: Optional[Schema] = None
298
+ ) -> List[ColumnUsage]:
299
+ """Every distinct ``(table, column, clause)`` use in a statement, sorted."""
300
+ return [
301
+ ColumnUsage(usage["table"], usage["column"], Clause(usage["clause"]))
302
+ for usage in _run("column_usages", sql, dialect=dialect, schema=_schema(schema))
303
+ ]
sqlscope/_library.py ADDED
@@ -0,0 +1,157 @@
1
+ """Loads the sqlscope FFI library and calls into it through ctypes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import json
7
+ import os
8
+ import sys
9
+ import threading
10
+ from pathlib import Path
11
+ from typing import Any, Dict, Optional
12
+
13
+ LIBRARY_PATH_ENV = "SQLSCOPE_LIBRARY_PATH"
14
+ """Environment variable that locates the library when :func:`load` gets no path."""
15
+
16
+ _ABI_VERSION = 1
17
+
18
+
19
+ class Error(Exception):
20
+ """Base class of every sqlscope error."""
21
+
22
+
23
+ class InvalidArgumentError(Error):
24
+ """An option or argument is invalid."""
25
+
26
+
27
+ class ParseError(Error):
28
+ """The SQL text could not be parsed."""
29
+
30
+
31
+ class UnsupportedError(Error):
32
+ """The statement shape is not supported, or the input exceeded a safety limit."""
33
+
34
+
35
+ class InternalError(Error):
36
+ """sqlscope produced an invalid result (a bug)."""
37
+
38
+
39
+ class LibraryNotFoundError(Error, OSError):
40
+ """The sqlscope FFI library could not be loaded."""
41
+
42
+
43
+ _ERRORS = {
44
+ "invalid_argument": InvalidArgumentError,
45
+ "parse": ParseError,
46
+ "unsupported": UnsupportedError,
47
+ }
48
+
49
+
50
+ def library_file_name() -> str:
51
+ """The platform's file name of the sqlscope shared library."""
52
+ if sys.platform == "win32":
53
+ return "sqlscope_ffi.dll"
54
+ if sys.platform == "darwin":
55
+ return "libsqlscope_ffi.dylib"
56
+ return "libsqlscope_ffi.so"
57
+
58
+
59
+ class _Library:
60
+ def __init__(self, path: str) -> None:
61
+ try:
62
+ lib = ctypes.CDLL(path)
63
+ except OSError as error:
64
+ raise LibraryNotFoundError(f"cannot load {path}: {error}") from error
65
+ try:
66
+ lib.sqlscope_abi_version.restype = ctypes.c_uint32
67
+ lib.sqlscope_abi_version.argtypes = []
68
+ lib.sqlscope_version.restype = ctypes.c_char_p
69
+ lib.sqlscope_version.argtypes = []
70
+ lib.sqlscope_call.restype = ctypes.c_void_p
71
+ lib.sqlscope_call.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
72
+ lib.sqlscope_free.restype = None
73
+ lib.sqlscope_free.argtypes = [ctypes.c_void_p]
74
+ except AttributeError as error:
75
+ raise LibraryNotFoundError(f"{path} is not a sqlscope library: {error}") from error
76
+ abi = lib.sqlscope_abi_version()
77
+ if abi != _ABI_VERSION:
78
+ raise LibraryNotFoundError(f"{path} implements ABI {abi}, expected {_ABI_VERSION}")
79
+ self.path = path
80
+ self.version: str = lib.sqlscope_version().decode()
81
+ self._lib = lib
82
+
83
+ def call(self, operation: str, request: bytes) -> bytes:
84
+ # ctypes releases the GIL for the duration of the call.
85
+ response = self._lib.sqlscope_call(operation.encode(), request)
86
+ try:
87
+ return ctypes.string_at(response)
88
+ finally:
89
+ self._lib.sqlscope_free(response)
90
+
91
+
92
+ _loaded: Optional[_Library] = None
93
+ _lock = threading.Lock()
94
+
95
+
96
+ def _default_candidates() -> list[str]:
97
+ path = os.environ.get(LIBRARY_PATH_ENV)
98
+ if path:
99
+ return [path]
100
+ name = library_file_name()
101
+ return [str(Path(__file__).resolve().parent / name), name]
102
+
103
+
104
+ def load(path: Optional[str] = None) -> None:
105
+ """Load the sqlscope FFI library, the shared library published with each sqlscope release.
106
+
107
+ Without ``path`` it tries, in order, the path in ``$SQLSCOPE_LIBRARY_PATH``,
108
+ :func:`library_file_name` inside the ``sqlscope`` package directory, and
109
+ :func:`library_file_name` on the system library search path. Nothing is
110
+ ever downloaded.
111
+
112
+ Calling ``load`` is optional: the first operation loads the library the
113
+ same way. Once a library is loaded it stays loaded; loading a different
114
+ path afterwards raises :class:`LibraryNotFoundError`.
115
+ """
116
+ global _loaded
117
+ with _lock:
118
+ if _loaded is not None:
119
+ if path is None or os.fspath(path) == _loaded.path:
120
+ return
121
+ raise LibraryNotFoundError(f"sqlscope library already loaded from {_loaded.path}")
122
+ if path is not None:
123
+ _loaded = _Library(os.fspath(path))
124
+ return
125
+ failures = []
126
+ for candidate in _default_candidates():
127
+ try:
128
+ _loaded = _Library(candidate)
129
+ return
130
+ except LibraryNotFoundError as error:
131
+ failures.append(str(error))
132
+ raise LibraryNotFoundError(
133
+ f"cannot load {library_file_name()}; download it from a sqlscope release and pass its "
134
+ f"path to sqlscope.load() or set {LIBRARY_PATH_ENV}: " + "; ".join(failures)
135
+ )
136
+
137
+
138
+ def _library() -> _Library:
139
+ if _loaded is None:
140
+ load()
141
+ assert _loaded is not None
142
+ return _loaded
143
+
144
+
145
+ def library_version() -> str:
146
+ """The version of the loaded sqlscope FFI library, loading it if needed."""
147
+ return _library().version
148
+
149
+
150
+ def call(operation: str, request: Dict[str, Any]) -> Any:
151
+ """Run one operation and return its decoded result, raising on error."""
152
+ payload = json.dumps(request).encode()
153
+ response = json.loads(_library().call(operation, payload))
154
+ error = response.get("error")
155
+ if error is not None:
156
+ raise _ERRORS.get(error["kind"], InternalError)(error["message"])
157
+ return response["ok"]
sqlscope/py.typed ADDED
File without changes
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.5
2
+ Name: sqlscope-rs
3
+ Version: 0.2.0
4
+ Summary: Scope-aware SQL analysis and rewriting: row filters, CTE injection, table rewrites, column lineage.
5
+ Project-URL: Repository, https://github.com/dcalsky/sqlscope
6
+ License-Expression: MIT
7
+ Keywords: lineage,parser,row-level-security,sql
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Topic :: Database
10
+ Classifier: Typing :: Typed
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+
14
+ # sqlscope (Python)
15
+
16
+ Scope-aware SQL analysis and rewriting, backed by the
17
+ [sqlscope](https://github.com/dcalsky/sqlscope) FFI library.
18
+
19
+ This package is pure Python. It loads the sqlscope shared library
20
+ (`libsqlscope_ffi.so`, `libsqlscope_ffi.dylib` or `sqlscope_ffi.dll`) from a
21
+ [sqlscope release](https://github.com/dcalsky/sqlscope/releases) through
22
+ `ctypes`, and never bundles or downloads it. Point it at the library with
23
+ `SQLSCOPE_LIBRARY_PATH` or `sqlscope.load(path)`; otherwise it looks in the
24
+ package directory and then on the system library search path.
25
+
26
+ ```bash
27
+ pip install sqlscope-rs
28
+ ```
29
+
30
+ ```python
31
+ import sqlscope
32
+
33
+ sqlscope.load("/opt/sqlscope/libsqlscope_ffi.so") # optional with SQLSCOPE_LIBRARY_PATH
34
+
35
+ sqlscope.apply_row_filter("SELECT id FROM orders", "tenant_id = 7", dialect="postgres")
36
+ # 'SELECT id FROM (SELECT * FROM orders WHERE tenant_id = 7) AS orders'
37
+
38
+ sqlscope.column_origins(
39
+ "SELECT o.id, p.amount FROM orders o JOIN payments p ON o.id = p.order_id WHERE o.status = 'PAID'"
40
+ )
41
+ # {'orders': ['id'], 'payments': ['amount']}
42
+ ```
43
+
44
+ See the [project README](https://github.com/dcalsky/sqlscope#readme) for the
45
+ full API.
@@ -0,0 +1,6 @@
1
+ sqlscope/__init__.py,sha256=v4IUB84bPKElg7mUgBDAw4H2vz2dpgSoI6mxqn8yv6U,9757
2
+ sqlscope/_library.py,sha256=4ILjg8eBSf6P6XueGKYZ6161ul_XAHOHuCXEL0tzg28,5124
3
+ sqlscope/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ sqlscope_rs-0.2.0.dist-info/METADATA,sha256=CyTEudoqegKpIt_7Fx_yuDAMd71Z1HKqokWj4E0R5q0,1615
5
+ sqlscope_rs-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
6
+ sqlscope_rs-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any