Skip to content

Commit 179013f

Browse files
EC2 Default Userclaude
andcommitted
fix(writes): check the declared write before the constraint-enforcement profile
RequireWriteResultMode ran RequireNativeConstraintWrite first, so a write on a constrained table that does not declare that operation reported "constraint enforcement profile was not negotiated" instead of "Table ... does not support UPDATE" whenever the worker had not negotiated vgi.constraint.enforce.v1 (every vgi-rust attach; the read-only constrained tables of the example workers). The profile only matters for a write the table declares, so check the operation first, then the profile, then the result mode. Tests: test/support/rowid_constraint_worker.py now keeps the tables' declared writes although it never negotiates the enforcement profile, and gives items_insert_only a primary key. rowid_constraint_metadata.test asserts that INSERT/UPDATE/DELETE on writable constrained tables still fail on the missing profile (also ahead of the result-mode check), while UPDATE and DELETE on the insert-only table are unsupported. constraints.test expects "does not support INSERT/UPDATE/DELETE" on the read-only departments table. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c4a9fea commit 179013f

5 files changed

Lines changed: 112 additions & 10 deletions

File tree

‎docs/writable_tx_fixtures.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,10 @@ Initial rows: `(1, 'a', 10)`, `(2, 'b', 20)`, `(3, 'c', 30)`. Scans return rows
138138
`id INTEGER NOT NULL PRIMARY KEY`, `label VARCHAR`; rows `(1, 'x')`, `(2, 'y')`. Read-only: no
139139
insert, update or delete function, but the same constraint declarations as `items`.
140140
`UPDATE t.main.labels ...` must fail with the engine's `does not support UPDATE`, not with a
141-
constraint-profile error. (The SDK must negotiate `vgi.constraint.enforce.v1` because `items` is a
142-
writable table with constraints; the extension checks that negotiation before the table's write
143-
modes, so a missing profile shows up on `labels` too.)
141+
constraint-profile error. (The SDK must still negotiate `vgi.constraint.enforce.v1`, because `items`
142+
is a writable table with constraints. Before vgi v2.0 commit "check the declared write before the
143+
constraint-enforcement profile", the extension checked that negotiation first, so a missing profile
144+
showed up on `labels` too; it now reports the undeclared operation first.)
144145

145146
## Transaction hooks (transactional attaches only)
146147

‎src/storage/vgi_catalog.cpp‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ static int WriteResultModeRank(const string &mode) {
4848
}
4949

