relvector 0.1.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.
- relvector/__init__.py +48 -0
- relvector/_version.py +1 -0
- relvector/api.py +187 -0
- relvector/embeddings.py +146 -0
- relvector/errors.py +31 -0
- relvector/extraction.py +141 -0
- relvector/indexing.py +138 -0
- relvector/integrations/__init__.py +1 -0
- relvector/integrations/sqlguard.py +33 -0
- relvector/mapping.py +405 -0
- relvector/py.typed +0 -0
- relvector/schema.py +130 -0
- relvector/store.py +410 -0
- relvector-0.1.0.dist-info/METADATA +189 -0
- relvector-0.1.0.dist-info/RECORD +18 -0
- relvector-0.1.0.dist-info/WHEEL +4 -0
- relvector-0.1.0.dist-info/licenses/LICENSE +202 -0
- relvector-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +54 -0
relvector/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""RelVector — schema-aware semantic indexes over existing relational databases.
|
|
2
|
+
|
|
3
|
+
RelVector does not turn a SQL database into a vector database. It builds and maintains a
|
|
4
|
+
semantic index over *selected* relational data, keeping source references back to the rows;
|
|
5
|
+
the original database remains the authoritative source and is only ever read.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from relvector._version import __version__
|
|
9
|
+
from relvector.api import RelVector, connect
|
|
10
|
+
from relvector.embeddings import EmbeddingProvider, HashingEmbedding, SentenceTransformerEmbedding
|
|
11
|
+
from relvector.errors import (
|
|
12
|
+
IndexMismatchError,
|
|
13
|
+
MappingError,
|
|
14
|
+
QueryRejectedError,
|
|
15
|
+
RelVectorError,
|
|
16
|
+
RunSupersededError,
|
|
17
|
+
SchemaError,
|
|
18
|
+
SensitiveFieldError,
|
|
19
|
+
)
|
|
20
|
+
from relvector.indexing import IndexStats
|
|
21
|
+
from relvector.mapping import EntityMapping, Include, SemanticRecord
|
|
22
|
+
from relvector.schema import Schema, inspect_schema
|
|
23
|
+
from relvector.store import LocalVectorStore, SearchResult, VectorStore
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"EmbeddingProvider",
|
|
27
|
+
"EntityMapping",
|
|
28
|
+
"HashingEmbedding",
|
|
29
|
+
"Include",
|
|
30
|
+
"IndexMismatchError",
|
|
31
|
+
"IndexStats",
|
|
32
|
+
"LocalVectorStore",
|
|
33
|
+
"MappingError",
|
|
34
|
+
"QueryRejectedError",
|
|
35
|
+
"RelVector",
|
|
36
|
+
"RelVectorError",
|
|
37
|
+
"RunSupersededError",
|
|
38
|
+
"Schema",
|
|
39
|
+
"SchemaError",
|
|
40
|
+
"SearchResult",
|
|
41
|
+
"SemanticRecord",
|
|
42
|
+
"SensitiveFieldError",
|
|
43
|
+
"SentenceTransformerEmbedding",
|
|
44
|
+
"VectorStore",
|
|
45
|
+
"__version__",
|
|
46
|
+
"connect",
|
|
47
|
+
"inspect_schema",
|
|
48
|
+
]
|
relvector/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
relvector/api.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""The :class:`RelVector` facade: connect, inspect, configure, index, search."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from sqlalchemy import create_engine
|
|
11
|
+
from sqlalchemy.engine import Engine, make_url
|
|
12
|
+
|
|
13
|
+
from relvector.embeddings import EmbeddingProvider, get_provider
|
|
14
|
+
from relvector.errors import MappingError, RelVectorError
|
|
15
|
+
from relvector.extraction import Extractor, SqlValidator
|
|
16
|
+
from relvector.indexing import Indexer, IndexStats
|
|
17
|
+
from relvector.mapping import EntityMapping, Include, ResolvedMapping, resolve
|
|
18
|
+
from relvector.schema import Schema, inspect_schema
|
|
19
|
+
from relvector.store import LocalVectorStore, SearchResult, VectorStore
|
|
20
|
+
|
|
21
|
+
SUPPORTED_DIALECTS = ("sqlite", "postgresql")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def connect(source_url: str) -> Engine:
|
|
25
|
+
"""Create a read-only engine for a SQLite file or a PostgreSQL database.
|
|
26
|
+
|
|
27
|
+
SQLite files are opened with ``mode=ro`` (a missing file is an error, never silently
|
|
28
|
+
created); PostgreSQL sessions are set to ``READ ONLY`` transactions. Read-only mode is a
|
|
29
|
+
safety net; the database role should also be read-only.
|
|
30
|
+
"""
|
|
31
|
+
url = make_url(source_url)
|
|
32
|
+
backend = url.get_backend_name()
|
|
33
|
+
if backend == "sqlite":
|
|
34
|
+
if not url.database or url.database == ":memory:":
|
|
35
|
+
raise RelVectorError("an in-memory SQLite URL has no data to index; pass engine=")
|
|
36
|
+
path = Path(url.database).resolve()
|
|
37
|
+
if not path.is_file():
|
|
38
|
+
raise RelVectorError(f"SQLite database not found: {path}")
|
|
39
|
+
uri = f"{path.as_uri()}?mode=ro"
|
|
40
|
+
return create_engine(
|
|
41
|
+
"sqlite://",
|
|
42
|
+
creator=lambda: sqlite3.connect(uri, uri=True, check_same_thread=False),
|
|
43
|
+
)
|
|
44
|
+
if backend == "postgresql":
|
|
45
|
+
# REPEATABLE READ gives each batch's root and related-row queries one snapshot.
|
|
46
|
+
return create_engine(source_url, execution_options={
|
|
47
|
+
"postgresql_readonly": True, "isolation_level": "REPEATABLE READ"})
|
|
48
|
+
raise RelVectorError(f"unsupported database {backend!r}; supported: "
|
|
49
|
+
f"{', '.join(SUPPORTED_DIALECTS)}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RelVector:
|
|
53
|
+
"""Semantic index over selected relational data; the source database stays authoritative.
|
|
54
|
+
|
|
55
|
+
Example::
|
|
56
|
+
|
|
57
|
+
rv = RelVector("sqlite:///shop.db", embedding_provider="local",
|
|
58
|
+
index_path="shop.rvindex")
|
|
59
|
+
rv.configure(root_table="orders", include_tables=["shipments"],
|
|
60
|
+
text_fields=["orders.status", "shipments.delay_reason"],
|
|
61
|
+
metadata_fields=["orders.status"])
|
|
62
|
+
rv.index(batch_size=500)
|
|
63
|
+
for hit in rv.search("orders delayed by transport problems", top_k=5):
|
|
64
|
+
print(hit.score, hit.source_pk, hit.text)
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
source_url: str | None = None,
|
|
70
|
+
*,
|
|
71
|
+
engine: Engine | None = None,
|
|
72
|
+
embedding_provider: str | EmbeddingProvider = "local",
|
|
73
|
+
vector_store: str | VectorStore = "local",
|
|
74
|
+
index_path: str | Path = "relvector_index.sqlite",
|
|
75
|
+
schema: str | None = None,
|
|
76
|
+
sql_validator: SqlValidator | None = None,
|
|
77
|
+
allow_external_embeddings: bool = False,
|
|
78
|
+
) -> None:
|
|
79
|
+
if (source_url is None) == (engine is None):
|
|
80
|
+
raise RelVectorError("pass exactly one of source_url or engine")
|
|
81
|
+
self.engine = engine if engine is not None else connect(source_url) # type: ignore[arg-type]
|
|
82
|
+
self.source = (make_url(source_url).render_as_string(hide_password=True) if source_url
|
|
83
|
+
else engine.url.render_as_string(hide_password=True)) # type: ignore[union-attr]
|
|
84
|
+
self.provider = get_provider(embedding_provider)
|
|
85
|
+
if self.provider.sends_data_externally and not allow_external_embeddings:
|
|
86
|
+
raise RelVectorError(
|
|
87
|
+
f"embedding provider {self.provider.model_id!r} sends source text to an external "
|
|
88
|
+
"service; pass allow_external_embeddings=True to opt in explicitly"
|
|
89
|
+
)
|
|
90
|
+
if isinstance(vector_store, str):
|
|
91
|
+
if vector_store != "local":
|
|
92
|
+
raise RelVectorError(f"unknown vector store {vector_store!r}; use 'local'")
|
|
93
|
+
self.store: VectorStore = LocalVectorStore(index_path)
|
|
94
|
+
else:
|
|
95
|
+
self.store = vector_store
|
|
96
|
+
self.db_schema = schema
|
|
97
|
+
self.sql_validator = sql_validator
|
|
98
|
+
self._schema: Schema | None = None
|
|
99
|
+
self._mapping: ResolvedMapping | None = None
|
|
100
|
+
|
|
101
|
+
def __repr__(self) -> str:
|
|
102
|
+
return f"RelVector(source={self.source!r}, provider={self.provider.model_id!r})"
|
|
103
|
+
|
|
104
|
+
def __enter__(self) -> RelVector:
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
def __exit__(self, *exc: object) -> None:
|
|
108
|
+
self.close()
|
|
109
|
+
|
|
110
|
+
def close(self) -> None:
|
|
111
|
+
self.store.close()
|
|
112
|
+
self.engine.dispose()
|
|
113
|
+
|
|
114
|
+
def inspect_schema(self, *, refresh: bool = False) -> Schema:
|
|
115
|
+
if self._schema is None or refresh:
|
|
116
|
+
self._schema = inspect_schema(self.engine, schema=self.db_schema)
|
|
117
|
+
return self._schema
|
|
118
|
+
|
|
119
|
+
def configure(
|
|
120
|
+
self,
|
|
121
|
+
mapping: EntityMapping | None = None,
|
|
122
|
+
*,
|
|
123
|
+
root_table: str | None = None,
|
|
124
|
+
include_tables: Sequence[str | Include] = (),
|
|
125
|
+
text_fields: Sequence[str] = (),
|
|
126
|
+
metadata_fields: Sequence[str] = (),
|
|
127
|
+
exclude_fields: Sequence[str] = (),
|
|
128
|
+
allow_sensitive: Sequence[str] = (),
|
|
129
|
+
templates: Mapping[str, str] | None = None,
|
|
130
|
+
labels: Mapping[str, str] | None = None,
|
|
131
|
+
name: str | None = None,
|
|
132
|
+
version: str = "1",
|
|
133
|
+
max_children: int = 20,
|
|
134
|
+
) -> ResolvedMapping:
|
|
135
|
+
"""Set the entity mapping (pass an :class:`EntityMapping` or keyword arguments)."""
|
|
136
|
+
if mapping is None:
|
|
137
|
+
if root_table is None:
|
|
138
|
+
raise MappingError("root_table is required")
|
|
139
|
+
mapping = EntityMapping(
|
|
140
|
+
root_table=root_table, include=tuple(include_tables),
|
|
141
|
+
text_fields=tuple(text_fields), metadata_fields=tuple(metadata_fields),
|
|
142
|
+
exclude_fields=tuple(exclude_fields), allow_sensitive=tuple(allow_sensitive),
|
|
143
|
+
templates=dict(templates or {}), labels=dict(labels or {}), name=name,
|
|
144
|
+
version=version, max_children=max_children,
|
|
145
|
+
)
|
|
146
|
+
self._mapping = resolve(mapping, self.inspect_schema())
|
|
147
|
+
return self._mapping
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def mapping(self) -> ResolvedMapping:
|
|
151
|
+
if self._mapping is None:
|
|
152
|
+
raise MappingError("call configure() before indexing or searching")
|
|
153
|
+
return self._mapping
|
|
154
|
+
|
|
155
|
+
def extractor(self) -> Extractor:
|
|
156
|
+
return Extractor(self.engine, self.mapping, schema=self.db_schema,
|
|
157
|
+
validator=self.sql_validator)
|
|
158
|
+
|
|
159
|
+
def index(
|
|
160
|
+
self,
|
|
161
|
+
batch_size: int = 500,
|
|
162
|
+
*,
|
|
163
|
+
resume: bool = False,
|
|
164
|
+
prune: bool = True,
|
|
165
|
+
progress: Callable[[IndexStats], None] | None = None,
|
|
166
|
+
) -> IndexStats:
|
|
167
|
+
"""(Re-)index the configured entity. Idempotent; see :mod:`relvector.indexing`."""
|
|
168
|
+
return Indexer(self.extractor(), self.provider, self.store).run(
|
|
169
|
+
batch_size, resume=resume, prune=prune, progress=progress)
|
|
170
|
+
|
|
171
|
+
def search(
|
|
172
|
+
self,
|
|
173
|
+
query: str,
|
|
174
|
+
top_k: int = 5,
|
|
175
|
+
*,
|
|
176
|
+
filters: Mapping[str, Any] | None = None,
|
|
177
|
+
entity: str | None = None,
|
|
178
|
+
) -> list[SearchResult]:
|
|
179
|
+
"""Nearest records by cosine similarity. Similarity is not evidence of causation.
|
|
180
|
+
|
|
181
|
+
``filters`` match stored metadata exactly, e.g. ``{"orders.status": "delayed"}``.
|
|
182
|
+
``entity`` defaults to the configured mapping's entity, if any.
|
|
183
|
+
"""
|
|
184
|
+
if entity is None and self._mapping is not None:
|
|
185
|
+
entity = self._mapping.entity
|
|
186
|
+
vector = self.provider.embed([query])
|
|
187
|
+
return self.store.search(vector[0], top_k, entity=entity, filters=filters)
|
relvector/embeddings.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Embedding providers.
|
|
2
|
+
|
|
3
|
+
A provider is any object with ``model_id``, ``dimension``, ``sends_data_externally`` and
|
|
4
|
+
``embed(texts) -> ndarray[(n, dimension), float32]``. Two are built in:
|
|
5
|
+
|
|
6
|
+
* :class:`SentenceTransformerEmbedding` — a local ``sentence-transformers`` model
|
|
7
|
+
(``pip install relvector[local]``). Nothing leaves the machine; the model is downloaded by
|
|
8
|
+
sentence-transformers on first use unless ``local_files_only=True``.
|
|
9
|
+
* :class:`HashingEmbedding` — dependency-free, deterministic lexical feature hashing. It is not a
|
|
10
|
+
semantic model; it exists for tests, offline demos and as a keyword-ish baseline.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import re
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from typing import Any, Protocol, runtime_checkable
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
from numpy.typing import NDArray
|
|
22
|
+
|
|
23
|
+
from relvector.errors import RelVectorError
|
|
24
|
+
|
|
25
|
+
Vectors = NDArray[np.float32]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@runtime_checkable
|
|
29
|
+
class EmbeddingProvider(Protocol):
|
|
30
|
+
model_id: str
|
|
31
|
+
sends_data_externally: bool
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def dimension(self) -> int: ...
|
|
35
|
+
|
|
36
|
+
def embed(self, texts: Sequence[str]) -> Vectors: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def normalize(vectors: Any) -> Vectors:
|
|
40
|
+
arr = np.asarray(vectors, dtype=np.float32)
|
|
41
|
+
if arr.ndim != 2:
|
|
42
|
+
raise RelVectorError(f"embeddings must be 2-dimensional, got shape {arr.shape}")
|
|
43
|
+
norms = np.linalg.norm(arr, axis=1, keepdims=True)
|
|
44
|
+
norms[norms == 0] = 1.0
|
|
45
|
+
out: Vectors = arr / norms
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_TOKEN = re.compile(r"[a-z0-9]+")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class HashingEmbedding:
|
|
53
|
+
"""Signed feature hashing of words and character trigrams (stable across processes)."""
|
|
54
|
+
|
|
55
|
+
sends_data_externally = False
|
|
56
|
+
|
|
57
|
+
def __init__(self, dimension: int = 512) -> None:
|
|
58
|
+
if dimension < 8:
|
|
59
|
+
raise ValueError("dimension must be >= 8")
|
|
60
|
+
self._dimension = dimension
|
|
61
|
+
self.model_id = f"hashing-v1-{dimension}"
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def dimension(self) -> int:
|
|
65
|
+
return self._dimension
|
|
66
|
+
|
|
67
|
+
def _features(self, text: str) -> list[str]:
|
|
68
|
+
feats: list[str] = []
|
|
69
|
+
for word in _TOKEN.findall(text.lower()):
|
|
70
|
+
feats.append(f"w:{word}")
|
|
71
|
+
padded = f"#{word}#"
|
|
72
|
+
feats.extend(f"c:{padded[i:i + 3]}" for i in range(len(padded) - 2))
|
|
73
|
+
return feats
|
|
74
|
+
|
|
75
|
+
def embed(self, texts: Sequence[str]) -> Vectors:
|
|
76
|
+
out = np.zeros((len(texts), self._dimension), dtype=np.float32)
|
|
77
|
+
for row, text in enumerate(texts):
|
|
78
|
+
for feat in self._features(text):
|
|
79
|
+
h = int.from_bytes(hashlib.blake2b(feat.encode(), digest_size=8).digest(), "little")
|
|
80
|
+
out[row, h % self._dimension] += 1.0 if (h >> 63) & 1 else -1.0
|
|
81
|
+
return normalize(out)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class SentenceTransformerEmbedding:
|
|
85
|
+
"""A local sentence-transformers model, loaded lazily on first use."""
|
|
86
|
+
|
|
87
|
+
sends_data_externally = False
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
model_name: str = "sentence-transformers/all-MiniLM-L6-v2",
|
|
92
|
+
*,
|
|
93
|
+
device: str | None = None,
|
|
94
|
+
batch_size: int = 64,
|
|
95
|
+
local_files_only: bool = False,
|
|
96
|
+
) -> None:
|
|
97
|
+
self.model_name = model_name
|
|
98
|
+
self.model_id = f"sentence-transformers:{model_name}"
|
|
99
|
+
self.device = device
|
|
100
|
+
self.batch_size = batch_size
|
|
101
|
+
self.local_files_only = local_files_only
|
|
102
|
+
self._model: Any = None
|
|
103
|
+
|
|
104
|
+
def _load(self) -> Any:
|
|
105
|
+
if self._model is None:
|
|
106
|
+
try:
|
|
107
|
+
from sentence_transformers import SentenceTransformer # noqa: PLC0415
|
|
108
|
+
except ImportError as exc: # pragma: no cover - depends on the environment
|
|
109
|
+
raise RelVectorError(
|
|
110
|
+
f"the 'local' embedding provider could not import sentence-transformers "
|
|
111
|
+
f"({exc}); pip install 'relvector[local]' or use "
|
|
112
|
+
"embedding_provider='hashing'"
|
|
113
|
+
) from exc
|
|
114
|
+
self._model = SentenceTransformer(
|
|
115
|
+
self.model_name, device=self.device, local_files_only=self.local_files_only
|
|
116
|
+
)
|
|
117
|
+
return self._model
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def dimension(self) -> int:
|
|
121
|
+
dim = self._load().get_sentence_embedding_dimension()
|
|
122
|
+
if not dim:
|
|
123
|
+
raise RelVectorError(f"model {self.model_name!r} does not report a dimension")
|
|
124
|
+
return int(dim)
|
|
125
|
+
|
|
126
|
+
def embed(self, texts: Sequence[str]) -> Vectors:
|
|
127
|
+
if not texts:
|
|
128
|
+
return np.zeros((0, self.dimension), dtype=np.float32)
|
|
129
|
+
vectors = self._load().encode(
|
|
130
|
+
list(texts), batch_size=self.batch_size, convert_to_numpy=True,
|
|
131
|
+
normalize_embeddings=True, show_progress_bar=False,
|
|
132
|
+
)
|
|
133
|
+
return normalize(vectors)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def get_provider(spec: str | EmbeddingProvider, **kwargs: Any) -> EmbeddingProvider:
|
|
137
|
+
"""Resolve ``"local"``/``"sentence-transformers"``, ``"hashing"`` or a provider instance."""
|
|
138
|
+
if not isinstance(spec, str):
|
|
139
|
+
if not isinstance(spec, EmbeddingProvider):
|
|
140
|
+
raise RelVectorError("embedding_provider must be a name or an EmbeddingProvider")
|
|
141
|
+
return spec
|
|
142
|
+
if spec in ("local", "sentence-transformers"):
|
|
143
|
+
return SentenceTransformerEmbedding(**kwargs)
|
|
144
|
+
if spec == "hashing":
|
|
145
|
+
return HashingEmbedding(**kwargs)
|
|
146
|
+
raise RelVectorError(f"unknown embedding provider {spec!r}; use 'local' or 'hashing'")
|
relvector/errors.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Exception hierarchy: every error RelVector raises on purpose is a :class:`RelVectorError`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RelVectorError(Exception):
|
|
7
|
+
"""Base class for RelVector errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SchemaError(RelVectorError):
|
|
11
|
+
"""A table, column or relationship does not exist or cannot be resolved."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MappingError(RelVectorError):
|
|
15
|
+
"""An entity mapping is invalid (unknown field, ambiguous join, conflicting options)."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SensitiveFieldError(MappingError):
|
|
19
|
+
"""A mapping would embed or store a column that looks sensitive and was not authorised."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class IndexMismatchError(RelVectorError):
|
|
23
|
+
"""A persisted index was built with a different embedding model or dimension."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RunSupersededError(RelVectorError):
|
|
27
|
+
"""Another indexing run for the same entity started; this run stopped without pruning."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class QueryRejectedError(RelVectorError):
|
|
31
|
+
"""An optional SQL validator (for example SQLGuard Ninja) rejected an extraction query."""
|
relvector/extraction.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Bounded, read-only extraction of root rows and their related rows.
|
|
2
|
+
|
|
3
|
+
* Root rows are read in primary-key order with keyset pagination (``WHERE pk > :last``), never
|
|
4
|
+
``OFFSET``, so each batch costs an index range scan regardless of how deep the run is.
|
|
5
|
+
* Related rows are fetched per batch with ``IN (...)`` on the join key: one query per relation,
|
|
6
|
+
no joins, so a one-to-many relation can never multiply root rows (no Cartesian products).
|
|
7
|
+
* One-to-many relations are capped per parent with ``ROW_NUMBER()`` in the database and the true
|
|
8
|
+
total is counted separately, so memory is bounded by ``batch_size * max_items`` and any
|
|
9
|
+
truncation is reported rather than silent.
|
|
10
|
+
|
|
11
|
+
Only ``SELECT`` statements built with SQLAlchemy Core are executed; all values are bound
|
|
12
|
+
parameters. Identifiers come from the inspected schema, not from free-form user SQL.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from sqlalchemy import Select, column, func, select, table, tuple_
|
|
22
|
+
from sqlalchemy.engine import Connection, Engine
|
|
23
|
+
from sqlalchemy.sql.expression import TableClause
|
|
24
|
+
|
|
25
|
+
from relvector.mapping import Relation, ResolvedMapping
|
|
26
|
+
|
|
27
|
+
SqlValidator = Callable[[str], None]
|
|
28
|
+
"""Called with each extraction query (named bind parameters) before it runs; raise to block."""
|
|
29
|
+
|
|
30
|
+
Row = Mapping[str, Any]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class RowBundle:
|
|
35
|
+
row: Row
|
|
36
|
+
related: dict[str, tuple[list[Row], int]]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Batch:
|
|
41
|
+
bundles: list[RowBundle]
|
|
42
|
+
last_key: list[Any]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Extractor:
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
engine: Engine,
|
|
49
|
+
resolved: ResolvedMapping,
|
|
50
|
+
*,
|
|
51
|
+
schema: str | None = None,
|
|
52
|
+
validator: SqlValidator | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
self.engine = engine
|
|
55
|
+
self.resolved = resolved
|
|
56
|
+
self.schema = schema
|
|
57
|
+
self.validator = validator
|
|
58
|
+
|
|
59
|
+
def _table(self, name: str, columns: Sequence[str]) -> TableClause:
|
|
60
|
+
return table(name, *(column(c) for c in columns), schema=self.schema)
|
|
61
|
+
|
|
62
|
+
def _execute(self, conn: Connection, stmt: Select[Any]) -> list[Row]:
|
|
63
|
+
if self.validator is not None:
|
|
64
|
+
self.validator(self.render(stmt))
|
|
65
|
+
return [dict(r) for r in conn.execute(stmt).mappings()]
|
|
66
|
+
|
|
67
|
+
def render(self, stmt: Select[Any]) -> str:
|
|
68
|
+
"""The statement as SQL text with named parameters (what a validator sees)."""
|
|
69
|
+
dialect = type(self.engine.dialect)(paramstyle="named") # type: ignore[call-arg]
|
|
70
|
+
return str(stmt.compile(dialect=dialect, compile_kwargs={"render_postcompile": True}))
|
|
71
|
+
|
|
72
|
+
def root_query(self, batch_size: int, after: Sequence[Any] | None) -> Select[Any]:
|
|
73
|
+
r = self.resolved
|
|
74
|
+
t = self._table(r.root_table, r.root_select_columns)
|
|
75
|
+
pk = [t.c[c] for c in r.primary_key]
|
|
76
|
+
# SQLite permits NULL in non-INTEGER primary keys; such rows have no stable identity.
|
|
77
|
+
stmt = (select(*(t.c[c] for c in r.root_select_columns))
|
|
78
|
+
.where(*(c.is_not(None) for c in pk)).order_by(*pk).limit(batch_size))
|
|
79
|
+
if after is not None:
|
|
80
|
+
if len(pk) == 1:
|
|
81
|
+
stmt = stmt.where(pk[0] > after[0])
|
|
82
|
+
else:
|
|
83
|
+
stmt = stmt.where(tuple_(*pk) > tuple_(*after))
|
|
84
|
+
return stmt
|
|
85
|
+
|
|
86
|
+
def iter_batches(
|
|
87
|
+
self, batch_size: int = 500, after: Sequence[Any] | None = None
|
|
88
|
+
) -> Iterator[Batch]:
|
|
89
|
+
"""Yield batches of at most ``batch_size`` root rows, resuming after key ``after``."""
|
|
90
|
+
if batch_size < 1:
|
|
91
|
+
raise ValueError("batch_size must be >= 1")
|
|
92
|
+
last = list(after) if after is not None else None
|
|
93
|
+
while True:
|
|
94
|
+
with self.engine.connect() as conn:
|
|
95
|
+
rows = self._execute(conn, self.root_query(batch_size, last))
|
|
96
|
+
if not rows:
|
|
97
|
+
return
|
|
98
|
+
related = {rel.table: self._fetch_related(conn, rel, rows)
|
|
99
|
+
for rel in self.resolved.relations}
|
|
100
|
+
bundles = [
|
|
101
|
+
RowBundle(row, {t: per_key.get(row[rel.root_column], ([], 0))
|
|
102
|
+
for t, (rel, per_key) in related.items()})
|
|
103
|
+
for row in rows
|
|
104
|
+
]
|
|
105
|
+
last = [rows[-1][c] for c in self.resolved.primary_key]
|
|
106
|
+
yield Batch(bundles, last)
|
|
107
|
+
if len(rows) < batch_size:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
def _fetch_related(
|
|
111
|
+
self, conn: Connection, rel: Relation, rows: list[Row]
|
|
112
|
+
) -> tuple[Relation, dict[Any, tuple[list[Row], int]]]:
|
|
113
|
+
keys = sorted({r[rel.root_column] for r in rows if r[rel.root_column] is not None},
|
|
114
|
+
key=lambda k: (str(type(k)), k))
|
|
115
|
+
if not keys:
|
|
116
|
+
return rel, {}
|
|
117
|
+
t = self._table(rel.table, rel.select_columns)
|
|
118
|
+
join_col = t.c[rel.related_column]
|
|
119
|
+
cols = [t.c[c] for c in rel.select_columns]
|
|
120
|
+
out: dict[Any, tuple[list[Row], int]] = {}
|
|
121
|
+
if rel.kind == "many_to_one":
|
|
122
|
+
for r in self._execute(conn, select(*cols).where(join_col.in_(keys))):
|
|
123
|
+
out[r[rel.related_column]] = ([r], 1)
|
|
124
|
+
return rel, out
|
|
125
|
+
|
|
126
|
+
rn = func.row_number().over(
|
|
127
|
+
partition_by=join_col, order_by=[t.c[c] for c in rel.primary_key]
|
|
128
|
+
).label("_rv_rn")
|
|
129
|
+
ranked = select(*cols, rn).where(join_col.in_(keys)).subquery()
|
|
130
|
+
capped = (select(*(ranked.c[c] for c in rel.select_columns))
|
|
131
|
+
.where(ranked.c["_rv_rn"] <= rel.max_items)
|
|
132
|
+
.order_by(ranked.c[rel.related_column], ranked.c["_rv_rn"]))
|
|
133
|
+
counts = select(join_col, func.count().label("_rv_n")).where(
|
|
134
|
+
join_col.in_(keys)).group_by(join_col)
|
|
135
|
+
totals = {r[rel.related_column]: r["_rv_n"] for r in self._execute(conn, counts)}
|
|
136
|
+
grouped: dict[Any, list[Row]] = {}
|
|
137
|
+
for r in self._execute(conn, capped):
|
|
138
|
+
grouped.setdefault(r[rel.related_column], []).append(r)
|
|
139
|
+
for key, total in totals.items():
|
|
140
|
+
out[key] = (grouped.get(key, []), total)
|
|
141
|
+
return rel, out
|