Skip to content

perf: preserve dictionary encoding for character_length, initcap, and reverse - #23930

Merged
Jefffrey merged 7 commits into
apache:mainfrom
lyne7-sc:preserve-unicode-dictionary-encoding
Aug 3, 2026
Merged

perf: preserve dictionary encoding for character_length, initcap, and reverse#23930
Jefffrey merged 7 commits into
apache:mainfrom
lyne7-sc:preserve-unicode-dictionary-encoding

Conversation

@lyne7-sc

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

Previously, coercion materialized dictionary-encoded inputs for character_length, initcap, and reverse. This lost the encoding and evaluated the function for every row instead of once per dictionary value entry.

This PR extends dictionary preservation to these functions.

character_length and reverse now use Coercible signatures to explicitly model string inputs and binary-to-string coercion while preserving dictionary encoding.

What changes are included in this PR?

  • Preserve dictionary encoding for character_length, initcap, and reverse.
  • Evaluate only dictionary values while reusing the original keys.
  • Migrate character_length and reverse from Uniform to Coercible.
  • Add tests and benchmarks.

Are these changes tested?

Yes, covered by SQL logic tests.

Are there any user-facing changes?

Yes. These functions now preserve dictionary encoding in their output.

character_length and reverse now accept logical string and binary inputs instead of implicitly converting unrelated types to strings.

Benchmark

group                                                           branch                                 main
-----                                                           ------                                 ----
dictionary_encoding/string/cardinality_10/character_length      1.00   218.5±32.62ns        ? ?/sec    31.04     6.8±0.07µs        ? ?/sec
dictionary_encoding/string/cardinality_10/initcap               1.00    292.2±4.75ns        ? ?/sec    359.88   105.2±0.91µs        ? ?/sec
dictionary_encoding/string/cardinality_10/reverse               1.00   501.6±15.71ns        ? ?/sec    140.60    70.5±1.05µs        ? ?/sec
dictionary_encoding/string/cardinality_100/character_length     1.00   307.5±24.93ns        ? ?/sec    21.85     6.7±0.09µs        ? ?/sec
dictionary_encoding/string/cardinality_100/initcap              1.00  1486.1±65.36ns        ? ?/sec    70.56   104.9±1.57µs        ? ?/sec
dictionary_encoding/string/cardinality_100/reverse              1.00  1410.9±52.69ns        ? ?/sec    49.28    69.5±0.68µs        ? ?/sec
dictionary_encoding/string/cardinality_1000/character_length    1.00  1007.4±93.42ns        ? ?/sec    6.64      6.7±0.07µs        ? ?/sec
dictionary_encoding/string/cardinality_1000/initcap             1.00     12.8±0.17µs        ? ?/sec    8.41    107.5±8.55µs        ? ?/sec
dictionary_encoding/string/cardinality_1000/reverse             1.00     10.5±0.24µs        ? ?/sec    6.56     69.1±0.58µs        ? ?/sec
dictionary_encoding/string/cardinality_8192/character_length    1.00      6.6±0.09µs        ? ?/sec    1.02      6.7±0.18µs        ? ?/sec
dictionary_encoding/string/cardinality_8192/initcap             1.00    104.2±1.36µs        ? ?/sec    1.00    104.2±1.63µs        ? ?/sec
dictionary_encoding/string/cardinality_8192/reverse             1.24     85.4±2.99µs        ? ?/sec    1.00     69.0±0.58µs        ? ?/sec

@github-actions github-actions Bot added sqllogictest SQL Logic Tests (.slt) functions Changes to functions implementation labels Jul 28, 2026
@codecov-commenter

codecov-commenter commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.50704% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.88%. Comparing base (21ad189) to head (5771b50).

