Skip to content

Commit b09848b

Browse files
maltesanderclaude
andcommitted
fix: honour the declared SQL type and reject phantom parameters
Three defects in the parameter path, fixed in this order because making an unbound marker an error before hardening the scanner would have turned working statements into errors. `count_params` tracked single-quoted string literals and nothing else, so a `?` inside a delimited identifier or a comment counted as a parameter marker: `SELECT "a?b" FROM t` reported one parameter and `SELECT 1 -- huh?` reported one too. `escape.rs` already scanned the same text correctly, so the fix is to stop having a second scanner: its `copy_*` helpers split into `skip_*` plus a copying wrapper, and `count_params` calls the same `skip_*` functions with the identifier delimiters taken from the backend's `EscapeDialect`. A marker with no binding was padded with `ColumnValue::Null`, so `WHERE x = ?` with nothing bound ran as `WHERE x = NULL` — no rows, and `SQL_SUCCESS`. Both execution paths now report 07002, the first clause of that row on the `SQLExecute` and `SQLExecDirect` tables, neither of them `(DM)`-marked. The data-at-execution scan rejects the same gap so it is not a second route to the old behaviour. A `SQL_PARAM_OUTPUT` binding still yields `Null`: it has no input value, and reading its uninitialised buffer would be unsound. `read_param_value` matched on the C type alone, so `ParameterBinding::sql_type` — `SQLBindParameter`'s `ParameterType` — was recorded and never read. For every C type but the two character ones that lost nothing; for `SQL_C_CHAR` and `SQL_C_WCHAR` it discarded the only statement of what the text was, and `SQL_C_CHAR` + `SQL_NUMERIC` reached the backend as a string. The new `param_convert` module is the spec's "C to SQL: Character" table transcribed, with the SQLSTATE its third column gives for each outcome. Decimal literals are carried as digits and a scale rather than through `f64`, so scale survives. Driver-visible: a backend now receives `Decimal`, `I32`, `Timestamp` and so on where a character binding previously produced `String`. Verified with `pre-commit run --all-files` (15 hooks), 937 unit tests, and Miri (927 passed, no leaks). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4fb165b commit b09848b

10 files changed

Lines changed: 1579 additions & 132 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,7 @@ Generic framework. Zero database-specific code.
790790
| `types/version.rs` | Parsed data-source version numbers, for a backend gating capabilities on server version |
791791
| `types/redacted.rs` | `Redacted<T>``Debug` wrapper that prints `*****` for sensitive fields (e.g. passwords) |
792792
| `column_value.rs` | `write_column_value()` — core data marshalling for `SQLGetData` (NULL, truncation, type coercion) |
793+
| `param_convert.rs` | `text_to_sql_type()` — the reverse direction: converts `SQL_C_CHAR`/`SQL_C_WCHAR` parameter text to the SQL type `SQLBindParameter` declared. The spec's "C to SQL: Character" table, transcribed |
793794
| `synthetic.rs` | `SyntheticStatement` — in-memory result set for `SQLGetTypeInfo` and catalog functions |
794795
| `catalog_sort.rs` | Sorts a catalog result set into its spec-mandated order; NULL placement from `Backend::null_collation` |
795796
| `catalog_ident.rs` | `SQL_ATTR_METADATA_ID` identifier normalisation and the `SQLTables` `TableType` value-list parser |

CHANGELOG.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,57 @@ Everything a driver has to change for the catalog rework, in one place.
797797

798798
### Fixed
799799

