Skip to content

[fix](variant) Keep empty JSON objects intact when reading VARIANT - #67476

Open
morningman wants to merge 2 commits into
apache:masterfrom
morningman:wt-adbc-67367
Open

[fix](variant) Keep empty JSON objects intact when reading VARIANT#67476
morningman wants to merge 2 commits into
apache:masterfrom
morningman:wt-adbc-67367

Conversation

@morningman

Copy link
Copy Markdown
Contributor

What problem does this PR solve?

Issue Number: close #67367

Related PR: #65615 (Arrow Flight SQL tracking issue)

Problem Summary:

A VARIANT row that holds an empty JSON object carries no payload at all, so what makes it read back as {} lives in the column's shape rather than in its data. Two read paths lost that and returned a different, valid-looking value for a persisted {}.

1. Over Arrow Flight a {} came back as an empty string.

CREATE TABLE t (k INT, vn VARIANT NOT NULL) DUPLICATE KEY(k)
DISTRIBUTED BY HASH(k) BUCKETS 1 PROPERTIES("replication_num" = "1");
INSERT INTO t VALUES (1, '{}'), (2, '{}'), (3, '{}');
INSERT INTO t VALUES (4, '{}'), (5, '{"a" : 1}');

SELECT k, vn FROM t ORDER BY k;
--   MySQL : 1 {}  2 {}  3 {}  4 {}  5 {"a":1}
--   Arrow : 1 ''  2 ''  3 ''  4 {}  5 {"a":1}    <-- rows of the first merged run

The Arrow Flight result writer re-materializes every block through MutableBlock (varrow_flight_result_writer.cpp), one copy more than the MySQL writer does, and unlike insert_indices_from that copy is not finalized. An unfinalized Subcolumn keeps one part per source range, and Subcolumn::insert_range_from appends a new part instead of rewriting the earlier ones, so a later typed part promotes only the column-level least common type:

lct=Nullable(JSONB) ndip=0 parts=2 num_rows=5
serialize part i=0 ind=0 part_size=3 ptype=Nothing   <-- the empty-object rows

The "untyped root serializes as an empty object" rule in Subcolumn::serialize_text_json only consulted that column-level type, so the rows still sitting in an untyped part fell through to the Nothing serde and rendered as an empty string. The fix applies the same rule per part.

Because the trigger is the block shape (a sort merging rows from more than one source block), not the connection, the original report looked like a per-connection or first-read problem. It is neither: the same connection returns {} for a query shape that does not merge runs, and a fresh connection returns "" for one that does.

2. CAST(VARIANT AS STRING/JSON) returned SQL NULL for the same value whenever the column held no path at all — on both protocols:

-- every row is an empty object
SELECT v, CAST(v AS STRING), CAST(v AS JSON) FROM only_empty;   -->  {}   NULL   NULL
-- the same value in a column that also holds paths
SELECT v, CAST(v AS STRING) FROM mixed WHERE k = 1;             -->  {}   {}

Such a column is still a scalar variant — its root simply never got a type — and is_scalar_variant() short-circuited is_root_valuable, so the cast took the scalar-root fast path, found nothing to convert and produced NULL for every row. A NOT NULL column returned NULL this way too. The fix requires the root to carry a value, falls back to serializing the tree when no row's root does, and lets a STRING/JSONB target serialize the tree before the all-defaults branch turns it into NULL. Rows whose root does hold a value keep the root conversion, which is what unwraps a JSON string into its text.

Both fixes are read-path only; nothing about how a VARIANT is stored changes.

Known remaining gap, deliberately out of scope: in a column that mixes {} with scalar roots (e.g. 123), CAST of the {} rows still returns NULL. Closing that needs a row-level substitution in the root fast path, which would change the existing JSON-string unwrapping contract that variant_p0/column_name depends on.

Release note

Fix VARIANT empty JSON objects being returned as an empty string over Arrow Flight SQL, and as SQL NULL by CAST(VARIANT AS STRING/JSON) when the column holds no path.

Check List (For Author)

  • Test
    • Regression test
    • Unit Test
    • Manual test (add detailed scripts or steps below)

