@@ -1303,6 +1303,204 @@ mod test {
13031303 ) ;
13041304 }
13051305
1306+ /// In `UPDATE t1 SET x = ... FROM t2` the assignment target must resolve
1307+ /// against the table being updated, not through the lexical scope. The
1308+ /// scope also contains the `FROM` relations, so a same-named column there
1309+ /// used to make the target spuriously ambiguous (and could shadow it).
1310+ /// Here both tables have an `email` column; the assignment must get
1311+ /// `users.email` — the encrypted one.
1312+ #[ test]
1313+ fn update_assignment_resolves_against_target_table_not_from_relation ( ) {
1314+ let schema = resolver ( schema ! {
1315+ tables: {
1316+ users: {
1317+ id,
1318+ email ( EQL : Eq ) ,
1319+ }
1320+ aux: {
1321+ id,
1322+ email,
1323+ }
1324+ }
1325+ } ) ;
1326+
1327+ let statement = parse ( "UPDATE users SET email = $1 FROM aux WHERE users.id = aux.id" ) ;
1328+
1329+ let typed = match type_check ( schema, & statement) {
1330+ Ok ( typed) => typed,
1331+ Err ( err) => panic ! ( "type check failed: {err}" ) ,
1332+ } ;
1333+
1334+ let target = Value :: Eql ( EqlTerm :: Full ( EqlValue :: with_canonical_identity (
1335+ TableColumn {
1336+ table : id ( "users" ) ,
1337+ column : id ( "email" ) ,
1338+ } ,
1339+ EqlTraits :: from ( EqlTrait :: Eq ) ,
1340+ ) ) ) ;
1341+
1342+ assert_eq ! ( typed. params, vec![ ( Param ( 1 ) , target) ] ) ;
1343+ assert_eq ! ( typed. projection, Projection ( vec![ ] ) ) ;
1344+ }
1345+
1346+ /// Proxy loads its schema from the database with *quoted* column idents
1347+ /// (`Ident::with_quote('"', ..)`) behind an editable resolver, while SQL
1348+ /// usually spells the same columns unquoted. A type identity derived from
1349+ /// an assignment target must still unify with one derived from the scope,
1350+ /// so the resolver has to return the schema's canonical idents rather than
1351+ /// echo the caller's spelling. With the caller's spelling,
1352+ /// `UPDATE t SET c = $1 WHERE c = $1` pinned the same param to
1353+ /// `EQL(t."c")` and `EQL(t.c)` and failed with "cannot unify EQL terms".
1354+ #[ test]
1355+ fn update_reused_param_unifies_against_quoted_schema_idents ( ) {
1356+ let eq = EqlTraits :: from ( EqlTrait :: Eq ) ;
1357+
1358+ let mut schema = Schema :: new ( "public" ) ;
1359+ let mut table = crate :: model:: Table :: new ( Ident :: new ( "encrypted" ) ) ;
1360+ table. add_column ( Arc :: new ( crate :: model:: Column :: native ( Ident :: with_quote (
1361+ '"' , "id" ,
1362+ ) ) ) ) ;
1363+ table. add_column ( Arc :: new ( crate :: model:: Column :: eql (
1364+ Ident :: with_quote ( '"' , "encrypted_text" ) ,
1365+ eq,
1366+ crate :: unifier:: DomainIdentity :: canonical ( crate :: unifier:: TokenType :: Text , eq) ,
1367+ ) ) ) ;
1368+ schema. add_table ( table) ;
1369+
1370+ // The editable resolver is the one Proxy uses at runtime; it resolves
1371+ // through `SchemaDelta`, not `Schema`.
1372+ let resolver = Arc :: new ( TableResolver :: new_editable ( Arc :: new ( schema) ) ) ;
1373+
1374+ let statement = parse ( "UPDATE encrypted SET encrypted_text = $1 WHERE encrypted_text = $1" ) ;
1375+
1376+ let typed = match type_check ( resolver, & statement) {
1377+ Ok ( typed) => typed,
1378+ Err ( err) => panic ! ( "type check failed: {err}" ) ,
1379+ } ;
1380+
1381+ // The param's identity is the canonical (quoted) schema spelling.
1382+ let target = Value :: Eql ( EqlTerm :: Full ( EqlValue :: with_canonical_identity (
1383+ TableColumn {
1384+ table : id ( "encrypted" ) ,
1385+ column : Ident :: with_quote ( '"' , "encrypted_text" ) ,
1386+ } ,
1387+ eq,
1388+ ) ) ) ;
1389+
1390+ assert_eq ! ( typed. params, vec![ ( Param ( 1 ) , target) ] ) ;
1391+ }
1392+
1393+ /// The row-count expressions in `LIMIT`/`OFFSET` can never be encrypted,
1394+ /// so placeholders there must be pinned to `Native` at inference time.
1395+ /// Previously they were left as unconstrained type variables and only
1396+ /// resolved to `Native` by the late unresolved-value fallback in
1397+ /// `Unifier::resolve_unresolved_value_nodes` — this pins the guarantee
1398+ /// where the clause is inferred instead of relying on that fallback.
1399+ #[ test]
1400+ fn limit_and_offset_placeholders_infer_native ( ) {
1401+ let schema = resolver ( schema ! {
1402+ tables: {
1403+ users: {
1404+ id,
1405+ email ( EQL : Eq ) ,
1406+ }
1407+ }
1408+ } ) ;
1409+
1410+ let statement = parse ( "SELECT id FROM users LIMIT $1 OFFSET $2" ) ;
1411+
1412+ let typed = match type_check ( schema, & statement) {
1413+ Ok ( typed) => typed,
1414+ Err ( err) => panic ! ( "type check failed: {err}" ) ,
1415+ } ;
1416+
1417+ assert_eq ! (
1418+ typed. params,
1419+ vec![
1420+ ( Param ( 1 ) , Value :: Native ( NativeValue ( None ) ) ) ,
1421+ ( Param ( 2 ) , Value :: Native ( NativeValue ( None ) ) ) ,
1422+ ]
1423+ ) ;
1424+ }
1425+
1426+ /// Same as `limit_and_offset_placeholders_infer_native`, but for the
1427+ /// quantity in a `FETCH FIRST n ROWS ONLY` clause.
1428+ #[ test]
1429+ fn fetch_first_placeholder_infers_native ( ) {
1430+ let schema = resolver ( schema ! {
1431+ tables: {
1432+ users: {
1433+ id,
1434+ email ( EQL : Eq ) ,
1435+ }
1436+ }
1437+ } ) ;
1438+
1439+ let statement = parse ( "SELECT id FROM users FETCH FIRST $1 ROWS ONLY" ) ;
1440+
1441+ let typed = match type_check ( schema, & statement) {
1442+ Ok ( typed) => typed,
1443+ Err ( err) => panic ! ( "type check failed: {err}" ) ,
1444+ } ;
1445+
1446+ assert_eq ! (
1447+ typed. params,
1448+ vec![ ( Param ( 1 ) , Value :: Native ( NativeValue ( None ) ) ) ]
1449+ ) ;
1450+ }
1451+
1452+ /// Because `LIMIT` is pinned to `Native` at inference time, an encrypted
1453+ /// value can no longer flow into it silently — the mapper refuses the
1454+ /// statement instead of forwarding SQL that the database would reject
1455+ /// (or worse, that would leak a ciphertext into a row count).
1456+ #[ test]
1457+ fn encrypted_column_in_limit_is_rejected ( ) {
1458+ let schema = resolver ( schema ! {
1459+ tables: {
1460+ users: {
1461+ id,
1462+ email ( EQL : Eq ) ,
1463+ }
1464+ }
1465+ } ) ;
1466+
1467+ let statement = parse ( "SELECT id FROM users LIMIT email" ) ;
1468+
1469+ type_check ( schema, & statement)
1470+ . expect_err ( "an encrypted column must not type check as a LIMIT row count" ) ;
1471+ }
1472+
1473+ /// A statement variant with no inference rule must fail closed with an
1474+ /// error stating the invariant, not traverse without constraining the
1475+ /// statement's top-level type. (`requires_type_check` never admits
1476+ /// `TRUNCATE`, so this can only be reached by calling `type_check`
1477+ /// directly — but if `requires_type_check` is ever widened without a
1478+ /// matching inference rule, this is the error that makes it loud.)
1479+ #[ test]
1480+ fn statement_without_inference_rule_fails_closed ( ) {
1481+ let schema = resolver ( schema ! {
1482+ tables: {
1483+ users: {
1484+ id,
1485+ }
1486+ }
1487+ } ) ;
1488+
1489+ let statement = parse ( "TRUNCATE TABLE users" ) ;
1490+
1491+ match type_check ( schema, & statement) {
1492+ Ok ( _) => panic ! ( "expected type check to fail" ) ,
1493+ Err ( err) => assert_eq ! (
1494+ err. to_string( ) ,
1495+ format!(
1496+ "type inference has no rule for statement `{statement}`; \
1497+ `requires_type_check` admits a statement variant that \
1498+ `InferType<'_, Statement>` does not handle"
1499+ )
1500+ ) ,
1501+ }
1502+ }
1503+
13061504 #[ test]
13071505 fn delete ( ) {
13081506 // init_tracing();
0 commit comments