5050
static void RequireWriteResultMode(const VgiTableEntry &table, const string &operation, bool return_chunk) {
51-
vgi::RequireNativeConstraintWrite(*table.GetCatalog().Cast<VgiCatalog>().attach_result(), table.GetTableInfo());
5251
const auto requested = return_chunk ? "rows" : "count";
5352
const auto &modes = table.GetTableInfo().write_result_modes;
5453
auto entry = modes.find(operation);
@@ -59,6 +58,11 @@ static void RequireWriteResultMode(const VgiTableEntry &table, const string &ope
5958
.Set(vgi::error_key::kOperation, StringUtil::Upper(operation)),
6059
StringUtil::Format("Table '%s' does not support %s", table.name, StringUtil::Upper(operation)));
6160
}
61+
// Only a table that declares this operation needs the constraint-enforcement
62+
// profile. Checked after the operation itself, so a read-only constrained
63+
// table reports "does not support UPDATE" whether or not the worker
64+
// negotiated vgi.constraint.enforce.v1.
65+
vgi::RequireNativeConstraintWrite(*table.GetCatalog().Cast<VgiCatalog>().attach_result(), table.GetTableInfo());
6266
if (WriteResultModeRank(entry->second) < WriteResultModeRank(requested)) {
6367
throw BinderException(
6468
vgi::ErrorInfo(vgi::error_subtype::kUnsupported)

‎test/sql/integration/table/constraints.test‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -200,15 +200,28 @@ PRIMARY KEY 4
200200
UNIQUE 2
201201

202202
# ============================================================================
203-
# Reading immutable constraint facts does not authorize constrained writes.
204-
# This must fail at native planning, before fallback write-function discovery.
205-
# Count mode has no RETURNING requirement to accidentally mask the missing gate.
203+
# A constrained table that declares no write function is read-only: writes
204+
# fail at native planning, before fallback write-function discovery, as
205+
# unsupported operations, whether or not the worker negotiated the
206+
# constraint-enforcement profile (only a declared write needs it; see
207+
# table/rowid_constraint_metadata.test). Count mode has no RETURNING
208+
# requirement to mask the check.
206209
# ============================================================================
207210

208211
statement error
209212
INSERT INTO example.data.departments (id, name, budget) VALUES (99, 'unauthorized', 100);
210213
----
211-
constraint enforcement profile was not negotiated
214+
<REGEX>:.*Table '.*departments.*' does not support INSERT.*
215+
216+
statement error
217+
UPDATE example.data.departments SET budget = 1 WHERE id = 1;
218+
----
219+
<REGEX>:.*Table '.*departments.*' does not support UPDATE.*
220+
221+
statement error
222+
DELETE FROM example.data.departments WHERE id = 1;
223+
----
224+
<REGEX>:.*Table '.*departments.*' does not support DELETE.*
212225

213226
query I
214227
SELECT count(*) FROM example.data.departments;

‎test/sql/integration/table/rowid_constraint_metadata.test‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,16 @@
66
# a different set of constraint records per catalog name:
77
# enforced_keys items PRIMARY KEY (id)
88
# items_no_returning PRIMARY KEY (id), UNIQUE (name)
9+
# items_insert_only PRIMARY KEY (id)
910
# rowid_keys items PRIMARY KEY (rowid)
1011
# items_no_returning PRIMARY KEY (id, rowid), UNIQUE (rowid, name)
1112
# metadata_only_keys items PRIMARY KEY (id), METADATA_ONLY / UNVALIDATED
1213
# Every record except METADATA_ONLY is ENFORCED + ALL_ROWS. Informational
1314
# (METADATA_ONLY) constraints are not supported. The fixture serves its records
14-
# unvalidated so these assertions exercise the extension's own checks.
15+
# unvalidated so these assertions exercise the extension's own checks. It
16+
# negotiates the constraint read profile only, never vgi.constraint.enforce.v1,
17+
# yet keeps serving the tables' declared writes (vgi-python alone would serve
18+
# them read-only).
1519

1620
require-env VGI_ROWID_CONSTRAINT_WORKER
1721

@@ -37,6 +41,7 @@ WHERE database_name = 'e' AND constraint_type IN ('PRIMARY KEY', 'UNIQUE')
3741
ORDER BY 1, 2;
3842
----
3943
items PRIMARY KEY [id]
44+
items_insert_only PRIMARY KEY [id]
4045
items_no_returning PRIMARY KEY [id]
4146
items_no_returning UNIQUE [name]
4247

@@ -59,6 +64,59 @@ SELECT count(rowid), count(id) FROM e.main.items_no_returning;
5964
----
6065
0 0
6166

67+
# --- Writes without the constraint-enforcement profile ----------------------
68+
# A write the table declares needs vgi.constraint.enforce.v1, which this worker
69+
# never negotiates, so it fails at planning on the missing profile. A write the
70+
# table does not declare is reported as unsupported first: the missing profile
71+
# is irrelevant to it.
72+
73+
statement error
74+
INSERT INTO e.main.items VALUES (1, 'a', 10);
75+
----
76+
constraint enforcement profile was not negotiated
77+
78+
statement error
79+
UPDATE e.main.items SET qty = 1;
80+
----
81+
constraint enforcement profile was not negotiated
82+
83+
statement error
84+
DELETE FROM e.main.items;
85+
----
86+
constraint enforcement profile was not negotiated
87+
88+
statement error
89+
INSERT INTO e.main.items_insert_only VALUES (1, 'a');
90+
----
91+
constraint enforcement profile was not negotiated
92+
93+
# The profile is checked before the result mode: items_no_returning is
94+
# count-only, but the missing profile is what this attach lacks.
95+
statement error
96+
INSERT INTO e.main.items_no_returning VALUES (1, 'a', 10) RETURNING *;
97+
----
98+
constraint enforcement profile was not negotiated
99+
100+
statement error
101+
UPDATE e.main.items_insert_only SET name = 'b';
102+
----
103+
<REGEX>:.*Table '.*items_insert_only.*' does not support UPDATE.*
104+
105+
statement error
106+
DELETE FROM e.main.items_insert_only;
107+
----
108+
<REGEX>:.*Table '.*items_insert_only.*' does not support DELETE.*
109+
110+
statement error
111+
UPDATE e.main.items_insert_only SET name = 'b';
112+
----
113+
<!REGEX>:.*constraint enforcement profile.*
114+
115+
query I
116+
SELECT count(*) FROM e.main.items;
117+
----
118+
0
119+
62120
# --- Keys naming the rowid column are rejected ------------------------------
63121

64122
statement ok

‎test/support/rowid_constraint_worker.py‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
* ``enforced_keys`` — supported records, all ENFORCED + ALL_ROWS:
1111
``items`` PRIMARY KEY (id)
1212
``items_no_returning`` PRIMARY KEY (id), UNIQUE (name)
13+
``items_insert_only`` PRIMARY KEY (id) (declares INSERT only)
1314
* ``rowid_keys`` — ENFORCED + ALL_ROWS records that name the rowid column:
1415
``items`` PRIMARY KEY (rowid)
1516
``items_no_returning`` PRIMARY KEY (id, rowid), UNIQUE (rowid, name)
@@ -57,6 +58,7 @@
5758
(ConstraintKind.PRIMARY_KEY, ["id"], _TRUSTED),
5859
(ConstraintKind.UNIQUE, ["name"], _TRUSTED),
5960
],
61+
"items_insert_only": [(ConstraintKind.PRIMARY_KEY, ["id"], _TRUSTED)],
6062
},
6163
),
6264
"rowid_keys": (
@@ -83,12 +85,25 @@
8385
raise RuntimeError("vgi.worker.validate_constraint_records moved; update this fixture")
8486
vgi.worker.validate_constraint_records = lambda *args, **kwargs: None
8587

88+
# vgi-python also serves a table with constraint records read-only (drops its
89+
# write functions and modes) unless the attachment selected
90+
# vgi.constraint.enforce.v1. Keep the declared writes, so the extension's own
91+
# planning check is what refuses them. Same loud failure if the hook moves.
92+
if not hasattr(vgi.worker.Worker, "_table_for_capabilities"):
93+
raise RuntimeError("vgi.worker.Worker._table_for_capabilities moved; update this fixture")
94+
8695

8796
class RowidConstraintCatalog(SimpleWritableCatalog):
8897
"""SimpleWritableCatalog whose tables carry the records of the attached mode."""
8998

9099
def catalog_capabilities(self, *, attach_opaque_data): # type: ignore[no-untyped-def,override]
91-
"""Serve constraint records; writes are not offered, so the tables are read-only."""
100+
"""Serve constraint records but never negotiate vgi.constraint.enforce.v1.
101+
102+
The tables keep their simple_writable writes (see
103+
``RowidConstraintWorker._table_for_capabilities``), so a write a table
104+
declares fails on the missing enforcement profile and one it does not
105+
declare fails as unsupported.
106+
"""
92107
del attach_opaque_data
93108
return CatalogCapabilities(read=[CONSTRAINT_RECORDS_READ])
94109

@@ -136,6 +151,17 @@ class RowidConstraintWorker(SimpleWritableWorker):
136151

137152
catalog_interface = RowidConstraintCatalog
138153

154+
@staticmethod
155+
def _table_for_capabilities(info, selected): # type: ignore[no-untyped-def,override]
156+
served = SimpleWritableWorker._table_for_capabilities(info, selected)
157+
return dataclasses.replace(
158+
served,
159+
write_result_modes=info.write_result_modes,
160+
insert_function=info.insert_function,
161+
update_function=info.update_function,
162+
delete_function=info.delete_function,
163+
)
164+
139165

140166
if __name__ == "__main__":
141167
RowidConstraintWorker.main()

0 commit comments

Comments
 (0)