800+
- **Character parameter data ignored the SQL type it was bound as.**
801+
`SQLBindParameter` takes two types — `ValueType`, the C type the value
802+
arrives in, and `ParameterType`, the SQL type the data source is to receive —
803+
and ODBC makes the driver convert between them. `read_param_value` matched on
804+
the C type alone, so `ParameterBinding::sql_type` was recorded and never
805+
read. For every C type but the two character ones that lost nothing, because
806+
the C type already fixes the value's shape; for `SQL_C_CHAR` and
807+
`SQL_C_WCHAR` it discarded the only statement of what the text *was*.
808+
`SQL_C_CHAR` + `SQL_NUMERIC` — what pyodbc emits for a `Decimal`, and what
809+
any client emits for a value it delivers as text — reached the backend as
810+
`ColumnValue::String`, so a driver that renders its parameters emitted
811+
`WHERE amount = '12.34'` against a decimal column and the data source
812+
rejected the comparison. The new `param_convert` module is the spec's
813+
"C to SQL: Character" table transcribed: decimal, exact-integer,
814+
approximate-numeric, `SQL_BIT`, binary (hexadecimal pairs) and the three
815+
datetime targets, each with the SQLSTATE that table's third column gives —
816+
`22018` for text that is not a literal of the declared type, `22001` for a
817+
conversion that would truncate, `22003` for out of range, `22008` for a
818+
datetime component the target cannot hold. **Driver-visible:** a backend now
819+
receives `ColumnValue::Decimal`, `I32`, `Timestamp` and so on where it
820+
previously received `String` for these bindings; one that parsed the string
821+
itself can drop that code, and one that matched only `String` must handle the
822+
typed variants. Character SQL types, the interval types, `SQL_GUID` and
823+
driver-specific type identifiers are unchanged and still arrive as `String`.
824+
`SQLPutData` data-at-execution text goes through the same conversion, so the
825+
two routes to a parameter agree.
826+
827+
- **`?` was counted as a parameter marker inside quoted identifiers and
828+
comments.** `count_params` tracked single-quoted string literals and nothing
829+
else, so `SELECT "a?b" FROM t` reported one parameter and `SELECT 1 -- huh?`
830+
reported one too. `SQLNumParams` over-reported, `collect_params` padded the
831+
phantom marker with a value, and a driver whose own substitution scan
832+
mirrored core's rewrote the identifier along with it. The scan now skips
833+
string literals, delimited identifiers, `--` line comments and `/* … */`
834+
block comments, taking the identifier delimiters from the backend's
835+
`EscapeDialect` rather than assuming `"`. The region helpers are `escape`'s
836+
own, shared with `translate_escapes` so the two scans cannot drift apart
837+
again.
838+
839+
- **A parameter marker with no bound value was padded with NULL.**
840+
`collect_params` emitted `ColumnValue::Null` for a marker the application
841+
never called `SQLBindParameter` for, so `WHERE x = ?` with nothing bound ran
842+
as `WHERE x = NULL`, matched no row and reported success — the application
843+
saw an empty result set rather than its own mistake. Both `SQLExecute` and
844+
`SQLExecDirectW` now report `07002` (COUNT field incorrect), which is the
845+
first clause of that row on both diagnostics tables and carries no `(DM)`
846+
marker. The data-at-execution scan rejects the same gap, so it is not a
847+
second route to the old behaviour. A `SQL_PARAM_OUTPUT` binding still yields
848+
`Null` and is unaffected: it has no input value by definition, and reading
849+
its uninitialised buffer would be unsound.
850+
800851
- The 32 `HY008` doc comments across `src/ffi/` claimed the state could not
801852
arise, on one of two false grounds: that "the `Backend` trait is synchronous"
802853
— which says nothing about another thread cancelling — or that it was

