Fix NPE reading STRING COLLATE columns via Arrow#1548
Conversation
A collated string column (e.g. STRING COLLATE UTF8_LCASE) is returned with a type_name that does not map to any ColumnInfoTypeName, so ColumnInfo.getTypeName() is null and ArrowToJavaObjectConverter.convert throws a NullPointerException at `switch (requiredType)` when reading the value (both list and single-row results). Recover the type from the arrow metadata prefix: when requiredType is null and the metadata starts with STRING (which covers "STRING COLLATE ..."), treat it as STRING. Also guard the switch with an explicit DatabricksValidationException so any future unmapped type produces a clear error instead of a raw NPE. Regression vs 3.3.3 (which reads these columns fine). Fixes databricks#1547. Added ArrowToJavaObjectConverterTest#testCollatedStringWithNullRequiredType. Signed-off-by: Matthieu Sprunck <msprunck@cisco.com>
- The null-guard error path no longer embeds the raw column value in the log message or exception (could expose sensitive result data or emit very large values); it now reports only the arrow metadata. - Added testUnresolvableTypeThrowsValidationException covering the guard branch (null requiredType + unmappable metadata -> DatabricksValidationException instead of NPE). Signed-off-by: Matthieu Sprunck <msprunck@cisco.com>
Collated string columns (e.g. "STRING COLLATE UTF8_LCASE") arrive from the SEA result manifest with a null ColumnInfoTypeName, so getColumnType() returned OTHER and getColumnTypeName() the raw collated text, even though getObject() now reads the value correctly. In the SEA ResultManifest metadata constructor, recover STRING from the typeText prefix when the type name is null (mirroring the existing TIMESTAMP_NTZ null-recovery), so getColumnType() resolves to VARCHAR. The collated typeText is preserved for getColumnTypeName(). Added DatabricksResultSetMetaDataTest#testColumnsWithCollatedString. Signed-off-by: Matthieu Sprunck <msprunck@cisco.com>
| // A collated string column (e.g. "STRING COLLATE UTF8_LCASE") is reported with a type_name | ||
| // that does not map to any ColumnInfoTypeName, leaving requiredType null. Recover the type | ||
| // from the metadata prefix so the value is read as a string instead of throwing an NPE below. | ||
| if (requiredType == null && arrowMetadata.startsWith(STRING)) { |
There was a problem hiding this comment.
Medium · flagged by devil's-advocate + test reviewers, confirmed against head SHA.
The two recovery sites added in this PR normalize case differently:
- Value path (this line,
ArrowToJavaObjectConverter.java:96):arrowMetadata.startsWith(STRING)— case-sensitive. - Metadata path (
DatabricksResultSetMetaData.java:113):columnInfo.getTypeText().toUpperCase().startsWith(STRING)— case-insensitive.
If any server build emits the collated type text in lower/mixed case (e.g. "string collate utf8_lcase"), the metadata path resolves the column to VARCHAR, but this value path's startsWith fails, requiredType stays null, and getObject throws DatabricksValidationException. The driver would then advertise a readable VARCHAR column that cannot actually be read — worse than a clean failure.
Suggestion: align both sites on the same case handling. Since the value-path siblings (ARRAY/STRUCT/MAP/…) are all case-sensitive by the Spark SqlName uppercase convention, either accept that in both places or normalize both with toUpperCase(Locale.ROOT).
Posted by code-review-squad · /full-review · feedback: #code-review-squad-feedback
| // A collated string column (e.g. "STRING COLLATE UTF8_LCASE") is reported with a type_name | ||
| // that does not map to any ColumnInfoTypeName, leaving requiredType null. Recover the type | ||
| // from the metadata prefix so the value is read as a string instead of throwing an NPE below. | ||
| if (requiredType == null && arrowMetadata.startsWith(STRING)) { |
There was a problem hiding this comment.
Medium (open question) · flagged by devil's-advocate; consequence not verified at the branching point.
The read path (ArrowStreamResult.java:251-253) falls back to columnInfo.getTypeText() only when chunkIterator.getType() returns null — not when it returns a non-null value that maps to no known type. So this recovery keys off arrowMetadata (the Arrow field Spark:DataType:SqlName), which may differ from the manifest type_text.
Issue #1547 notes two warehouse builds on the same version label behaved differently (one NPE'd, one didn't). If some build's Spark:DataType:SqlName for a collated column is a value that doesn't start with "STRING", this recovery won't fire and the read now throws DatabricksValidationException instead of the old NPE — still unreadable for the user.
I could not confirm what Spark:DataType:SqlName actually holds for the failing build (UNKNOWN_COLUMN_TYPE_NAME is a protobuf/SDK enum sentinel → null after deserialization, not an Arrow-metadata string), so this is a question rather than a confirmed defect.
Question: for the exact warehouse build in #1547 that NPE'd, what does the Arrow field metadata Spark:DataType:SqlName contain for the collated column? If it can be non-STRING, consider recovering when either arrowMetadata or columnInfo.getTypeText() starts with "STRING".
Posted by code-review-squad · /full-review · feedback: #code-review-squad-feedback
| if (columnTypeName == null | ||
| && columnInfo.getTypeText() != null | ||
| && columnInfo.getTypeText().toUpperCase().startsWith(STRING)) { | ||
| columnTypeName = ColumnInfoTypeName.STRING; |
There was a problem hiding this comment.
Medium · flagged by architecture + maintainability reviewers.
This recovery now lives in both DatabricksResultSetMetaData and ArrowToJavaObjectConverter because the SEA path has no single type-name resolution point — the metadata constructor recovers into a local variable and never writes back to the shared ColumnInfo, so the read path independently re-derives the null getTypeName(). Every future null-type_name server type must be patched in lockstep, and the two copies have already drifted on case handling (see F1).
This mirrors a pre-existing convention, so it's a maintainability cost to note rather than a blocker. Suggested direction (follow-up, not required here): a single resolveTypeName(ColumnInfo) helper applied right after SEA manifest deserialization that calls setTypeName(...) on the shared object, letting both consumers drop their local recovery.
Posted by code-review-squad · /full-review · feedback: #code-review-squad-feedback
| // A collated string column (e.g. "STRING COLLATE UTF8_LCASE") is reported with a type_name | ||
| // that does not map to any ColumnInfoTypeName, leaving requiredType null. Recover the type | ||
| // from the metadata prefix so the value is read as a string instead of throwing an NPE below. | ||
| if (requiredType == null && arrowMetadata.startsWith(STRING)) { |
There was a problem hiding this comment.
Low · flagged by devil's-advocate.
A hypothetical future type whose name begins with the letters STRING (e.g. "STRINGVIEW", "STRINGSET") would be silently coerced to scalar STRING and read via toString() — producing wrong values rather than an error, precisely on the null-typeName path where a brand-new type is most likely to land. Consistent with the existing sibling prefix checks, so low priority.
Optional: match a word boundary — typeText.equals("STRING") || typeText.startsWith("STRING ") || typeText.startsWith("STRING(") — so "STRING COLLATE …" matches but "STRINGVIEW" does not.
Posted by code-review-squad · /full-review · feedback: #code-review-squad-feedback
| } | ||
|
|
||
| @Test | ||
| public void testCollatedStringWithNullRequiredType() throws Exception { |
There was a problem hiding this comment.
Low · flagged by test reviewer.
Both new tests feed already-uppercase literals, so:
- The
.toUpperCase()on the metadata side is a no-op in the test — delete it from production and the test still passes (covered by zero assertions). - Plain
"STRING"via the null-recovery path is uncovered (the existingtestNullHandlingInVarCharVectoruses a non-nullrequiredType).
A converter case with lowercase metadata — convert(vector, 0, null, "string collate utf8_lcase", new ColumnInfo()) expecting the value — would currently fail and directly surface F1. Adding a lowercase metadata assertion covers the .toUpperCase() call. The two added tests are otherwise sound (real Arrow vectors, independent literal expectations, would fail if the fix were reverted).
Posted by code-review-squad · /full-review · feedback: #code-review-squad-feedback
Code Review Squad — ReviewScore: 89/100 — MODERATE RISK Solid, well-scoped bug fix that faithfully follows the existing in-file prefix-recovery convention. No merge blockers — every finding below is a robustness or test-coverage gap around the recovery logic, verified against the PR head SHA. The highest-value item is the case-asymmetry between the two recovery sites (F1); the lowercase test in F5 would have caught it for free. Security and language reviewers found nothing at threshold. |
Description
Fixes #1547.
A
STRINGcolumn carrying a non-default collation (e.g.STRING COLLATE UTF8_LCASE) cannot be read on3.4.1. Such a column is reported with atype_namethat does not map to anyColumnInfoTypeName, soColumnInfo.getTypeName()isnull. Two things break:ArrowToJavaObjectConverter.convertreachesswitch (requiredType)withrequiredType == nulland throws aNullPointerException. This aborts every result set containing a collated string column, including a single-row result.DatabricksResultSetMetaDatamaps the null type name toTypes.OTHER, sogetColumnType()returnsOTHERand the column is not usable as a string, even once the value read is fixed.Why 3.4.1 surfaces this
3.4.1 routes metadata/type resolution through the SEA result manifest (the SQL SHOW / SEA metadata unification described in the 3.4.1 breaking changes) rather than native Thrift metadata RPCs. That path returns the column's server type text verbatim (
STRING COLLATE UTF8_LCASE, reported over Arrow astype_name = UNKNOWN_COLUMN_TYPE_NAME), which the driver's type mapping does not recognize. Older drivers took a metadata path that yielded a mappableSTRINGtype, so the same query/column worked. The driver's conversion source is otherwise unchanged between the two versions — the trigger is the metadata routing, not the conversion logic.Changes
ArrowToJavaObjectConverter.convert: whenrequiredTypeis null and the arrow metadata starts withSTRING(coveringSTRING COLLATE ...), resolve the type toSTRING, mirroring the existing prefix-based recovery forARRAY/STRUCT/MAP/VARIANT/TIMESTAMP/GEOMETRY/GEOGRAPHY. Also guard theswitchwith an explicitDatabricksValidationException(logging only the metadata, not the raw cell value) so any future unmapped type produces a clear error instead of a raw NPE.DatabricksResultSetMetaData(SEA result manifest constructor): when the column type name is null and the type text starts withSTRING, recoverSTRINGsogetColumnType()resolves toVARCHARinstead ofOTHER. The collated type text is preserved sogetColumnTypeName()still reports the server type (mirroring the existingTIMESTAMP_NTZnull-recovery).Testing
ArrowToJavaObjectConverterTest#testCollatedStringWithNullRequiredType— reads a collated column value withrequiredType = null, asserts the string is returned rather than an NPE.ArrowToJavaObjectConverterTest#testUnresolvableTypeThrowsValidationException— asserts the null-guard throwsDatabricksValidationExceptionfor a genuinely unmappable type.DatabricksResultSetMetaDataTest#testColumnsWithCollatedString— assertsgetColumnType()isVARCHARandgetColumnTypeName()preservesSTRING COLLATE UTF8_LCASE.ArrowToJavaObjectConverterTestandDatabricksResultSetMetaDataTestpass (64 tests).rule_name COLLATE UTF8_LCASE/rule_description COLLATE UTF8_LCASE: with this change on3.4.1, value reads succeed andgetColumnType()reportsVARCHAR, where the unpatched3.4.1driver throws the NPE.Additional Notes to the Reviewer
STRING-prefix branches are guarded by a null type check, so they act only as a fallback and do not change behavior for columns whose type already resolves.