Files with missing lines Patch % Lines
datafusion/functions/src/unicode/initcap.rs 79.06% 5 Missing and 4 partials ⚠️
...tafusion/functions/src/unicode/character_length.rs 93.33% 0 Missing and 1 partial ⚠️
datafusion/functions/src/unicode/reverse.rs 92.30% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #23930      +/-   ##
==========================================
- Coverage   80.88%   80.88%   -0.01%     
==========================================
  Files        1101     1101              
  Lines      375720   375746      +26     
  Branches   375720   375746      +26     
==========================================
+ Hits       303895   303914      +19     
+ Misses      53729    53722       -7     
- Partials    18096    18110      +14     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Self {
signature: Signature::uniform(
1,
vec![Utf8, LargeUtf8, Utf8View],

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.

i think this allowed any type;

> select character_length(10);
+-----------------------------+
| character_length(Int64(10)) |
+-----------------------------+
| 2                           |
+-----------------------------+
1 row(s) fetched.
Elapsed 0.004 seconds.

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.

yeah, this narrows the set of accepted inputs, but some of the implicit coercions allowed by the previous signatures don’t seem very intuitive to me. For example, reverse([1, 2, 3]) returns "]3 ,2 ,1[", and reverse(true) returns "eurt".

I checked postgresql and duckdb, and both require an explicit string cast here. This behavior seems more reasonable to me.

select reverse(true);
ERROR: function reverse(boolean) does not exist
HINT: You might need to add explicit type casts.

select reverse(ARRAY[1,2,3]);
ERROR: function reverse(integer[]) does not exist
HINT: You might need to add explicit type casts.

so I’m not sure whether we should keep the existing implicit conversions or require an explicit cast?

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.

at this point its more a question of if we should keep the existing behaviour or not 🤔

it looks like we didnt rely on the previous behaviour on our test suite (?) but maybe a downstream user could have used it. i am in favour of having a stricter signature, but i know in general we've been limiting breaking changes where possible

(i do agree things like reversing the array looks off)

if we substitute in TypeSignatureClass::Any for TypeSignatureClass::Native(logical_binary()) does it keep the previous behaviour? then we could in a separate followup explicitly tighten the input signature so at least its more visible in its own PR

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.

I’ve switched both character_length and reverse to Any to restore the previous implicit coercions. One difference for reverse is that non-string inputs are now coerced to Utf8View instead of Utf8.

Tightening the signatures in a followup sounds good, especially since there may be other functions with similar issues.

Comment thread datafusion/functions/src/unicode/initcap.rs Outdated
Comment thread datafusion/functions/src/unicode/initcap.rs Outdated
Self {
signature: Signature::uniform(
1,
vec![Utf8View, Utf8, LargeUtf8],

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.

similarly here it used to accept anything

Self {
signature: Signature::uniform(
1,
vec![Utf8, LargeUtf8, Utf8View],

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.

at this point its more a question of if we should keep the existing behaviour or not 🤔

it looks like we didnt rely on the previous behaviour on our test suite (?) but maybe a downstream user could have used it. i am in favour of having a stricter signature, but i know in general we've been limiting breaking changes where possible

(i do agree things like reversing the array looks off)

if we substitute in TypeSignatureClass::Any for TypeSignatureClass::Native(logical_binary()) does it keep the previous behaviour? then we could in a separate followup explicitly tighten the input signature so at least its more visible in its own PR

let args = &args.args;
match args[0].data_type() {
Utf8 | Utf8View | LargeUtf8 => make_scalar_function(reverse, vec![])(args),
Utf8 | Utf8View | LargeUtf8 | DataType::Dictionary(_, _) => {

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.

we can probably just remove this datatype check here, its unnecessary since signature guards it for us

@Jefffrey
Jefffrey added this pull request to the merge queue Aug 3, 2026
@Jefffrey

Jefffrey commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

thanks @lyne7-sc

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 3, 2026
@Jefffrey
Jefffrey added this pull request to the merge queue Aug 3, 2026
Merged via the queue into apache:main with commit 37baf42 Aug 3, 2026
38 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

functions Changes to functions implementation sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants