kbforge-sql 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.
- kbforge_sql-0.1.0/.gitignore +19 -0
- kbforge_sql-0.1.0/PKG-INFO +242 -0
- kbforge_sql-0.1.0/README.md +230 -0
- kbforge_sql-0.1.0/pyproject.toml +25 -0
- kbforge_sql-0.1.0/src/kbforge_sql/__init__.py +3 -0
- kbforge_sql-0.1.0/src/kbforge_sql/config.py +182 -0
- kbforge_sql-0.1.0/src/kbforge_sql/connector.py +385 -0
- kbforge_sql-0.1.0/src/kbforge_sql/errors.py +9 -0
- kbforge_sql-0.1.0/src/kbforge_sql/identity.py +46 -0
- kbforge_sql-0.1.0/src/kbforge_sql/render.py +52 -0
- kbforge_sql-0.1.0/src/kbforge_sql/values.py +54 -0
- kbforge_sql-0.1.0/tests/sql_testdb.py +89 -0
- kbforge_sql-0.1.0/tests/test_sql_config.py +221 -0
- kbforge_sql-0.1.0/tests/test_sql_connector.py +330 -0
- kbforge_sql-0.1.0/tests/test_sql_deletions.py +185 -0
- kbforge_sql-0.1.0/tests/test_sql_errors.py +127 -0
- kbforge_sql-0.1.0/tests/test_sql_identity.py +55 -0
- kbforge_sql-0.1.0/tests/test_sql_live.py +58 -0
- kbforge_sql-0.1.0/tests/test_sql_render.py +63 -0
- kbforge_sql-0.1.0/tests/test_sql_values.py +62 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*.egg-info/
|
|
4
|
+
dist/
|
|
5
|
+
build/
|
|
6
|
+
.eggs/
|
|
7
|
+
*.egg
|
|
8
|
+
.venv/
|
|
9
|
+
.pytest_cache/
|
|
10
|
+
.ruff_cache/
|
|
11
|
+
.coverage
|
|
12
|
+
|
|
13
|
+
# Local environment / secrets — never commit real keys
|
|
14
|
+
.env
|
|
15
|
+
.env.*
|
|
16
|
+
!.env.example
|
|
17
|
+
|
|
18
|
+
# subagent-driven-development scratch (ledger, briefs, reports)
|
|
19
|
+
.superpowers/
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: kbforge-sql
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SQL source connector for kbforge
|
|
5
|
+
Author-email: Qing <qingye779@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Requires-Dist: kbforge>=0.8.0
|
|
9
|
+
Requires-Dist: pydantic>=2.0
|
|
10
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# kbforge-sql
|
|
14
|
+
|
|
15
|
+
`kbforge-sql` lets any database [SQLAlchemy](https://www.sqlalchemy.org/) can reach be a
|
|
16
|
+
[kbforge](https://github.com/flyersworder/kbforge) source. Install it alongside kbforge and
|
|
17
|
+
a source becomes one scoped `SELECT` instead of a Python package: each row the query
|
|
18
|
+
returns (or each group of rows sharing an id) becomes one canonical document, rendered as
|
|
19
|
+
fixed-format markdown. It registers itself under the `kbforge.connectors` entry-point
|
|
20
|
+
group, so `kbforge list` shows `sql` with no further wiring. Every run is a full snapshot,
|
|
21
|
+
which makes deletions derivable — an id seen last run and missing now becomes an explicit
|
|
22
|
+
tombstone — so this is the first kbforge connector that emits tombstones at all.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install kbforge-sql
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`kbforge-sql` ships **no database driver**; the operator installs the one their engine
|
|
31
|
+
needs, for example:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install denodo-sqlalchemy # Denodo
|
|
35
|
+
pip install "psycopg[binary]" # PostgreSQL
|
|
36
|
+
pip install oracledb # Oracle
|
|
37
|
+
pip install pyodbc # SQL Server and other ODBC targets
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Configure a source
|
|
41
|
+
|
|
42
|
+
A flat source — one row, one document:
|
|
43
|
+
|
|
44
|
+
```yaml
|
|
45
|
+
system: products # per-instance identity; prefixes every doc_id
|
|
46
|
+
url_env: DENODO_URL # env var NAME: denodo://kb_reader@host:9996/product_vdb
|
|
47
|
+
password_env: DENODO_PASSWORD # optional env var NAME; injected via URL.set()
|
|
48
|
+
query: |
|
|
49
|
+
SELECT product_id, product_name, family, status, description, last_refreshed
|
|
50
|
+
FROM iv_product
|
|
51
|
+
WHERE app_id = 'EV-TRACTION'
|
|
52
|
+
id: [product_id] # one or more columns -> native_id
|
|
53
|
+
title: product_name
|
|
54
|
+
text: description # optional free-text column, placed verbatim
|
|
55
|
+
facets: [family, status] # scalar columns -> filterable frontmatter
|
|
56
|
+
exclude: [last_refreshed] # volatile columns, dropped before hashing
|
|
57
|
+
type: product # OKF `type` for every concept; default "concept"
|
|
58
|
+
url_template: https://portal.example/products/{product_id} # optional; {column} fields, id columns only
|
|
59
|
+
retries: 2 # transient-error retries; default 2
|
|
60
|
+
max_removed_fraction: 0.5 # deletion ceiling, see below; default 0.5
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
A grouped source — a joined view where one application spans many rows, folded into one
|
|
64
|
+
document per application:
|
|
65
|
+
|
|
66
|
+
```yaml
|
|
67
|
+
system: applications
|
|
68
|
+
url_env: DENODO_URL
|
|
69
|
+
password_env: DENODO_PASSWORD
|
|
70
|
+
query: |
|
|
71
|
+
SELECT app_id, app_name, segment, app_description,
|
|
72
|
+
product_id, product_name, product_status
|
|
73
|
+
FROM iv_product_application
|
|
74
|
+
WHERE app_id = 'EV-TRACTION'
|
|
75
|
+
id: [app_id]
|
|
76
|
+
title: app_name
|
|
77
|
+
text: app_description
|
|
78
|
+
facets: [segment]
|
|
79
|
+
type: application
|
|
80
|
+
group:
|
|
81
|
+
children: [product_id, product_name, product_status]
|
|
82
|
+
order_by: [product_id]
|
|
83
|
+
heading: Products # optional; defaults to the source `system`
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Columns outside `id` and `group.children` must be constant across an entity's rows; a
|
|
87
|
+
grouped entity whose rows disagree on one is a fetch error naming the id and the column.
|
|
88
|
+
|
|
89
|
+
kbforge takes connector config as repeated YAML-typed `--set` pairs, so that is one key
|
|
90
|
+
per flag:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
kbforge run --connector sql \
|
|
94
|
+
--set system=products \
|
|
95
|
+
--set url_env=DENODO_URL \
|
|
96
|
+
--set password_env=DENODO_PASSWORD \
|
|
97
|
+
--set 'query=SELECT product_id, product_name, family, status, description, last_refreshed FROM iv_product' \
|
|
98
|
+
--set 'id=[product_id]' \
|
|
99
|
+
--set title=product_name \
|
|
100
|
+
--set text=description \
|
|
101
|
+
--set 'facets=[family, status]' \
|
|
102
|
+
--set 'exclude=[last_refreshed]' \
|
|
103
|
+
--set type=product \
|
|
104
|
+
--mirror .kbforge/mirror --out .kbforge/out --state .kbforge/state
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Because `--set` values are YAML-typed, a query with its own quoted string literal or a
|
|
108
|
+
multi-line `WHERE` clause is easier to get right in a shell variable or script than typed
|
|
109
|
+
inline; quote the whole `key=value` pair once and let the query itself carry its quotes.
|
|
110
|
+
|
|
111
|
+
The query reaches the driver exactly as written, with no parameter binding, so `LIKE 'EV-%'`,
|
|
112
|
+
Postgres `::` casts and `'10:30'` literals are safe on every driver. On the first result
|
|
113
|
+
the connector checks that every configured column exists (listing the real ones if not)
|
|
114
|
+
and that no two result columns share a name; `SELECT a.id, b.id` must alias them apart.
|
|
115
|
+
|
|
116
|
+
## First run: `dry-run` and a throwaway mirror
|
|
117
|
+
|
|
118
|
+
No tool proves a query executable across every dialect, so try a new source against a
|
|
119
|
+
mirror you can throw away before pointing it at the real one. kbforge's default publisher
|
|
120
|
+
is `dry-run`, so the query runs and every concept renders to local files with nothing
|
|
121
|
+
opened anywhere:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
kbforge run --connector sql --set … \
|
|
125
|
+
--mirror /tmp/try --state /tmp/try-state --out /tmp/try-out
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Read the rendered files under `/tmp/try-out` before wiring in a real `--mirror` and
|
|
129
|
+
`--publisher`.
|
|
130
|
+
|
|
131
|
+
## Credentials
|
|
132
|
+
|
|
133
|
+
`url_env` and `password_env` hold environment variable **names**, never values. A
|
|
134
|
+
credential never appears in config, on a command line, or in an error message — errors
|
|
135
|
+
name the env var, not its content. With `password_env` set, the URL carries no password
|
|
136
|
+
and the raw value is injected with SQLAlchemy's `URL.set(password=...)`, so a password
|
|
137
|
+
containing `@`, `:`, `/` or `%` needs no percent-escaping.
|
|
138
|
+
|
|
139
|
+
The expected deployment is a service account, and the recommended one is a **dedicated
|
|
140
|
+
read-only account** granted `SELECT` only on the views its queries use. The connector
|
|
141
|
+
rolls back every transaction and never commits — no commit exists anywhere in the
|
|
142
|
+
package — but that is a bound on accidents, not a guarantee: kbforge cannot prove a SQL
|
|
143
|
+
string is free of side effects through a function call, a procedure, or a dialect
|
|
144
|
+
extension, so the database's grants are what actually prevent a write. (The connector
|
|
145
|
+
issues an explicit rollback even though SQLAlchemy also rolls back on connection close;
|
|
146
|
+
belt-and-braces, since the account's grants are what really carry the guarantee.)
|
|
147
|
+
|
|
148
|
+
`kbforge_validate_config` also rejects a blank `title` or a blank `id` column name before
|
|
149
|
+
any connection is attempted, alongside the checks in §3.1 of the design note.
|
|
150
|
+
|
|
151
|
+
A dropped or refused connection is retried up to `retries` times (default 2) with
|
|
152
|
+
exponential backoff capped at 30 seconds; bad SQL, a missing view, or a missing grant fails
|
|
153
|
+
at once, because retrying a typo only delays the message — on drivers that classify it that
|
|
154
|
+
way (see "Known limits").
|
|
155
|
+
|
|
156
|
+
## Deletions
|
|
157
|
+
|
|
158
|
+
Every run is a full snapshot: the connector keeps the id set from the last published run
|
|
159
|
+
in the cursor manifest, and any id missing from this run's result becomes an explicit
|
|
160
|
+
tombstone. Two guards sit in front of that, because a view that returns nothing — a cache
|
|
161
|
+
mid-refresh, a failed upstream load, a filter changed upstream — is not an error to the
|
|
162
|
+
database:
|
|
163
|
+
|
|
164
|
+
- **Empty result with a non-empty prior manifest fails the run** and emits no tombstones,
|
|
165
|
+
rather than reading "the source is empty" as "delete every concept". An intentionally
|
|
166
|
+
empty source is rare enough to be handled by removing its config instead.
|
|
167
|
+
- **Deletion ceiling.** If the tombstones would exceed `max_removed_fraction` (default
|
|
168
|
+
`0.5`) of the prior manifest, the run fails and states the count and the fraction. For a
|
|
169
|
+
deliberate large cleanup, rerun with `KBFORGE_SQL_ALLOW_REMOVALS` set (see below) rather
|
|
170
|
+
than raising `max_removed_fraction` — see "Known limits" for why.
|
|
171
|
+
|
|
172
|
+
## Known limits
|
|
173
|
+
|
|
174
|
+
**Whether an error is retried is the driver's call.** The connector retries SQLAlchemy's
|
|
175
|
+
`OperationalError` and `InterfaceError`, which most drivers reserve for connection
|
|
176
|
+
failures. Some also raise `OperationalError` for statement errors — sqlite3 for a missing
|
|
177
|
+
table or a syntax error, pymysql for access denied and unmapped server errors — so on those
|
|
178
|
+
drivers a bad query is retried before it fails, and the message reads "OperationalError
|
|
179
|
+
after N attempt(s)". That costs time, never correctness. PostgreSQL (psycopg) reports these
|
|
180
|
+
as `ProgrammingError` and fails at once; check your driver with a deliberately misspelled
|
|
181
|
+
view on a first run, and set `retries: 0` if it misclassifies.
|
|
182
|
+
|
|
183
|
+
**Editing ANY config key resets deletion memory, not just the query.** Cursor slots are
|
|
184
|
+
keyed by a digest of the *whole* connector config (`pipeline._instance_key`), so editing
|
|
185
|
+
`query` — narrowing its `WHERE`, say — or any other key, including `max_removed_fraction`
|
|
186
|
+
itself, means the next run finds no prior cursor: `NoOp`, no tombstones, and the old slot
|
|
187
|
+
trips the ceiling again if the edit is ever reverted. The connector cannot fix this; it is
|
|
188
|
+
deliberately mirror-blind. After narrowing a query, remove the stale concepts by hand in
|
|
189
|
+
the review repository.
|
|
190
|
+
|
|
191
|
+
**A tripped deletion ceiling is cleared with `KBFORGE_SQL_ALLOW_REMOVALS`, not by raising
|
|
192
|
+
`max_removed_fraction`.** Raising the fraction is a config edit, so it hits the limit
|
|
193
|
+
above: it resets deletion memory instead of performing the cleanup, and the old cursor
|
|
194
|
+
slot trips the ceiling again once the fraction is reverted. Instead, set the environment
|
|
195
|
+
variable `KBFORGE_SQL_ALLOW_REMOVALS` to a comma-separated list of source `system` names
|
|
196
|
+
(for example `KBFORGE_SQL_ALLOW_REMOVALS=products`) and rerun with the config unchanged —
|
|
197
|
+
it is read at fetch time, out of band from the config, so the cursor slot and the rest of
|
|
198
|
+
`max_removed_fraction`'s guard stay intact. The empty-result guard always applies, even
|
|
199
|
+
with the override set.
|
|
200
|
+
|
|
201
|
+
**Ids can collide with another source's.** `concept_path` drops the system prefix, so a
|
|
202
|
+
product id `42` and an application id `42` render the same file, and the pipeline aborts
|
|
203
|
+
on the collision — numeric keys make this likely. Until bundle paths are system-qualified,
|
|
204
|
+
give ids a kind prefix in the query itself (`'product-' || product_id AS kb_id`, or the
|
|
205
|
+
`CONCAT` your dialect prefers) and use that column as `id`. No connector config is needed
|
|
206
|
+
for this.
|
|
207
|
+
|
|
208
|
+
**Ids differing only in case are one directory on a case-insensitive checkout.** `Foo` and
|
|
209
|
+
`foo` render distinct `native_id`s but the same path on the default macOS or Windows
|
|
210
|
+
filesystem, so a case-only id pair collides in a local checkout even though it would not
|
|
211
|
+
on Linux CI.
|
|
212
|
+
|
|
213
|
+
**Canonicalization can fold two raw ids into one entity.** NFC normalization and
|
|
214
|
+
trailing-whitespace stripping (§4.3) run before the duplicate-id check, so two raw id
|
|
215
|
+
values that canonicalize to the same string collapse onto one id: without `group`, that is
|
|
216
|
+
the duplicate-id error; with `group`, it is a silent merge into one entity's rows.
|
|
217
|
+
|
|
218
|
+
## Testing against your own database
|
|
219
|
+
|
|
220
|
+
The package ships a live test that is skipped unless you ask for it. Point it at a
|
|
221
|
+
read-only view and run it with your driver installed:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
pip install kbforge-sql denodo-sqlalchemy pytest # or psycopg[binary], oracledb, ...
|
|
225
|
+
export KBFORGE_SQL_LIVE_URL='denodo://kb_reader@denodo.example:9996/product_vdb'
|
|
226
|
+
export KBFORGE_SQL_LIVE_PASSWORD=... # optional
|
|
227
|
+
export KBFORGE_SQL_LIVE_QUERY="SELECT product_id, product_name FROM iv_product WHERE product_name LIKE '%a%'"
|
|
228
|
+
export KBFORGE_SQL_LIVE_ID=product_id KBFORGE_SQL_LIVE_TITLE=product_name
|
|
229
|
+
pytest packages/kbforge-sql/tests/test_sql_live.py --run-live
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
It fetches twice and requires identical content hashes, which is the check that matters
|
|
233
|
+
for a new source: anything volatile in the result (a refresh timestamp, a computed
|
|
234
|
+
column) fails it, and belongs in `exclude`. Without the `KBFORGE_SQL_LIVE_QUERY` variables
|
|
235
|
+
it queries `information_schema`, which any PostgreSQL accepts.
|
|
236
|
+
|
|
237
|
+
## Design
|
|
238
|
+
|
|
239
|
+
The [design note](https://github.com/flyersworder/kbforge/blob/main/docs/design/2026-09-18-sql-source-connector-design.md)
|
|
240
|
+
holds the full rationale and what remains deferred (incremental fetch, relations between
|
|
241
|
+
rows, deletion memory across a query edit). The shipped design is in
|
|
242
|
+
[`docs/architecture.md`](https://github.com/flyersworder/kbforge/blob/main/docs/architecture.md) §4.1.
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# kbforge-sql
|
|
2
|
+
|
|
3
|
+
`kbforge-sql` lets any database [SQLAlchemy](https://www.sqlalchemy.org/) can reach be a
|
|
4
|
+
[kbforge](https://github.com/flyersworder/kbforge) source. Install it alongside kbforge and
|
|
5
|
+
a source becomes one scoped `SELECT` instead of a Python package: each row the query
|
|
6
|
+
returns (or each group of rows sharing an id) becomes one canonical document, rendered as
|
|
7
|
+
fixed-format markdown. It registers itself under the `kbforge.connectors` entry-point
|
|
8
|
+
group, so `kbforge list` shows `sql` with no further wiring. Every run is a full snapshot,
|
|
9
|
+
which makes deletions derivable — an id seen last run and missing now becomes an explicit
|
|
10
|
+
tombstone — so this is the first kbforge connector that emits tombstones at all.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install kbforge-sql
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`kbforge-sql` ships **no database driver**; the operator installs the one their engine
|
|
19
|
+
needs, for example:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install denodo-sqlalchemy # Denodo
|
|
23
|
+
pip install "psycopg[binary]" # PostgreSQL
|
|
24
|
+
pip install oracledb # Oracle
|
|
25
|
+
pip install pyodbc # SQL Server and other ODBC targets
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Configure a source
|
|
29
|
+
|
|
30
|
+
A flat source — one row, one document:
|
|
31
|
+
|
|
32
|
+
```yaml
|
|
33
|
+
system: products # per-instance identity; prefixes every doc_id
|
|
34
|
+
url_env: DENODO_URL # env var NAME: denodo://kb_reader@host:9996/product_vdb
|
|
35
|
+
password_env: DENODO_PASSWORD # optional env var NAME; injected via URL.set()
|
|
36
|
+
query: |
|
|
37
|
+
SELECT product_id, product_name, family, status, description, last_refreshed
|
|
38
|
+
FROM iv_product
|
|
39
|
+
WHERE app_id = 'EV-TRACTION'
|
|
40
|
+
id: [product_id] # one or more columns -> native_id
|
|
41
|
+
title: product_name
|
|
42
|
+
text: description # optional free-text column, placed verbatim
|
|
43
|
+
facets: [family, status] # scalar columns -> filterable frontmatter
|
|
44
|
+
exclude: [last_refreshed] # volatile columns, dropped before hashing
|
|
45
|
+
type: product # OKF `type` for every concept; default "concept"
|
|
46
|
+
url_template: https://portal.example/products/{product_id} # optional; {column} fields, id columns only
|
|
47
|
+
retries: 2 # transient-error retries; default 2
|
|
48
|
+
max_removed_fraction: 0.5 # deletion ceiling, see below; default 0.5
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
A grouped source — a joined view where one application spans many rows, folded into one
|
|
52
|
+
document per application:
|
|
53
|
+
|
|
54
|
+
```yaml
|
|
55
|
+
system: applications
|
|
56
|
+
url_env: DENODO_URL
|
|
57
|
+
password_env: DENODO_PASSWORD
|
|
58
|
+
query: |
|
|
59
|
+
SELECT app_id, app_name, segment, app_description,
|
|
60
|
+
product_id, product_name, product_status
|
|
61
|
+
FROM iv_product_application
|
|
62
|
+
WHERE app_id = 'EV-TRACTION'
|
|
63
|
+
id: [app_id]
|
|
64
|
+
title: app_name
|
|
65
|
+
text: app_description
|
|
66
|
+
facets: [segment]
|
|
67
|
+
type: application
|
|
68
|
+
group:
|
|
69
|
+
children: [product_id, product_name, product_status]
|
|
70
|
+
order_by: [product_id]
|
|
71
|
+
heading: Products # optional; defaults to the source `system`
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Columns outside `id` and `group.children` must be constant across an entity's rows; a
|
|
75
|
+
grouped entity whose rows disagree on one is a fetch error naming the id and the column.
|
|
76
|
+
|
|
77
|
+
kbforge takes connector config as repeated YAML-typed `--set` pairs, so that is one key
|
|
78
|
+
per flag:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
kbforge run --connector sql \
|
|
82
|
+
--set system=products \
|
|
83
|
+
--set url_env=DENODO_URL \
|
|
84
|
+
--set password_env=DENODO_PASSWORD \
|
|
85
|
+
--set 'query=SELECT product_id, product_name, family, status, description, last_refreshed FROM iv_product' \
|
|
86
|
+
--set 'id=[product_id]' \
|
|
87
|
+
--set title=product_name \
|
|
88
|
+
--set text=description \
|
|
89
|
+
--set 'facets=[family, status]' \
|
|
90
|
+
--set 'exclude=[last_refreshed]' \
|
|
91
|
+
--set type=product \
|
|
92
|
+
--mirror .kbforge/mirror --out .kbforge/out --state .kbforge/state
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Because `--set` values are YAML-typed, a query with its own quoted string literal or a
|
|
96
|
+
multi-line `WHERE` clause is easier to get right in a shell variable or script than typed
|
|
97
|
+
inline; quote the whole `key=value` pair once and let the query itself carry its quotes.
|
|
98
|
+
|
|
99
|
+
The query reaches the driver exactly as written, with no parameter binding, so `LIKE 'EV-%'`,
|
|
100
|
+
Postgres `::` casts and `'10:30'` literals are safe on every driver. On the first result
|
|
101
|
+
the connector checks that every configured column exists (listing the real ones if not)
|
|
102
|
+
and that no two result columns share a name; `SELECT a.id, b.id` must alias them apart.
|
|
103
|
+
|
|
104
|
+
## First run: `dry-run` and a throwaway mirror
|
|
105
|
+
|
|
106
|
+
No tool proves a query executable across every dialect, so try a new source against a
|
|
107
|
+
mirror you can throw away before pointing it at the real one. kbforge's default publisher
|
|
108
|
+
is `dry-run`, so the query runs and every concept renders to local files with nothing
|
|
109
|
+
opened anywhere:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
kbforge run --connector sql --set … \
|
|
113
|
+
--mirror /tmp/try --state /tmp/try-state --out /tmp/try-out
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Read the rendered files under `/tmp/try-out` before wiring in a real `--mirror` and
|
|
117
|
+
`--publisher`.
|
|
118
|
+
|
|
119
|
+
## Credentials
|
|
120
|
+
|
|
121
|
+
`url_env` and `password_env` hold environment variable **names**, never values. A
|
|
122
|
+
credential never appears in config, on a command line, or in an error message — errors
|
|
123
|
+
name the env var, not its content. With `password_env` set, the URL carries no password
|
|
124
|
+
and the raw value is injected with SQLAlchemy's `URL.set(password=...)`, so a password
|
|
125
|
+
containing `@`, `:`, `/` or `%` needs no percent-escaping.
|
|
126
|
+
|
|
127
|
+
The expected deployment is a service account, and the recommended one is a **dedicated
|
|
128
|
+
read-only account** granted `SELECT` only on the views its queries use. The connector
|
|
129
|
+
rolls back every transaction and never commits — no commit exists anywhere in the
|
|
130
|
+
package — but that is a bound on accidents, not a guarantee: kbforge cannot prove a SQL
|
|
131
|
+
string is free of side effects through a function call, a procedure, or a dialect
|
|
132
|
+
extension, so the database's grants are what actually prevent a write. (The connector
|
|
133
|
+
issues an explicit rollback even though SQLAlchemy also rolls back on connection close;
|
|
134
|
+
belt-and-braces, since the account's grants are what really carry the guarantee.)
|
|
135
|
+
|
|
136
|
+
`kbforge_validate_config` also rejects a blank `title` or a blank `id` column name before
|
|
137
|
+
any connection is attempted, alongside the checks in §3.1 of the design note.
|
|
138
|
+
|
|
139
|
+
A dropped or refused connection is retried up to `retries` times (default 2) with
|
|
140
|
+
exponential backoff capped at 30 seconds; bad SQL, a missing view, or a missing grant fails
|
|
141
|
+
at once, because retrying a typo only delays the message — on drivers that classify it that
|
|
142
|
+
way (see "Known limits").
|
|
143
|
+
|
|
144
|
+
## Deletions
|
|
145
|
+
|
|
146
|
+
Every run is a full snapshot: the connector keeps the id set from the last published run
|
|
147
|
+
in the cursor manifest, and any id missing from this run's result becomes an explicit
|
|
148
|
+
tombstone. Two guards sit in front of that, because a view that returns nothing — a cache
|
|
149
|
+
mid-refresh, a failed upstream load, a filter changed upstream — is not an error to the
|
|
150
|
+
database:
|
|
151
|
+
|
|
152
|
+
- **Empty result with a non-empty prior manifest fails the run** and emits no tombstones,
|
|
153
|
+
rather than reading "the source is empty" as "delete every concept". An intentionally
|
|
154
|
+
empty source is rare enough to be handled by removing its config instead.
|
|
155
|
+
- **Deletion ceiling.** If the tombstones would exceed `max_removed_fraction` (default
|
|
156
|
+
`0.5`) of the prior manifest, the run fails and states the count and the fraction. For a
|
|
157
|
+
deliberate large cleanup, rerun with `KBFORGE_SQL_ALLOW_REMOVALS` set (see below) rather
|
|
158
|
+
than raising `max_removed_fraction` — see "Known limits" for why.
|
|
159
|
+
|
|
160
|
+
## Known limits
|
|
161
|
+
|
|
162
|
+
**Whether an error is retried is the driver's call.** The connector retries SQLAlchemy's
|
|
163
|
+
`OperationalError` and `InterfaceError`, which most drivers reserve for connection
|
|
164
|
+
failures. Some also raise `OperationalError` for statement errors — sqlite3 for a missing
|
|
165
|
+
table or a syntax error, pymysql for access denied and unmapped server errors — so on those
|
|
166
|
+
drivers a bad query is retried before it fails, and the message reads "OperationalError
|
|
167
|
+
after N attempt(s)". That costs time, never correctness. PostgreSQL (psycopg) reports these
|
|
168
|
+
as `ProgrammingError` and fails at once; check your driver with a deliberately misspelled
|
|
169
|
+
view on a first run, and set `retries: 0` if it misclassifies.
|
|
170
|
+
|
|
171
|
+
**Editing ANY config key resets deletion memory, not just the query.** Cursor slots are
|
|
172
|
+
keyed by a digest of the *whole* connector config (`pipeline._instance_key`), so editing
|
|
173
|
+
`query` — narrowing its `WHERE`, say — or any other key, including `max_removed_fraction`
|
|
174
|
+
itself, means the next run finds no prior cursor: `NoOp`, no tombstones, and the old slot
|
|
175
|
+
trips the ceiling again if the edit is ever reverted. The connector cannot fix this; it is
|
|
176
|
+
deliberately mirror-blind. After narrowing a query, remove the stale concepts by hand in
|
|
177
|
+
the review repository.
|
|
178
|
+
|
|
179
|
+
**A tripped deletion ceiling is cleared with `KBFORGE_SQL_ALLOW_REMOVALS`, not by raising
|
|
180
|
+
`max_removed_fraction`.** Raising the fraction is a config edit, so it hits the limit
|
|
181
|
+
above: it resets deletion memory instead of performing the cleanup, and the old cursor
|
|
182
|
+
slot trips the ceiling again once the fraction is reverted. Instead, set the environment
|
|
183
|
+
variable `KBFORGE_SQL_ALLOW_REMOVALS` to a comma-separated list of source `system` names
|
|
184
|
+
(for example `KBFORGE_SQL_ALLOW_REMOVALS=products`) and rerun with the config unchanged —
|
|
185
|
+
it is read at fetch time, out of band from the config, so the cursor slot and the rest of
|
|
186
|
+
`max_removed_fraction`'s guard stay intact. The empty-result guard always applies, even
|
|
187
|
+
with the override set.
|
|
188
|
+
|
|
189
|
+
**Ids can collide with another source's.** `concept_path` drops the system prefix, so a
|
|
190
|
+
product id `42` and an application id `42` render the same file, and the pipeline aborts
|
|
191
|
+
on the collision — numeric keys make this likely. Until bundle paths are system-qualified,
|
|
192
|
+
give ids a kind prefix in the query itself (`'product-' || product_id AS kb_id`, or the
|
|
193
|
+
`CONCAT` your dialect prefers) and use that column as `id`. No connector config is needed
|
|
194
|
+
for this.
|
|
195
|
+
|
|
196
|
+
**Ids differing only in case are one directory on a case-insensitive checkout.** `Foo` and
|
|
197
|
+
`foo` render distinct `native_id`s but the same path on the default macOS or Windows
|
|
198
|
+
filesystem, so a case-only id pair collides in a local checkout even though it would not
|
|
199
|
+
on Linux CI.
|
|
200
|
+
|
|
201
|
+
**Canonicalization can fold two raw ids into one entity.** NFC normalization and
|
|
202
|
+
trailing-whitespace stripping (§4.3) run before the duplicate-id check, so two raw id
|
|
203
|
+
values that canonicalize to the same string collapse onto one id: without `group`, that is
|
|
204
|
+
the duplicate-id error; with `group`, it is a silent merge into one entity's rows.
|
|
205
|
+
|
|
206
|
+
## Testing against your own database
|
|
207
|
+
|
|
208
|
+
The package ships a live test that is skipped unless you ask for it. Point it at a
|
|
209
|
+
read-only view and run it with your driver installed:
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
pip install kbforge-sql denodo-sqlalchemy pytest # or psycopg[binary], oracledb, ...
|
|
213
|
+
export KBFORGE_SQL_LIVE_URL='denodo://kb_reader@denodo.example:9996/product_vdb'
|
|
214
|
+
export KBFORGE_SQL_LIVE_PASSWORD=... # optional
|
|
215
|
+
export KBFORGE_SQL_LIVE_QUERY="SELECT product_id, product_name FROM iv_product WHERE product_name LIKE '%a%'"
|
|
216
|
+
export KBFORGE_SQL_LIVE_ID=product_id KBFORGE_SQL_LIVE_TITLE=product_name
|
|
217
|
+
pytest packages/kbforge-sql/tests/test_sql_live.py --run-live
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
It fetches twice and requires identical content hashes, which is the check that matters
|
|
221
|
+
for a new source: anything volatile in the result (a refresh timestamp, a computed
|
|
222
|
+
column) fails it, and belongs in `exclude`. Without the `KBFORGE_SQL_LIVE_QUERY` variables
|
|
223
|
+
it queries `information_schema`, which any PostgreSQL accepts.
|
|
224
|
+
|
|
225
|
+
## Design
|
|
226
|
+
|
|
227
|
+
The [design note](https://github.com/flyersworder/kbforge/blob/main/docs/design/2026-09-18-sql-source-connector-design.md)
|
|
228
|
+
holds the full rationale and what remains deferred (incremental fetch, relations between
|
|
229
|
+
rows, deletion memory across a query edit). The shipped design is in
|
|
230
|
+
[`docs/architecture.md`](https://github.com/flyersworder/kbforge/blob/main/docs/architecture.md) §4.1.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "kbforge-sql"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "SQL source connector for kbforge"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
authors = [{ name = "Qing", email = "qingye779@gmail.com" }]
|
|
8
|
+
license = { text = "MIT" }
|
|
9
|
+
dependencies = [
|
|
10
|
+
# 0.8.0: cursor slots keyed per config instance, so two `sql` sources never
|
|
11
|
+
# share a manifest; `synthesize.OKF_OWNED` and `canonical.is_blank` exist.
|
|
12
|
+
"kbforge>=0.8.0",
|
|
13
|
+
"pydantic>=2.0",
|
|
14
|
+
"sqlalchemy>=2.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.entry-points."kbforge.connectors"]
|
|
18
|
+
sql = "kbforge_sql.connector:CONNECTOR"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["hatchling"]
|
|
22
|
+
build-backend = "hatchling.build"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/kbforge_sql"]
|