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.
@@ -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"]
@@ -0,0 +1,3 @@
1
+ """A relational database as a kbforge source, through configuration alone."""
2
+
3
+ __version__ = "0.1.0"