schemagate 0.1.0__tar.gz
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.
- schemagate-0.1.0/.gitignore +10 -0
- schemagate-0.1.0/LICENSE +17 -0
- schemagate-0.1.0/PKG-INFO +487 -0
- schemagate-0.1.0/README.md +420 -0
- schemagate-0.1.0/pyproject.toml +77 -0
- schemagate-0.1.0/src/schemagate/__init__.py +29 -0
- schemagate-0.1.0/src/schemagate/ai/__init__.py +28 -0
- schemagate-0.1.0/src/schemagate/ai/describe.py +170 -0
- schemagate-0.1.0/src/schemagate/ai/embedder.py +128 -0
- schemagate-0.1.0/src/schemagate/ai/providers.py +268 -0
- schemagate-0.1.0/src/schemagate/catalog.py +370 -0
- schemagate-0.1.0/src/schemagate/cli.py +161 -0
- schemagate-0.1.0/src/schemagate/demo_schema.py +260 -0
- schemagate-0.1.0/src/schemagate/embedder.py +150 -0
- schemagate-0.1.0/src/schemagate/embedders/__init__.py +0 -0
- schemagate-0.1.0/src/schemagate/embedders/hf.py +30 -0
- schemagate-0.1.0/src/schemagate/identity.py +80 -0
- schemagate-0.1.0/src/schemagate/integrations/__init__.py +0 -0
- schemagate-0.1.0/src/schemagate/integrations/langchain.py +79 -0
- schemagate-0.1.0/src/schemagate/introspect.py +100 -0
- schemagate-0.1.0/src/schemagate/mcp_server.py +375 -0
- schemagate-0.1.0/src/schemagate/models.py +172 -0
- schemagate-0.1.0/src/schemagate/py.typed +0 -0
- schemagate-0.1.0/src/schemagate/store.py +20 -0
- schemagate-0.1.0/src/schemagate/stores/__init__.py +0 -0
- schemagate-0.1.0/src/schemagate/stores/memory.py +44 -0
- schemagate-0.1.0/src/schemagate/stores/oracle.py +228 -0
- schemagate-0.1.0/src/schemagate/studio.html +1174 -0
- schemagate-0.1.0/src/schemagate/studio.py +194 -0
schemagate-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Copyright 2026 Ashish Sinha
|
|
6
|
+
|
|
7
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
8
|
+
you may not use this file except in compliance with the License.
|
|
9
|
+
You may obtain a copy of the License at
|
|
10
|
+
|
|
11
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
|
|
13
|
+
Unless required by applicable law or agreed to in writing, software
|
|
14
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
15
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
16
|
+
See the License for the specific language governing permissions and
|
|
17
|
+
limitations under the License.
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: schemagate
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Identity-scoped schema selection for text-to-SQL: give the model only the tables this caller may read, and 75% fewer prompt tokens
|
|
5
|
+
Project-URL: Homepage, https://github.com/ashishsinha1602/schemagate
|
|
6
|
+
Project-URL: Repository, https://github.com/ashishsinha1602/schemagate
|
|
7
|
+
Project-URL: Issues, https://github.com/ashishsinha1602/schemagate/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/ashishsinha1602/schemagate/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Ashish Sinha
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai-agent,bm25,claude,data-catalog,database,gemini,hybrid-search,langchain,llm,mcp,model-context-protocol,nl2sql,openai,oracle,oracle-23ai,postgresql,prompt-engineering,rag,rls,row-level-security,schema-linking,schema-retrieval,schema-selection,sql-generation,sql-server,sqlalchemy,text-to-sql,text2sql,vector-search,vpd
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Information Technology
|
|
16
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Topic :: Database
|
|
26
|
+
Classifier: Topic :: Database :: Database Engines/Servers
|
|
27
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
28
|
+
Classifier: Topic :: Security
|
|
29
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
30
|
+
Classifier: Typing :: Typed
|
|
31
|
+
Requires-Python: >=3.9
|
|
32
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
33
|
+
Provides-Extra: ai
|
|
34
|
+
Requires-Dist: anthropic>=0.40; extra == 'ai'
|
|
35
|
+
Requires-Dist: google-genai>=1.0; extra == 'ai'
|
|
36
|
+
Requires-Dist: openai>=1.40; extra == 'ai'
|
|
37
|
+
Provides-Extra: anthropic
|
|
38
|
+
Requires-Dist: anthropic>=0.40; extra == 'anthropic'
|
|
39
|
+
Provides-Extra: dev
|
|
40
|
+
Requires-Dist: anyio>=4; extra == 'dev'
|
|
41
|
+
Requires-Dist: hypothesis>=6; (python_version >= '3.10') and extra == 'dev'
|
|
42
|
+
Requires-Dist: langchain-core>=0.3; extra == 'dev'
|
|
43
|
+
Requires-Dist: mcp>=1.2; (python_version >= '3.10') and extra == 'dev'
|
|
44
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
45
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
46
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
47
|
+
Requires-Dist: sqlglot>=25; extra == 'dev'
|
|
48
|
+
Provides-Extra: gemini
|
|
49
|
+
Requires-Dist: google-genai>=1.0; extra == 'gemini'
|
|
50
|
+
Provides-Extra: huggingface
|
|
51
|
+
Requires-Dist: sentence-transformers>=3.0; extra == 'huggingface'
|
|
52
|
+
Provides-Extra: langchain
|
|
53
|
+
Requires-Dist: langchain-core>=0.3; extra == 'langchain'
|
|
54
|
+
Provides-Extra: mcp
|
|
55
|
+
Requires-Dist: mcp>=1.2; (python_version >= '3.10') and extra == 'mcp'
|
|
56
|
+
Provides-Extra: mssql
|
|
57
|
+
Requires-Dist: pyodbc>=5.0; extra == 'mssql'
|
|
58
|
+
Provides-Extra: mysql
|
|
59
|
+
Requires-Dist: pymysql>=1.1; extra == 'mysql'
|
|
60
|
+
Provides-Extra: openai
|
|
61
|
+
Requires-Dist: openai>=1.40; extra == 'openai'
|
|
62
|
+
Provides-Extra: oracle
|
|
63
|
+
Requires-Dist: oracledb>=2.4; extra == 'oracle'
|
|
64
|
+
Provides-Extra: postgres
|
|
65
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == 'postgres'
|
|
66
|
+
Description-Content-Type: text/markdown
|
|
67
|
+
|
|
68
|
+
# schemagate
|
|
69
|
+
|
|
70
|
+
Picks the handful of tables an NL2SQL model actually needs, and never shows it
|
|
71
|
+
tables the person asking isn't allowed to read.
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pip install schemagate
|
|
75
|
+
schemagate demo
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
That runs against a bundled 42-object schema. No database, no key, nothing to
|
|
79
|
+
configure. Then try it with the questions people actually type:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
schemagate demo "which customers owe us money"
|
|
83
|
+
schemagate demo "salary by employee" # restricted table absent
|
|
84
|
+
schemagate demo "salary by employee" --principal okta:hr --role payroll # now it's there
|
|
85
|
+
schemagate demo "late shipments by carrier" --prompt # the DDL the model gets
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Against your own database it's the same shape:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
schemagate select "revenue by month" --url postgresql://localhost/app --principal okta:jdoe --role finance
|
|
92
|
+
schemagate studio --url postgresql://localhost/app # the same thing, as a page
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`schemagate studio` opens a local page where you type questions, switch the caller's
|
|
96
|
+
roles, edit hints, and watch what reaches the prompt and what doesn't. The same
|
|
97
|
+
page runs publicly at **https://ashishsinha1602.github.io/schemagate/** on the six
|
|
98
|
+
bundled schemas, in your browser, with no server behind it. The selector on that page is a JavaScript
|
|
99
|
+
port of this library, and a test runs both against 1,789 cases and requires
|
|
100
|
+
identical rankings.
|
|
101
|
+
|
|
102
|
+
If you're coming from Vanna (archived March 2026), `docs/migrating-from-vanna.md`
|
|
103
|
+
is the short version: Vanna applied identity when the SQL *ran*; schemagate applies
|
|
104
|
+
it before the model sees the schema. Your `User` maps to a `Principal` in one
|
|
105
|
+
line.
|
|
106
|
+
|
|
107
|
+
## The problem this solves
|
|
108
|
+
|
|
109
|
+
Two things go wrong when you point an LLM at a database schema.
|
|
110
|
+
|
|
111
|
+
The first is cost. Most systems paste the whole schema into the prompt on every
|
|
112
|
+
question. That's fine for twenty tables and ruinous for two thousand.
|
|
113
|
+
|
|
114
|
+
The second is worse, and it's the reason I wrote this. Schema selection happens
|
|
115
|
+
*before* the query runs, so it happens before row-level security can do
|
|
116
|
+
anything. If your selection step isn't identity-aware, the model gets handed a
|
|
117
|
+
table the caller can't read. It writes perfectly good SQL. RLS or VPD filters
|
|
118
|
+
every row out. The user sees "no records found" and believes it.
|
|
119
|
+
|
|
120
|
+
That's not an access-denied message. It's a wrong answer with a confident tone,
|
|
121
|
+
and the user has no way to tell the difference. Filtering the catalog by
|
|
122
|
+
identity first is the only way I know to avoid it.
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from schemagate import Catalog, Principal
|
|
126
|
+
|
|
127
|
+
cat = Catalog().bootstrap("postgresql://localhost/app")
|
|
128
|
+
cat.hint("invoice_draft", "pre-issue drafts only, not real revenue")
|
|
129
|
+
cat.restrict("hr_compensation", ["payroll"])
|
|
130
|
+
|
|
131
|
+
sel = cat.select("revenue by month", top_k=6,
|
|
132
|
+
principal=Principal("okta:jdoe", roles={"finance"}))
|
|
133
|
+
|
|
134
|
+
sel.prompt_fragment() # compact DDL, ready for the system prompt
|
|
135
|
+
sel.object_list # [{'owner': ..., 'name': ...}]
|
|
136
|
+
sel.explain() # why each object was picked
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`hr_compensation` is not in that result and its name does not appear anywhere
|
|
140
|
+
in the prompt text.
|
|
141
|
+
|
|
142
|
+
## Install
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
pip install schemagate
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
That's the whole thing. One dependency (SQLAlchemy), no API key, no model
|
|
149
|
+
download. The default embedder is a hashed n-gram vectoriser that runs offline
|
|
150
|
+
and gives byte-identical results on every machine.
|
|
151
|
+
|
|
152
|
+
Extras, all optional:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
pip install 'schemagate[postgres]' 'schemagate[oracle]'
|
|
156
|
+
pip install 'schemagate[mssql]' 'schemagate[mysql]'
|
|
157
|
+
pip install 'schemagate[anthropic]' 'schemagate[openai]' 'schemagate[gemini]'
|
|
158
|
+
pip install 'schemagate[huggingface]'
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## How it picks
|
|
162
|
+
|
|
163
|
+
1. Reflect the schema through SQLAlchemy. No vendor SQL anywhere.
|
|
164
|
+
2. Index names, columns, comments, hints, and view definitions. That last one
|
|
165
|
+
matters more than it sounds: a view exposes only its output columns, so
|
|
166
|
+
`v_stock_shortfall` looks like it's about "shortfall" when the thing you'd
|
|
167
|
+
search for, `reorder_point`, is buried in its SELECT.
|
|
168
|
+
3. Retrieve with reciprocal-rank fusion over BM25 and vector similarity.
|
|
169
|
+
Neither alone is good enough. Vectors miss exact identifiers; BM25 misses
|
|
170
|
+
"owe us money" → `balance`.
|
|
171
|
+
4. Walk foreign keys to pull in join tables the question never mentions. In my
|
|
172
|
+
experience this is the single biggest cause of generated SQL that parses
|
|
173
|
+
but won't run.
|
|
174
|
+
5. Apply the caller's identity at every step above.
|
|
175
|
+
|
|
176
|
+
## Numbers
|
|
177
|
+
|
|
178
|
+
Six test schemas ship with the library. Run `python tests/bench.py` and you
|
|
179
|
+
get all of this printed back. `TESTING.md` is the full record of what was
|
|
180
|
+
tested, what broke, and what was found to be the database rather than schemagate.
|
|
181
|
+
|
|
182
|
+
| | |
|
|
183
|
+
|---|---|
|
|
184
|
+
| recall@6, 12 questions, 42-object schema | 100% |
|
|
185
|
+
| recall@6, same schema, questions phrased in business words | 50% |
|
|
186
|
+
| recall@6, unrelated 27-object clinical schema | 100% |
|
|
187
|
+
| recall@6, hostile 260-object schema | 100% |
|
|
188
|
+
| recall@6, 51-object claims star schema with 15 backup/staging copies | 100% |
|
|
189
|
+
| recall@6, 39-object bank ledger and trading book | 100% |
|
|
190
|
+
| recall@6, 40-object IoT telemetry fleet | 100% |
|
|
191
|
+
| real table beats its backup/staging copy, 19 cases across schemas | 19/19 |
|
|
192
|
+
| recall without foreign-key expansion | 93.8% |
|
|
193
|
+
| prompt tokens, full schema every call | 2,583 |
|
|
194
|
+
| prompt tokens, schemagate average | 631 (−75.6%) |
|
|
195
|
+
|
|
196
|
+
Token counts come from an estimator built into the benchmark so the number is
|
|
197
|
+
reproducible with no network and no extra install. `pip install tiktoken` and
|
|
198
|
+
the same script switches to exact `cl100k_base` counts. The ratio holds either
|
|
199
|
+
way.
|
|
200
|
+
|
|
201
|
+
Six schemas rather than one because a single schema whose questions happen to
|
|
202
|
+
share vocabulary with its own table names will flatter any retriever. The
|
|
203
|
+
second is a different domain entirely. The third is 260 objects of deliberate
|
|
204
|
+
sabotage: an `_archive` and `_stg` copy of every table, the same table name in
|
|
205
|
+
three schemas, an 8-deep foreign-key chain, a reference cycle, composite keys,
|
|
206
|
+
a 320-column table, 100-character identifiers, and names in Spanish and
|
|
207
|
+
Japanese. The fourth is a claims warehouse star schema built so that several
|
|
208
|
+
tables are plausible for every question and one is right: the same fact at
|
|
209
|
+
four grains, a slowly-changing member dimension with a history table, one date
|
|
210
|
+
dimension joined five different ways, bridge tables, and fifteen `_bkp`,
|
|
211
|
+
`_old`, `_v2`, `_tmp` and `stg_` copies of the important ones. The fifth is a
|
|
212
|
+
bank: a ledger at three grains, trades versus positions versus settlements,
|
|
213
|
+
FX both as a daily table and an as-of view, lending, and the KYC and AML
|
|
214
|
+
tables most callers must never see. The sixth is an IoT fleet: readings at
|
|
215
|
+
raw, one-minute and hourly grains, six monthly partition tables, an alarm
|
|
216
|
+
lifecycle spread across three tables. All six are invented. No real schema
|
|
217
|
+
from anywhere is in this repo.
|
|
218
|
+
|
|
219
|
+
That 50% row is the honest one. Read it before you adopt this.
|
|
220
|
+
|
|
221
|
+
## The 50% row, and what to do about it
|
|
222
|
+
|
|
223
|
+
The default embedder matches subwords, not meaning. Ask it for "things we're
|
|
224
|
+
running out of" and it will not find `v_stock_shortfall`, because those two
|
|
225
|
+
strings have nothing in common. Ask it about `stock_shortfall` and it's
|
|
226
|
+
excellent.
|
|
227
|
+
|
|
228
|
+
If your users type identifier-shaped questions, you're done, and you never need
|
|
229
|
+
an API key. If they type like people, give the catalog descriptions:
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
from schemagate.ai import SchemaDescriber, AnthropicProvider
|
|
233
|
+
|
|
234
|
+
cat.describe(SchemaDescriber(AnthropicProvider(model="claude-sonnet-4-5"),
|
|
235
|
+
cache_path=".schemagate-cache.json"))
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
One sentence per table, written by the model, indexed like any other schema
|
|
239
|
+
text. On the bundled schema that takes the business-words row from 50% to 100%
|
|
240
|
+
with no change to the identifier-style questions.
|
|
241
|
+
|
|
242
|
+
Claude, GPT and Gemini are supported. Anything else goes through
|
|
243
|
+
`CallableProvider`, which is also your escape hatch when a vendor changes their
|
|
244
|
+
SDK and you don't want to wait for a release from me.
|
|
245
|
+
|
|
246
|
+
```python
|
|
247
|
+
from schemagate.ai import (AnthropicProvider, OpenAIProvider, GeminiProvider,
|
|
248
|
+
CallableProvider, auto_provider, available_providers)
|
|
249
|
+
|
|
250
|
+
AnthropicProvider(model="claude-sonnet-4-5") # ANTHROPIC_API_KEY
|
|
251
|
+
OpenAIProvider(model="gpt-4.1-mini") # OPENAI_API_KEY
|
|
252
|
+
GeminiProvider(model="gemini-2.5-flash") # GEMINI_API_KEY
|
|
253
|
+
OpenAIProvider(model="…", base_url="http://localhost:11434/v1") # anything local
|
|
254
|
+
CallableProvider(lambda system, prompt: my_llm(system, prompt))
|
|
255
|
+
|
|
256
|
+
available_providers() # ['AnthropicProvider'] — names, never key values
|
|
257
|
+
auto_provider(model="…") # picks whichever key is set
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
`model` is required. I'm not shipping a default model ID, because model IDs
|
|
261
|
+
change every few months and a hardcoded one eventually 404s for everybody who
|
|
262
|
+
installed the version before the fix.
|
|
263
|
+
|
|
264
|
+
Three things worth knowing before you turn this on:
|
|
265
|
+
|
|
266
|
+
**What leaves your network.** Table names, column names, types, nullability,
|
|
267
|
+
existing comments, foreign keys. Not one row of data — `ObjectDoc` has no field
|
|
268
|
+
that could hold one, and there are tests asserting both halves of that. Nothing
|
|
269
|
+
is sent unless you call `describe()`.
|
|
270
|
+
|
|
271
|
+
**What it costs.** One short call per undescribed object, once. Objects that
|
|
272
|
+
already have a database comment or a hint are skipped by default. Results cache
|
|
273
|
+
by content, so re-running is free and only changed tables get re-described. Ask
|
|
274
|
+
before you pay:
|
|
275
|
+
|
|
276
|
+
```python
|
|
277
|
+
describer.estimate_calls(docs) # calls describe() would actually bill for
|
|
278
|
+
describer.preview(doc) # the exact text that would be sent
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
**What happens when it fails.** The object is skipped, cataloging continues, and
|
|
282
|
+
`describer.failures` lists what was missed. Pass `strict=True` if you'd rather
|
|
283
|
+
it raise. A `hint()` you wrote by hand always beats a generated description, so
|
|
284
|
+
fixing a bad one costs nothing.
|
|
285
|
+
|
|
286
|
+
You can swap the embedder for a hosted one too, but benchmark it first. On
|
|
287
|
+
identifier-heavy schema text the offline embedder is often just as good and it
|
|
288
|
+
doesn't cost anything per query.
|
|
289
|
+
|
|
290
|
+
```python
|
|
291
|
+
from schemagate.ai import APIEmbedder, OpenAIProvider
|
|
292
|
+
|
|
293
|
+
provider = OpenAIProvider(model="gpt-4.1-mini",
|
|
294
|
+
embed_model="text-embedding-3-small")
|
|
295
|
+
cat = Catalog(embedder=APIEmbedder(provider, dim=1536))
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
## Databases
|
|
299
|
+
|
|
300
|
+
Reflection uses only SQLAlchemy's dialect-agnostic Inspector. There's no
|
|
301
|
+
hand-written SQL in `schemagate.introspect` and a test fails the build if any
|
|
302
|
+
appears, so in principle any dialect SQLAlchemy supports will work.
|
|
303
|
+
|
|
304
|
+
In principle isn't evidence, so there's a script:
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
python scripts/certify_dialect.py 'postgresql+psycopg://user:pw@host/db'
|
|
308
|
+
python scripts/certify_dialect.py 'oracle+oracledb://user:pw@host:1521/?service_name=FREEPDB1'
|
|
309
|
+
python scripts/certify_dialect.py 'mssql+pyodbc://user:pw@host/db?driver=ODBC+Driver+18+for+SQL+Server'
|
|
310
|
+
python scripts/certify_dialect.py 'mysql+pymysql://user:pw@host/db'
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
It creates three `schemagate_cert_` tables, reflects them, runs selection and
|
|
314
|
+
identity scoping end to end, drops them again, and exits non-zero if anything
|
|
315
|
+
failed. Point it at a scratch schema.
|
|
316
|
+
|
|
317
|
+
| | |
|
|
318
|
+
|---|---|
|
|
319
|
+
| SQLite | certified, 10/10, in CI |
|
|
320
|
+
| PostgreSQL | certified, 10/10 on PostgreSQL 16, plus the full 260-object suite |
|
|
321
|
+
| Oracle | not yet run against a live instance |
|
|
322
|
+
| SQL Server | not yet run against a live instance |
|
|
323
|
+
| MySQL / MariaDB | not yet run against a live instance |
|
|
324
|
+
|
|
325
|
+
The bottom three say what they say because I haven't had a live instance to run
|
|
326
|
+
them against, not because I expect trouble. Run the script and tell me what
|
|
327
|
+
happens.
|
|
328
|
+
|
|
329
|
+
The same checks run under pytest if you export a URL, which is how CI certifies
|
|
330
|
+
a dialect for good:
|
|
331
|
+
|
|
332
|
+
```bash
|
|
333
|
+
export SCHEMAGATE_POSTGRES_URL='postgresql+psycopg://…'
|
|
334
|
+
export SCHEMAGATE_ORACLE_URL='oracle+oracledb://…'
|
|
335
|
+
export SCHEMAGATE_MSSQL_URL='mssql+pyodbc://…'
|
|
336
|
+
export SCHEMAGATE_MYSQL_URL='mysql+pymysql://…'
|
|
337
|
+
pytest tests/test_dialects.py -v
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
## Using it from an agent
|
|
341
|
+
|
|
342
|
+
If you already have an agent that writes SQL, the fastest way in is to let it
|
|
343
|
+
call schemagate as a tool rather than wiring the library into your code.
|
|
344
|
+
|
|
345
|
+
**MCP.** Claude Desktop, Claude Code, Cursor, or anything else that speaks the
|
|
346
|
+
Model Context Protocol:
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
pip install 'schemagate[mcp]'
|
|
350
|
+
SCHEMAGATE_DATABASE_URL=postgresql://localhost/app python -m schemagate.mcp_server
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Claude Desktop config:
|
|
354
|
+
|
|
355
|
+
```json
|
|
356
|
+
{"mcpServers": {"schemagate": {
|
|
357
|
+
"command": "python", "args": ["-m", "schemagate.mcp_server"],
|
|
358
|
+
"env": {"SCHEMAGATE_DATABASE_URL": "postgresql://localhost/app"}}}}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Three tools: `select_schema` (the DDL for a question, scoped to the caller),
|
|
362
|
+
`list_objects` (what this caller can see), `describe_object` (one object's full
|
|
363
|
+
DDL). All three take `principal` and `roles`. If the client leaves them out,
|
|
364
|
+
the caller is anonymous and sees only unrestricted objects. A restricted
|
|
365
|
+
object and a missing one return the same error, so existence doesn't leak.
|
|
366
|
+
`SCHEMAGATE_DATABASE_URL=demo` serves the bundled schema.
|
|
367
|
+
|
|
368
|
+
To host it for a team rather than one desktop:
|
|
369
|
+
|
|
370
|
+
```bash
|
|
371
|
+
SCHEMAGATE_MCP_TRANSPORT=streamable-http SCHEMAGATE_MCP_PORT=8765 python -m schemagate.mcp_server
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
It's built not to die. The index lives in memory after startup, so the
|
|
375
|
+
database going away does not take the server with it — `select_schema` keeps
|
|
376
|
+
answering from the last good reflection, and `refresh_catalog` reports the
|
|
377
|
+
failure instead of raising. Every tool catches everything and returns
|
|
378
|
+
`{"error": ...}`; a bad request cannot end the session for other clients.
|
|
379
|
+
`health` tells a load balancer what state it's in. A test throws 125 kinds of
|
|
380
|
+
garbage at every tool and then checks the next good request still works, and
|
|
381
|
+
another does the same through a real client over stdio. Works on MCP SDK 1.x
|
|
382
|
+
and 2.x; the 2.0 rename broke a fresh install once and there's a shim and a
|
|
383
|
+
test for it now.
|
|
384
|
+
|
|
385
|
+
**LangChain.** A proper `BaseRetriever`, so it composes:
|
|
386
|
+
|
|
387
|
+
```bash
|
|
388
|
+
pip install 'schemagate[langchain]'
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
```python
|
|
392
|
+
from schemagate.integrations.langchain import SchemagateRetriever, prompt_fragment
|
|
393
|
+
|
|
394
|
+
retriever = SchemagateRetriever(catalog=cat, top_k=6,
|
|
395
|
+
principal=Principal("okta:jdoe", roles={"finance"}))
|
|
396
|
+
chain = retriever | RunnableLambda(prompt_fragment) | your_sql_prompt | llm
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
The principal is bound at construction on purpose. Build one retriever per
|
|
400
|
+
caller; a chain can't forget to pass identity if the retriever already has it.
|
|
401
|
+
|
|
402
|
+
## Keeping the index in Oracle
|
|
403
|
+
|
|
404
|
+
`MemoryStore` rebuilds on every process start. Fine for a few hundred objects,
|
|
405
|
+
wrong for a long-lived service. `OracleStore` keeps vectors in Oracle 23ai's
|
|
406
|
+
native `VECTOR` type so the nearest-neighbour search runs in the database:
|
|
407
|
+
|
|
408
|
+
```python
|
|
409
|
+
from schemagate.stores.oracle import OracleStore
|
|
410
|
+
|
|
411
|
+
store = OracleStore(dsn="user/pw@host:1521/FREEPDB1", dim=512)
|
|
412
|
+
store.create_schema() # idempotent
|
|
413
|
+
|
|
414
|
+
cat = Catalog(store=store).bootstrap("oracle+oracledb://…")
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Pass `connection=` instead of `dsn=` to reuse your app's pool. It won't close a
|
|
418
|
+
connection it didn't open.
|
|
419
|
+
|
|
420
|
+
Scoping is a predicate inside the scored subquery, not a filter applied after
|
|
421
|
+
the rows come back. A row the caller can't see is never ranked and never leaves
|
|
422
|
+
the database.
|
|
423
|
+
|
|
424
|
+
Same caveat as above: 26 tests pin the SQL, the bind types and the scope
|
|
425
|
+
predicate, and every statement is checked against an independent Oracle parser,
|
|
426
|
+
but none of it has run against a live 23ai instance yet. To do that:
|
|
427
|
+
|
|
428
|
+
```bash
|
|
429
|
+
export SCHEMAGATE_ORACLE_DSN='user/password@host:1521/FREEPDB1'
|
|
430
|
+
pytest tests/test_store_conformance.py -v
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
Oracle Cloud's Always Free ATP is enough.
|
|
434
|
+
|
|
435
|
+
## Things that will bite you
|
|
436
|
+
|
|
437
|
+
**Archive and staging twins are handled, but know how.** If your warehouse has
|
|
438
|
+
`orders`, `orders_bkp` and `stg_orders`, the copies carry the same name words in
|
|
439
|
+
a shorter document, and cosine similarity likes short documents. Left alone,
|
|
440
|
+
a three-column `_tmp` copy beats the twenty-five-column table it was copied
|
|
441
|
+
from, even with a hint on the real one — I watched it happen. So an object
|
|
442
|
+
whose name is a real object's name plus `_bkp`, `_old`, `_tmp`, `_v2`,
|
|
443
|
+
`_archive` and so on, or `stg_`/`tmp_` in front, is ranked below the object it
|
|
444
|
+
shadows. Only when that object exists: a lone `pricing_v2` with no `pricing`
|
|
445
|
+
is left alone. Only in the same schema. And never when you name the copy
|
|
446
|
+
outright — asking for `fact_claim_line_v2` gets you `fact_claim_line_v2`. The
|
|
447
|
+
lists are `DEFAULT_SHADOW_SUFFIXES` and `DEFAULT_SHADOW_PREFIXES`; pass your
|
|
448
|
+
own to `Catalog(...)`, or empty tuples to switch it off. `cat.shadows()` shows
|
|
449
|
+
what was detected.
|
|
450
|
+
|
|
451
|
+
**Identifier length.** PostgreSQL truncates names to 63 bytes at creation.
|
|
452
|
+
That's the database doing it, not schemagate, and there's nothing to be done from
|
|
453
|
+
this side.
|
|
454
|
+
|
|
455
|
+
**Non-English schemas** work, including Chinese, Japanese and Korean, and
|
|
456
|
+
accents fold both ways so a search for `facturacion` finds `facturación`. But a
|
|
457
|
+
question in English will not find a table named in Spanish. Nothing lexical can
|
|
458
|
+
bridge that. Descriptions can.
|
|
459
|
+
|
|
460
|
+
**`top_k` is not a hard cap.** Foreign-key expansion runs after selection and
|
|
461
|
+
adds join tables on top. That's deliberate — SQL that references a table you
|
|
462
|
+
didn't include won't run — but size your prompt budget for it.
|
|
463
|
+
|
|
464
|
+
## Status
|
|
465
|
+
|
|
466
|
+
v0.1. Alpha, and the API may still move.
|
|
467
|
+
|
|
468
|
+
| | |
|
|
469
|
+
|---|---|
|
|
470
|
+
| Reflection | certified on SQLite and PostgreSQL |
|
|
471
|
+
| `MemoryStore` | done |
|
|
472
|
+
| AI cataloging | done, tested offline against fake providers |
|
|
473
|
+
| CLI | done |
|
|
474
|
+
| Studio (`schemagate studio`, and the hosted demo) | done, driven by a real browser in tests |
|
|
475
|
+
| MCP server | done, tested through a real MCP client |
|
|
476
|
+
| LangChain retriever | done, tested against langchain-core |
|
|
477
|
+
| `OracleStore` | written and statically verified, needs a live run |
|
|
478
|
+
| pgvector store | not started |
|
|
479
|
+
|
|
480
|
+
`import schemagate` never imports any provider SDK, and there's a test asserting it.
|
|
481
|
+
|
|
482
|
+
Default embeddings are stable across processes, machines and Python versions,
|
|
483
|
+
so cached or persisted vectors stay valid. That one is enforced by a test that
|
|
484
|
+
runs the embedder in fresh subprocesses under different `PYTHONHASHSEED`
|
|
485
|
+
values, because it was broken once and nothing else caught it.
|
|
486
|
+
|
|
487
|
+
Apache-2.0. Ashish Sinha.
|