andygrove opened a new issue, #5149:
URL: https://github.com/apache/datafusion-comet/issues/5149

   ### Describe the bug
   
   Comet's cast-from-string kernels do not reproduce Spark's 
whitespace-trimming semantics. This is currently tracked as a boolean-only 
problem in #4959, but an empirical audit of every string cast target shows that 
**7 of the 8 supported targets diverge, in both directions**, and the direction 
not covered by #4959 (Comet returning a non-NULL value where Spark returns NULL 
/ throws) is the more severe one.
   
   This epic supersedes #4959.
   
   #### Verification method
   
   - **Spark ground truth**: a Java probe run directly against the Spark 3.5.5 
jars, exercising the exact primitives each cast target uses 
(`UTF8String.trimAll`, `UTF8String.toInt`/`toLong`, `Double.parseDouble`, `new 
BigDecimal(String.trim)`, 
`DateTimeUtils.stringToDate`/`stringToTimestamp`/`stringToTimestampWithoutTimeZone`),
 over a 25-codepoint matrix in leading and trailing position.
   - **Comet**: the same matrix through `spark_cast` in 
`datafusion-comet-spark-expr`, in both legacy and ANSI eval mode.
   - The relevant Spark code paths are identical in 3.5.8 and 4.1.1.
   
   ### Spark has two trim regimes, not one
   
   | Regime | Trim set | Cast targets |
   | --- | --- | --- |
   | `UTF8String.trimAll` / 
`SparkDateTimeUtils.getTrimmedStart`+`getTrimmedEnd` | bytes `0x00`-`0x20` 
**and `0x7F`** | boolean, byte, short, int, long, date, timestamp, 
timestamp_ntz |
   | Java `String.trim` | chars `U+0000`-`U+0020` (**not `0x7F`**) | float, 
double, decimal |
   
   Neither regime trims **any** non-ASCII whitespace. `U+0085`, `U+00A0`, 
`U+1680`, `U+2000`-`U+200A`, `U+2028`, `U+2029`, `U+202F`, `U+205F` and 
`U+3000` all cause Spark to return NULL (or throw under ANSI) for every target.
   
   Sources: `UTF8String.java` (`isWhitespaceOrISOControl`, `trimAll`, `toInt`, 
`toLong`), `StringUtils.isTrueString`/`isFalseString`, 
`Decimal.stringToJavaBigDecimal`, `Cast.castToDouble`/`castToFloat`, 
`SparkDateTimeUtils.stringToDate`/`parseTimestampString`.
   
   ### Comet uses four different trim sets, three of them wrong
   
   | Cast target | Comet trim | Under-trims: Comet NULL / throws, Spark parses 
| Over-trims: Comet returns a value, Spark NULLs / throws |
   | --- | --- | --- | --- |
   | boolean | `str::trim` | `0x00`-`0x08`, `0x0E`-`0x1F`, `0x7F` | **all 
non-ASCII whitespace** |
   | byte / short / int / long | `<[u8]>::trim_ascii` | `0x00`-`0x08`, 
**`0x0B`**, `0x0E`-`0x1F`, `0x7F` | - |
   | float / double | `str::trim` | `0x00`-`0x08`, `0x0E`-`0x1F` | **all 
non-ASCII whitespace** |
   | decimal | ascii-ws + NUL | `0x01`-`0x08`, **`0x0B`**, `0x0E`-`0x1F` | - |
   | date | `is_ascii_whitespace() \|\| is_ascii_control()` | - | - |
   | timestamp | `str::trim` | `0x00`-`0x08`, `0x0E`-`0x1F`, `0x7F` | **all 
non-ASCII whitespace** |
   | timestamp_ntz | `str::trim` | `0x00`-`0x08`, `0x0E`-`0x1F`, `0x7F` | **all 
non-ASCII whitespace** |
   
   `date_parser` is the only correct path: its local 
`is_whitespace_or_iso_control` 
(`native/spark-expr/src/conversion_funcs/string.rs`) computes 
`is_ascii_whitespace() || is_ascii_control()`, which is exactly Spark's 
`trimAll` set. It is the model for the fix.
   
   Two details that are easy to get wrong:
   
   1. Rust's `u8::is_ascii_whitespace` **excludes `0x0B`** (vertical tab) while 
`str::trim` includes it. That is why the int and decimal paths have an extra 
failing byte that the boolean and float paths do not.
   2. `0x7F` **must** be trimmed for boolean / int / date / timestamp but 
**must not** be trimmed for float / double / decimal. A single shared helper 
applied uniformly would introduce a new divergence in the float and decimal 
paths.
   
   ### Severity
   
   Every one of these casts is `Compatible()` in `CometCast.canCastFromString`, 