New coverage, both verified to fail without this change and pass with it:

  • be/test/core/column/column_variant_test.cpp — 5 cases under ColumnVariantEmptyObjectTest. empty_object_survives_copy_of_mixed_type_parts reproduces the exact shape from the log above and fails on rows 0-2 without the fix. Note for anyone extending these: the source parts must disagree on type (one READ_MODE, one WRITE_MODE finalize) and the copy must be left unfinalized, otherwise the test passes either way.
  • regression-test/suites/variant_p0/test_variant_empty_object_cast.groovy — the cast, across the all-empty / with-NULL / mixed shapes, plus an assertion that both shapes agree.
  • regression-test/suites/arrow_flight_sql_p0/test_select_variant.groovy — the sorted multi-block read over Arrow Flight, with a JDBC-vs-Arrow assertEquals so the two protocols must return the same rows.

Manual: reproduced and re-verified end to end against a local single FE + single BE cluster through both the MySQL protocol and the Python adbc_driver_flightsql client from the original report.

Regression sweep of variant_p0 + arrow_flight_sql_p0 (174 suites). The remaining failures were each confirmed to reproduce on a pristine build of the same commit without this change: test_all_prdefine_type_to_sparse (decimal256 precision), test_outfile_csv_variant_type (S3 curlCode: 43), test_sql_cache_over_arrow_flight (FE-side IllegalStateException), test_variant_compaction_with_sparse_limit (array rendering spacing).

  • Behavior changed:

    • Yes. A persisted empty JSON object now reads back as {} instead of an empty string over Arrow Flight, and CAST(VARIANT AS STRING/JSON) on a column that holds no path returns {} instead of NULL. Both make the value agree with what SELECT <variant> and every other shape of the same column already returned.
  • Does this need documentation?

    • No.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Aju9eFEiRNAJUozsDjE9aj

A VARIANT row that holds an empty JSON object carries no payload at all, so
what makes it read back as `{}` lives in the column's shape rather than in its
data. Two paths lost that, and both returned a different, valid-looking value
for a persisted `{}`.

Over Arrow Flight a `{}` came back as an empty string. The Arrow Flight result
writer re-materializes every block through MutableBlock, one copy more than the
MySQL writer does, and unlike insert_indices_from that copy is not finalized.
An unfinalized Subcolumn keeps one part per source range, and
Subcolumn::insert_range_from appends a new part instead of rewriting the
earlier ones, so a later typed part promotes only the column-level least common
type. The "untyped root serializes as an empty object" rule in
serialize_text_json only consulted that column-level type, so the rows still
sitting in an untyped part fell through to the Nothing serde and rendered as an
empty string. Apply the rule per part as well. This showed up for `SELECT` over
Arrow Flight whenever a sort merged rows from more than one source block: the
rows of the run merged first came back as `""` while the rest came back as
`{}`, which is why it looked like a per-connection or first-read problem.

CAST(VARIANT AS STRING/JSON) returned SQL NULL for the same value whenever the
column held no path at all. Such a column is still a scalar variant - its root
simply never got a type - and is_scalar_variant() short-circuited
is_root_valuable, so the cast took the scalar-root fast path, found nothing to
convert and produced NULL for every row, while the very same value in a column
that also holds paths came back as `{}`. Require the root to carry a value,
fall back to serializing the tree when no row's root does, and let a
STRING/JSONB target serialize the tree before the all-defaults branch turns it
into NULL. Rows whose root does hold a value keep the root conversion, which is
what unwraps a JSON string into its text.

Both fixes are read-path only; nothing about how a VARIANT is stored changes.

Fixes apache#67367

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aju9eFEiRNAJUozsDjE9aj
@hello-stephen

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Apache Doris.
Don't know what should be done next? See How to process your PR.