src/column_value.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -616,7 +616,7 @@ fn civil_from_days(days: i64) -> (i64, u16, u16) {
616616
clippy::disallowed_methods,
617617
reason = "SQL_TYPE_TIME -> SQL_C_TYPE_TIMESTAMP is specified as using the current date"
618618
)]
619-
fn current_utc_date() -> (i16, u16, u16) {
619+
pub(crate) fn current_utc_date() -> (i16, u16, u16) {
620620
// `try_from` rather than `as`: a clock far enough out to exceed i64 seconds
621621
// is nonsense either way, but wrapping it into a negative would turn a date
622622
// in the far future into one in the distant past.
@@ -783,7 +783,7 @@ fn parse_time_fields(s: &str) -> Result<(u16, u16, u16, u32), OdbcError> {
783783
Ok((hour, minute, second, fraction))
784784
}
785785

786-
fn parse_sql_date(s: &str) -> Result<Date, OdbcError> {
786+
pub(crate) fn parse_sql_date(s: &str) -> Result<Date, OdbcError> {
787787
let (year, month, day) = parse_date_fields(s.trim())?;
788788
Ok(Date { year, month, day })
789789
}
@@ -793,7 +793,7 @@ fn parse_sql_date(s: &str) -> Result<Date, OdbcError> {
793793
/// to `SQL_C_TYPE_TIME` must check the returned fraction themselves and report
794794
/// 01S07 if it is non-zero — this function only parses, it does not decide
795795
/// whether the drop is acceptable for the caller's target type.
796-
fn parse_sql_time(s: &str) -> Result<(Time, u32), OdbcError> {
796+
pub(crate) fn parse_sql_time(s: &str) -> Result<(Time, u32), OdbcError> {
797797
let (hour, minute, second, fraction) = parse_time_fields(s.trim())?;
798798
Ok((
799799
Time {
@@ -805,7 +805,7 @@ fn parse_sql_time(s: &str) -> Result<(Time, u32), OdbcError> {
805805
))
806806
}
807807

808-
fn parse_sql_timestamp(s: &str) -> Result<Timestamp, OdbcError> {
808+
pub(crate) fn parse_sql_timestamp(s: &str) -> Result<Timestamp, OdbcError> {
809809
let t = s.trim();
810810
// Accept either the ODBC space separator or the ISO 8601 'T'.
811811
let (date_part, time_part) = match t.split_once([' ', 'T']) {

src/escape.rs

Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ impl EscapeDialect {
148148
}
149149
}
150150

151-
fn ident_close(&self, open: char) -> Option<char> {
151+
pub(crate) fn ident_close(&self, open: char) -> Option<char> {
152152
self.identifier_quotes
153153
.iter()
154154
.find(|(o, _)| *o == open)
@@ -230,38 +230,40 @@ fn translate_slice(
230230
Ok(out)
231231
}
232232

233-
/// Copy a single-quoted string literal verbatim, including a `''` doubled quote.
234-
fn copy_string(chars: &[char], i: &mut usize, out: &mut String) {
235-
out.push(chars[*i]); // opening '
236-
*i += 1;
233+
// The `skip_*` helpers below advance `*i` past one lexical region without
234+
// interpreting it; the `copy_*` wrappers do the same and copy what was skipped.
235+
// Splitting them this way is what lets `ffi::params::count_params` scan for `?`
236+
// parameter markers with exactly the region boundaries this module translates
237+
// escapes with. Two independent scanners is how a `?` inside a quoted
238+
// identifier came to be counted as a marker.
239+
240+
/// Advance past a single-quoted string literal, including any `''` doubled quote.
241+
pub(crate) fn skip_string(chars: &[char], i: &mut usize) {
242+
*i += 1; // opening '
237243
while *i < chars.len() {
238244
let c = chars[*i];
239-
out.push(c);
240245
*i += 1;
241246
if c == '\'' {
242247
if chars.get(*i) == Some(&'\'') {
243-
out.push('\''); // doubled quote — stays inside the string
244-
*i += 1;
248+
*i += 1; // doubled quote — stays inside the string
245249
} else {
246250
break; // closing quote
247251
}
248252
}
249253
}
250254
}
251255

252-
fn copy_quoted_ident(chars: &[char], i: &mut usize, out: &mut String, open: char, close: char) {
253-
out.push(chars[*i]); // opening quote
254-
*i += 1;
256+
/// Advance past a delimited identifier opened by `open` and closed by `close`.
257+
pub(crate) fn skip_quoted_ident(chars: &[char], i: &mut usize, open: char, close: char) {
258+
*i += 1; // opening quote
255259
while *i < chars.len() {
256260
let c = chars[*i];
257-
out.push(c);
258261
*i += 1;
259262
if c == close {
260263
// A doubled close-quote escapes it, but only for symmetric quote
261264
// styles (`"..."`, `` `...` ``). Bracket identifiers (`[...]`) have
262265
// no doubling — a `]` always closes them.
263266
if open == close && chars.get(*i) == Some(&close) {
264-
out.push(close);
265267
*i += 1;
266268
} else {
267269
break;
@@ -270,33 +272,62 @@ fn copy_quoted_ident(chars: &[char], i: &mut usize, out: &mut String, open: char
270272
}
271273
}
272274

273-
fn copy_line_comment(chars: &[char], i: &mut usize, out: &mut String) {
275+
/// Advance past a `--` line comment, up to and including its newline. An
276+
/// unterminated comment runs to the end of the statement.
277+
pub(crate) fn skip_line_comment(chars: &[char], i: &mut usize) {
274278
while *i < chars.len() {
275279
let c = chars[*i];
276-
out.push(c);
277280
*i += 1;
278281
if c == '\n' {
279282
break;
280283
}
281284
}
282285
}
283286

284-
fn copy_block_comment(chars: &[char], i: &mut usize, out: &mut String) {
285-
out.push(chars[*i]); // '/'
286-
out.push(chars[*i + 1]); // '*'
287-
*i += 2;
287+
/// Advance past a `/* … */` block comment. An unterminated comment runs to the
288+
/// end of the statement.
289+
pub(crate) fn skip_block_comment(chars: &[char], i: &mut usize) {
290+
*i += 2; // '/' '*'
288291
while *i < chars.len() {
289292
if chars[*i] == '*' && chars.get(*i + 1) == Some(&'/') {
290-
out.push('*');
291-
out.push('/');
292293
*i += 2;
293294
break;
294295
}
295-
out.push(chars[*i]);
296296
*i += 1;
297297
}
298298
}
299299

300+
/// Copy a single-quoted string literal verbatim, including a `''` doubled quote.
301+
fn copy_string(chars: &[char], i: &mut usize, out: &mut String) {
302+
copy_skipped(chars, i, out, skip_string);
303+
}
304+
305+
fn copy_quoted_ident(chars: &[char], i: &mut usize, out: &mut String, open: char, close: char) {
306+
copy_skipped(chars, i, out, |chars, i| {
307+
skip_quoted_ident(chars, i, open, close);
308+
});
309+
}
310+
311+
fn copy_line_comment(chars: &[char], i: &mut usize, out: &mut String) {
312+
copy_skipped(chars, i, out, skip_line_comment);
313+
}
314+
315+
/// Run `skip` and append every character it advanced over to `out`.
316+
fn copy_skipped(
317+
chars: &[char],
318+
i: &mut usize,
319+
out: &mut String,
320+
skip: impl FnOnce(&[char], &mut usize),
321+
) {
322+
let start = *i;
323+
skip(chars, i);
324+
out.extend(&chars[start..*i]);
325+
}
326+
327+
fn copy_block_comment(chars: &[char], i: &mut usize, out: &mut String) {
328+
copy_skipped(chars, i, out, skip_block_comment);
329+
}
330+
300331
/// Find the index of the `}` matching the `{` at `open`, skipping strings,
301332
/// quoted identifiers, comments and nested braces. Errors if unterminated.
302333
fn find_matching_brace(

0 commit comments

Comments
 (0)