so they all run natively by default with no `allowIncompat` gate.
   
   Measured ANSI-mode behavior:
   
   | Input | Spark | Comet |
   | --- | --- | --- |
   | `"\x01true"` AS boolean | `true` | **throws** `CAST_INVALID_INPUT` |
   | `"\x7F123"` AS int | `123` | **throws** |
   | `" true"` AS boolean | **throws** | `true` |
   | `" 1.5"` AS double | **throws** | `1.5` |
   | `" 2020-01-01 10:00:00"` AS timestamp | **throws** | a timestamp |
   
   So the impact is spurious query failures in one direction, and silent wrong 
results with a swallowed ANSI error in the other. The over-trim direction is 
the `priority:critical` part and is currently documented nowhere: the entry in 
`docs/source/user-guide/latest/compatibility/index.md` covers only the boolean 
under-trim.
   
   ### Also in scope
   
   - `native/spark-expr/src/datetime_funcs/to_time.rs` uses the same 
`str::trim`. **Latent only**: `spark_to_time` is exported from the crate but is 
not registered in `jni_api` and has no serde entry, so it is unreachable from 
Spark today. Spark's `stringToTime` is also subtler than the others 
(`trimRight` first, for AM/PM suffix detection, then `parseTimestampString`'s 
trimAll set), so porting it needs its own analysis. Should be fixed before 
`to_time` is wired up, and coordinated with #4288.
   - `timestamp_parser`'s Spark-4 leading-whitespace gate decides "has leading 
whitespace" via `value.trim_start()`, so it inherits the same wrong notion of 
whitespace and needs updating alongside the trim itself.
   
   ### Adjacent, not part of this epic
   
   `native/spark-expr/src/csv_funcs/to_csv.rs` implements 
`ignoreLeadingWhiteSpace` / `ignoreTrailingWhiteSpace` with `trim_start` / 
`trim_end`. Univocity's semantics are `<= ' '`, so this is very likely the same 
class of bug, but it has a different root cause and was **not verified**. Worth 
its own issue rather than folding in here.
   
   ### Proposed subtasks
   
   - [ ] Introduce two explicit, documented trim helpers in `conversion_funcs` 
-- one for the `trimAll` set (bytes `0x00`-`0x20` plus `0x7F`) and one for the 
Java `String.trim` set (`0x00`-`0x20` only) -- and unit-test them directly 
against the byte sets rather than against cast results.
   - [ ] Fix `CAST(string AS boolean)` to use the trimAll helper (the original 
#4959 scope, plus the over-trim direction).
   - [ ] Fix `CAST(string AS byte/short/int/long)` to use the trimAll helper, 
in all three eval-mode variants (`legacy`, `ansi`, `try`).
   - [ ] Fix `CAST(string AS float/double)` to use the `String.trim` helper, 
keeping `0x7F` untrimmed.
   - [ ] Fix `CAST(string AS decimal)` to use the `String.trim` helper, keeping 
`0x7F` untrimmed and preserving the existing NUL handling.
   - [ ] Fix `CAST(string AS timestamp)` and `CAST(string AS timestamp_ntz)` to 
use the trimAll helper, including the Spark-4 leading-whitespace gate in 
`timestamp_parser`.
   - [ ] Add a shared parity test covering the full codepoint matrix (leading, 
trailing, both, and interior positions) for every string cast target, in all 
eval modes.
   - [ ] Fix the latent `to_time.rs` trim, or record the divergence on #4288 so 
it is handled when `to_time` is wired up.
   - [ ] Update `docs/source/user-guide/latest/compatibility/index.md` to 
describe both divergence directions and all affected targets, and remove the 
entries once each target is fixed.
   - [ ] File a separate issue for the `to_csv` 
`ignoreLeading/TrailingWhiteSpace` trimming, after confirming univocity's 
semantics.
   
   ### Steps to reproduce
   
   ```sql
   -- 0x01 is an ASCII control byte; Spark trims it, Comet does not
   SELECT CAST(concat(char(1), 'true') AS boolean);   -- Spark: true,  Comet: 
NULL (throws under ANSI)
   SELECT CAST(concat(char(1), '123')  AS int);       -- Spark: 123,   Comet: 
NULL (throws under ANSI)
   
   -- U+3000 is Unicode whitespace but not ASCII; Spark does not trim it, Comet 
does
   SELECT CAST(concat(' ', 'true') AS boolean);  -- Spark: NULL,  Comet: true
   SELECT CAST(concat(' ', '1.5')  AS double);   -- Spark: NULL,  Comet: 1.5
   ```
   
   ### Expected behavior
   
   Comet's string casts should trim exactly the byte set that the corresponding 
Spark cast path trims: bytes `0x00`-`0x20` plus `0x7F` for boolean / integral / 
date / timestamp / timestamp_ntz, and bytes `0x00`-`0x20` only for float / 
double / decimal. No non-ASCII whitespace should be trimmed by any of them.
   
   ### Additional context
   
   Originally reported for boolean only in #4959, which came out of the review 
of #4914. That PR deliberately left the pre-existing trim behavior alone to 
stay a pure performance change.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to