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 +303 -0
- sqlscope/_library.py +157 -0
- sqlscope/py.typed +0 -0
- sqlscope_rs-0.2.0.dist-info/METADATA +45 -0
- sqlscope_rs-0.2.0.dist-info/RECORD +6 -0
- sqlscope_rs-0.2.0.dist-info/WHEEL +4 -0
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,,
|