Please clearly describe your PR:

  1. What problem was fixed (it's best to include specific error reporting information). How it was fixed.
  2. Which behaviors were modified. What was the previous behavior, what is it now, why was it modified, and what possible impacts might there be.
  3. What features were added. Why was this function added?
  4. Which code was refactored and why was this part of the code refactored?
  5. Which functions were optimized and what is the difference before and after the optimization?

@morningman

Copy link
Copy Markdown
Contributor Author

run buildall

@morningman

Copy link
Copy Markdown
Contributor Author

run buildall

@eldenmoon

Copy link
Copy Markdown
Member

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes: the empty-object preservation goal is clear and the added coverage exercises the reported Arrow/JDBC and full-batch cast paths, but three blocking correctness gaps remain.

  1. The per-part INVALID_TYPE check also matches array shells over Nothing, changing a valid empty array from [] to {} in the mixed-part state this PR targets.
  2. The new root-value scan uses physical payloads outside the logical cast input, so an inactive suffix or an outer-null masked scalar can route a visible empty object through root conversion and turn it into SQL NULL.
  3. The newly reachable all-default JSONB fallback produces a logical-prefix data column with a full-source null map, causing prefix casts to throw on a nullable-column size mismatch.

Critical-checkpoint conclusions:

  • Goal and scope: the intended behavior is well defined and the diff is focused, but the new predicates/executor path are broader than the tested cases.
  • Correctness, special conditions, and parallel paths: STRING-family, JSONB, root/tree serialization, READ/WRITE finalization, range/index copies, and Arrow/JDBC materialization were traced; the three inline findings are independently reachable and blocking.
  • Lifecycle, ownership, concurrency, and error propagation: private Variant finalization and copied-column lifetimes are sound; no additional ownership, race, lock, cleanup, or unchecked-status issue was found.
  • Configuration, compatibility, persistence, transactions, and FE/BE boundaries: this change adds none, and no separate issue was found in those checkpoints.
  • Performance: the added root scan is linear in the batch; once bounded to logical rows it is not a separate blocker.
  • Tests: the new unit/regression cases cover the primary empty-object scenarios, but miss mixed empty arrays, outer-null retained payloads, inactive prefixes, and prefix JSONB output sizing. No builds or tests were run because the review bundle explicitly prohibited them.

Comment thread be/src/core/column/column_variant.cpp Outdated
// still sit in an untyped part. Render those rows as an empty JSON object here too,
// instead of letting the Nothing serde emit an empty string. See #67367.
if (get_base_type_of_array(data_types[i])->get_primitive_type() ==
PrimitiveType::INVALID_TYPE) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also matches Array(Nothing), which is the normal type of an empty array, so the new early return changes [] to {} in an unfinalized mixed-part root. For example, a doc-mode/root-only Variant run containing [] followed by a run containing [1] retains an Array(Nothing) first part while promoting the column LCT; scalar-root serialization reaches this line and now emits {} instead of letting DataTypeArraySerDe preserve the array delimiters. Please restrict this special case to a scalar Nothing part (zero array dimensions) and add the corresponding mixed-copy regression.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in c2d5878 — this was a real regression, not a hypothetical one.

get_base_type_of_array() strips every array layer, so Nullable(Array(Nullable(Nothing))) reduced to Nullable(Nothing) and matched the new early return. And that type is exactly what an empty array gets: FieldVisitorToScalarType inserts nothing into type_indexes for [], so get_least_supertype_jsonb() over the empty set returns Nothing (get_least_supertype.cpp:258-261), and with num_dimensions == 1 create_array_of_type() builds Nullable(Array(Nullable(Nothing))) (column_variant.cpp:85-97).

The rule now keys off the part's own type, which is precisely "scalar Nothing, zero dimensions": DataTypeNullable::get_primitive_type() forwards to its nested type (data_type_nullable.h:58), DataTypeNothing gives INVALID_TYPE, and DataTypeArray gives TYPE_ARRAY.

Added ColumnVariantEmptyObjectTest.empty_array_part_is_not_an_empty_object for the mixed-copy shape you describe. Against the previous check it fails with

serialize_subcolumn_row(subcolumn, 0)
  Which is: "{}"          expected "[]"
part type Nullable(Array(Nullable(Nothing))), column type Nullable(Array(Nullable(TINYINT)))

and it passes with this change. All 6 cases in the suite pass.

if (nullable_root != nullptr) {
const auto& root_null_map = nullable_root->get_null_map_data();
is_root_valuable = std::any_of(root_null_map.begin(), root_null_map.end(),
[](UInt8 is_null) { return is_null == 0; });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please scope this test to the logical rows being cast, not every physical root payload. prepare_remove_nullable supplies null_map separately and preserves nested payloads, so CAST(IF(k=1, v, NULL) AS STRING) over v = [{}, 123] counts the masked 123, takes root conversion, and returns NULL for the visible {} row. The same happens when a prepared cast is called with input_rows_count == 1 on a two-row root [NULL, 123], because this scans past the active prefix. Limit the scan to [0, input_rows_count) and require the outer null map (when present) to be clear; cover both STRING and JSONB.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scan tightened in c2d5878: it is now bounded by input_rows_count and skips the rows the outer null map masks out.

The outer-null-map half is real — prepare_remove_nullable unnests the argument and hands arg_null_map over separately (function_cast.cpp:206-219), and need_replace_null_data_to_default returns false for STRING/JSONB targets, so the masked rows keep their payloads.

One correction for the record: it was not a regression from this PR. In your v = [{}, 123] example the pre-PR code also returned NULL for the visible {} row, because is_root_valuable was simply is_scalar_variant(). The new any_of can only ever lower is_root_valuable, never raise it. So that case is the mixed-root gap the PR description calls out as deliberately out of scope — but honouring the mask closes this sub-case of it, so it is in.

On the prefix half, I could not find a live caller where input_rows_count < col_from.size() for this path: vcast_expr.cpp:122 and :171 pass temp_block.rows(), the per-row try_cast path at :236 passes a 1-row cut with count 1, prepare_remove_nullable forwards input_rows_count unchanged over same-size unnested columns, and const arguments go through a temporary block sized by its own rows(). The variant is also clone_finalized() before this point, so the root's size equals the column's. Bounding the scan costs nothing either way, so it is bounded.

@@ -125,6 +148,9 @@ inline Status cast_from_variant_impl(FunctionContext* context, Block& block,
return cast_from_generic_to_jsonb(context, finalized_block, arguments, result,
input_rows_count);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This newly activates cast_from_generic_to_jsonb for all-default Variant roots, but that helper sizes its null map with the full col_from.size() while producing only input_rows_count nested values. A prepared JSONB cast of a two-row [{}, {}] source with input_rows_count == 1 therefore throws when ColumnNullable checks the 1-vs-2 sizes; all roots are null, so limiting the scan above does not address it. Please make the JSONB fallback obey the logical row count (or pass it a prefix-cut column) and cover this prefix case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deliberately not addressed in this PR: the mechanism is real, but it predates this change and I could not find a reachable path to it.

ColumnUInt8::create(col_from.size(), 0) against a loop over input_rows_count has been there since #50940 (bd3c453, 2025-07-17), and cast_from_generic_to_jsonb is the shared generic-to-JSONB path for array/map/struct as well. If input_rows_count < col_from.size() were reachable, CAST(<array> AS JSON) would already throw today, independently of variant.

Reachability is the same question as the prefix half of the sibling comment, and the answer is the same: every cast entry point passes the full row count (vcast_expr.cpp:122, :171), the per-row try_cast path passes a 1-row cut with count 1 (:236), prepare_remove_nullable forwards the count unchanged over same-size columns, and const arguments run through a temporary block sized by its own rows().

If you would like it hardened regardless, ColumnUInt8::create(input_rows_count, 0) is a no-op whenever the sizes agree. I would rather send that as a separate PR against the shared helper than widen this one, since it changes a function every generic-to-JSONB cast goes through. Happy to do that if a maintainer prefers.

@hello-stephen

Copy link
Copy Markdown
Contributor

BE UT Coverage Report

Increment line coverage 73.91% (17/23) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 63.02% (29552/46894)
Line Coverage 48.07% (309890/644728)
Region Coverage 43.67% (250490/573605)
Branch Coverage 45.24% (116511/257567)

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-H: Total hot run time: 16874 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpch-tools
Tpch sf100 test result on commit e3c52e059175db932d991d81cf2346b3c65fa7de, data reload: false

------ Round 1 ----------------------------------
============================================
q1	17576	3095	3100	3095
q2	2103	253	218	218
q3	10235	859	533	533
q4	4670	260	208	208
q5	7666	580	385	385
q6	134	110	92	92
q7	532	497	381	381
q8	9240	918	945	918
q9	3459	2382	2364	2364
q10	6534	878	716	716
q11	396	200	182	182
q12	617	261	201	201
q13	18101	1539	1146	1146
q14	159	151	142	142
q15	q16	435	394	369	369
q17	1380	914	866	866
q18	3075	2255	2258	2255
q19	1114	916	693	693
q20	377	293	209	209
q21	5333	1669	1890	1669
q22	323	269	232	232
Total cold run time: 93459 ms
Total hot run time: 16874 ms

----- Round 2, with runtime_filter_mode=off -----
============================================
q1	3497	3426	3449	3426
q2	507	397	368	368
q3	2253	2364	2139	2139
q4	1196	1206	915	915
q5	2182	2114	2104	2104
q6	169	121	89	89
q7	1026	933	854	854
q8	1609	1432	1430	1430
q9	3178	3127	3126	3126
q10	1859	1806	1627	1627
q11	360	274	253	253
q12	457	431	345	345
q13	1492	1553	1138	1138
q14	167	177	156	156
q15	q16	402	389	359	359
q17	3693	3267	3219	3219
q18	4865	4420	4724	4420
q19	860	919	867	867
q20	1005	978	803	803
q21	3832	3284	3215	3215
q22	397	334	313	313
Total cold run time: 35006 ms
Total hot run time: 31166 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-DS: Total hot run time: 82232 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpcds-tools
TPC-DS sf100 test result on commit e3c52e059175db932d991d81cf2346b3c65fa7de, data reload: false

query5	4234	404	319	319
query6	386	131	158	131
query7	4952	416	231	231
query8	286	127	111	111
query9	8676	2922	2909	2909
query10	422	218	180	180
query11	5386	1033	908	908
query12	120	68	70	68
query13	1191	473	339	339
query14	6105	2224	2104	2104
query14_1	2042	1981	1994	1981
query15	177	117	112	112
query16	937	349	357	349
query17	819	455	368	368
query18	2333	319	235	235
query19	170	138	108	108
query20	74	68	70	68
query21	201	103	89	89
query22	5265	5374	5344	5344
query23	6572	6198	5880	5880
query23_1	5951	6053	6089	6053
query24	7290	1093	756	756
query24_1	793	796	779	779
query25	440	318	258	258
query26	1230	244	134	134
query27	2765	422	252	252
query28	4714	1506	1505	1505
query29	936	463	361	361
query30	255	154	131	131
query31	824	408	332	332
query32	151	78	77	77
query33	475	221	183	183
query34	1008	823	498	498
query35	426	404	344	344
query36	575	596	536	536
query37	127	87	75	75
query38	1003	844	807	807
query39	489	506	476	476
query39_1	466	454	443	443
query40	207	94	83	83
query41	59	55	56	55
query42	77	75	74	74
query43	248	249	213	213
query44	1043	551	550	550
query45	114	106	99	99
query46	764	870	508	508
query47	803	756	698	698
query48	320	323	237	237
query49	527	236	203	203
query50	737	258	202	202
query51	8144	8144	8145	8144
query52	66	71	60	60
query53	202	194	159	159
query54	237	185	170	170
query55	77	61	57	57
query56	192	181	155	155
query57	741	665	675	665
query58	195	169	190	169
query59	1248	1257	1095	1095
query60	232	191	164	164
query61	117	109	117	109
query62	373	207	178	178
query63	176	138	138	138
query64	2714	661	562	562
query65	1613	1618	1594	1594
query66	1924	258	208	208
query67	9615	9751	9838	9751
query68	2890	1160	717	717
query69	345	213	188	188
query70	669	615	601	601
query71	249	179	173	173
query72	2314	1746	1772	1746
query73	617	579	323	323
query74	2002	1209	1147	1147
query75	1161	1088	971	971
query76	2293	737	523	523
query77	244	246	208	208
query78	3937	3772	3164	3164
query79	2271	846	564	564
query80	1593	330	273	273
query81	505	158	131	131
query82	1125	123	91	91
query83	276	205	195	195
query84	295	107	93	93
query85	784	359	294	294
query86	418	171	172	171
query87	1040	966	892	892
query88	2769	2132	2102	2102
query89	288	200	175	175
query90	1985	121	123	121
query91	131	120	98	98
query92	76	65	70	65
query93	1466	1052	697	697
query94	703	257	218	218
query95	526	316	222	222
query96	767	613	282	282
query97	1037	1039	1003	1003
query98	167	135	132	132
query99	429	349	315	315
Total cold run time: 177860 ms
Total hot run time: 82232 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
ClickBench: Total hot run time: 14.54 s
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/clickbench-tools
ClickBench test result on commit e3c52e059175db932d991d81cf2346b3c65fa7de, data reload: false

query1	0.01	0.00	0.01
query2	0.08	0.04	0.03
query3	0.24	0.10	0.11
query4	1.60	0.10	0.09
query5	0.18	0.17	0.16
query6	1.25	0.70	0.67
query7	0.03	0.01	0.00
query8	0.05	0.03	0.03
query9	0.28	0.21	0.21
query10	0.33	0.34	0.34
query11	0.16	0.12	0.12
query12	0.16	0.12	0.12
query13	0.31	0.30	0.32
query14	0.44	0.44	0.45
query15	0.36	0.35	0.36
query16	0.21	0.21	0.22
query17	0.71	0.69	0.70
query18	0.16	0.16	0.16
query19	1.16	1.10	1.15
query20	0.02	0.01	0.01
query21	15.44	0.15	0.10
query22	5.06	0.04	0.04
query23	16.20	0.24	0.10
query24	3.01	0.31	0.27
query25	0.12	0.05	0.03
query26	0.82	0.16	0.12
query27	0.03	0.04	0.03
query28	3.71	0.55	0.27
query29	12.44	3.19	2.59
query30	0.26	0.12	0.12
query31	2.77	0.36	0.18
query32	3.53	0.34	0.25
query33	1.35	1.38	1.40
query34	15.42	2.26	1.75
query35	1.78	1.77	1.68
query36	0.46	0.29	0.29
query37	0.06	0.04	0.04
query38	0.04	0.03	0.02
query39	0.03	0.02	0.02
query40	0.11	0.08	0.07
query41	0.08	0.03	0.02
query42	0.03	0.02	0.02
query43	0.03	0.03	0.02
Total cold run time: 90.52 s
Total hot run time: 14.54 s

@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 100.00% (23/23) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 76.28% (34646/45420)
Line Coverage 61.33% (390869/637365)
Region Coverage 57.50% (328561/571421)
Branch Coverage 58.30% (149727/256828)

…ect rule

Two follow-ups on the per-part empty-object rule and the root-value scan.

The per-part rule keyed off get_base_type_of_array(), which strips every array
layer, so it also matched Array(Nothing) - the type an empty array gets, since
an empty array carries no element type. That is not an absent value the way an
untyped part is: it is `[]`, and the array serde renders it. In an unfinalized
subcolumn whose later part promotes the column-level least common type past
Array(Nothing), a `[]` sitting in the unpromoted part came back as `{}`. Key
the rule off the part's own type instead, which is exactly "scalar Nothing".

The root-value scan walked the whole root null map. Only the rows this call
converts should have a say: prepare_remove_nullable hands the outer null map
over as a separate argument and leaves the masked rows' payloads in place, so a
masked row whose root carries a value kept every visible row on the root
conversion. Bound the scan by input_rows_count and skip masked rows.

ColumnVariantEmptyObjectTest.empty_array_part_is_not_an_empty_object covers the
first one: it fails on the previous check with
`part type Nullable(Array(Nullable(Nothing))), column type
Nullable(Array(Nullable(TINYINT)))` returning `{}` instead of `[]`, and passes
with this change. All 6 cases in the suite pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FE4hQXyTkfCbqkMGbokU6e
@morningman

Copy link
Copy Markdown
Contributor Author

run buildall

@hello-stephen

Copy link
Copy Markdown
Contributor

BE UT Coverage Report

Increment line coverage 45.71% (16/35) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 63.02% (29550/46891)
Line Coverage 48.06% (309840/644726)
Region Coverage 43.64% (250309/573615)
Branch Coverage 45.23% (116506/257577)

@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 94.29% (33/35) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 76.29% (34647/45417)
Line Coverage 61.35% (390996/637363)
Region Coverage 57.60% (329161/571431)
Branch Coverage 58.38% (149940/256838)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Arrow Flight SQL: first VARIANT read on a pre-existing connection returns an empty string instead of {}

3 participants