This is an automated email from the ASF dual-hosted git repository.

zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git


The following commit(s) were added to refs/heads/main by this push:
     new a2cf16fc7 perf(partition): append query-escaped path values directly 
(#2256)
a2cf16fc7 is described below

commit a2cf16fc7cb9994425a9e19b65924ec427dc57c4
Author: Minh Vu <[email protected]>
AuthorDate: Wed Oct 21 22:55:11 2026 +0200

    perf(partition): append query-escaped path values directly (#2256)
    
    * perf(partition): append query-escaped path values directly
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * style(partition): satisfy integer range lint checks
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * test(table): use integer range in partition path benchmarks
    
    ---------
    
    Signed-off-by: Minh Vu <[email protected]>
---
 internal/query_escape.go            |  52 +++++++++++++++++++
 internal/query_escape_bench_test.go |  60 ++++++++++++++++++++++
 internal/query_escape_test.go       |  49 ++++++++++++++++++
 partitions.go                       |   2 +-
 table/partition_path.go             |   4 +-
 table/partition_path_bench_test.go  | 100 ++++++++++++++++++++++++++++++++++++
 6 files changed, 264 insertions(+), 3 deletions(-)

diff --git a/internal/query_escape.go b/internal/query_escape.go
new file mode 100644
index 000000000..19999c67e
--- /dev/null
+++ b/internal/query_escape.go
@@ -0,0 +1,52 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package internal
+
+import "strings"
+
+const queryEscapeHex = "0123456789ABCDEF"
+
+// AppendQueryEscaped writes the exact encoding produced by url.QueryEscape
+// directly to a builder. Avoiding the temporary encoded string saves work
+// when building partition paths, especially for multiple escaped fields.
+func AppendQueryEscaped(builder *strings.Builder, value string) {
+       start := 0
+       for i := range len(value) {
+               char := value[i]
+               if (char >= 'a' && char <= 'z') ||
+                       (char >= 'A' && char <= 'Z') ||
+                       (char >= '0' && char <= '9') ||
+                       char == '-' || char == '_' || char == '.' || char == 
'~' {
+                       continue
+               }
+
+               if start < i {
+                       builder.WriteString(value[start:i])
+               }
+               if char == ' ' {
+                       builder.WriteByte('+')
+               } else {
+                       builder.WriteByte('%')
+                       builder.WriteByte(queryEscapeHex[char>>4])
+                       builder.WriteByte(queryEscapeHex[char&15])
+               }
+               start = i + 1
+       }
+
+       builder.WriteString(value[start:])
+}
diff --git a/internal/query_escape_bench_test.go 
b/internal/query_escape_bench_test.go
new file mode 100644
index 000000000..ef3c0cfe1
--- /dev/null
+++ b/internal/query_escape_bench_test.go
@@ -0,0 +1,60 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package internal
+
+import (
+       "net/url"
+       "strings"
+       "testing"
+)
+
+var queryEscapeBenchmarkSink string
+
+// BenchmarkAppendQueryEscaped compares the existing url.QueryEscape + 
WriteString
+// path with direct appending to the same builder and capacity.
+func BenchmarkAppendQueryEscaped(b *testing.B) {
+       cases := []struct {
+               name  string
+               value string
+       }{
+               {"ASCII-32", "customer-region-2026-oct-europe"},
+               {"Escaped-48", "Paris / Île-de-France ? + travel / cities"},
+               {"Long-escaped", strings.Repeat("some/part?=%\t+é", 24)},
+               {"Long-plain", strings.Repeat("customerseg2026", 30)},
+       }
+       for _, tc := range cases {
+               b.Run(tc.name, func(b *testing.B) {
+                       for _, method := range []string{"QueryEscape", 
"AppendQueryEscaped"} {
+                               b.Run(method, func(b *testing.B) {
+                                       b.ReportAllocs()
+                                       for range b.N {
+                                               var builder strings.Builder
+                                               builder.Grow(len(tc.value) + 20)
+                                               
builder.WriteString("partition=")
+                                               if method == "QueryEscape" {
+                                                       
builder.WriteString(url.QueryEscape(tc.value))
+                                               } else {
+                                                       
AppendQueryEscaped(&builder, tc.value)
+                                               }
+                                               queryEscapeBenchmarkSink = 
builder.String()
+                                       }
+                               })
+                       }
+               })
+       }
+}
diff --git a/internal/query_escape_test.go b/internal/query_escape_test.go
new file mode 100644
index 000000000..0d61e5cc6
--- /dev/null
+++ b/internal/query_escape_test.go
@@ -0,0 +1,49 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package internal
+
+import (
+       "net/url"
+       "strings"
+       "testing"
+)
+
+func TestAppendQueryEscapedMatchesURLQueryEscape(t *testing.T) {
+       values := []string{
+               "", "north-america", "spaces in value", "a/b?c=d&e+f", "x%y#z",
+               "~._-", "\x00\x7f\xff", "東京都", "café/naïve", "a\nb\tc",
+               strings.Repeat("/ +%", 100), strings.Repeat("ascii-09", 100),
+       }
+       // Cover every possible pair of input bytes, including invalid UTF-8.
+       for i := range 256 {
+               for j := range 256 {
+                       values = append(values, string([]byte{byte(i), 
byte(j)}))
+               }
+       }
+
+       for _, value := range values {
+               var builder strings.Builder
+               builder.WriteString("partition=")
+               AppendQueryEscaped(&builder, value)
+               got := builder.String()
+               want := "partition=" + url.QueryEscape(value)
+               if got != want {
+                       t.Fatalf("encoding differs for %q: got %q, want %q", 
value, got, want)
+               }
+       }
+}
diff --git a/partitions.go b/partitions.go
index efd11e621..38867e267 100644
--- a/partitions.go
+++ b/partitions.go
@@ -948,7 +948,7 @@ func (ps *PartitionSpec) PartitionToPath(data StructLike, 
sc *Schema) string {
 
                // Only escape the value (which changes per row)
                valueStr := 
resolvedFields[i].field.Transform.ToHumanStrType(resolvedFields[i].resultType, 
data.Get(i))
-               sb.WriteString(url.QueryEscape(valueStr))
+               internal.AppendQueryEscaped(&sb, valueStr)
        }
 
        return sb.String()
diff --git a/table/partition_path.go b/table/partition_path.go
index 5c360d1e2..7a999cae0 100644
--- a/table/partition_path.go
+++ b/table/partition_path.go
@@ -18,10 +18,10 @@
 package table
 
 import (
-       "net/url"
        "strings"
 
        "github.com/apache/iceberg-go"
+       "github.com/apache/iceberg-go/internal"
 )
 
 type partitionPathField struct {
@@ -53,7 +53,7 @@ func (p partitionPathPlan) format(data iceberg.StructLike) 
string {
                sb.WriteString(field.escapedName)
                sb.WriteByte('=')
                value := field.transform.ToHumanStrType(field.resultType, 
data.Get(i))
-               sb.WriteString(url.QueryEscape(value))
+               internal.AppendQueryEscaped(&sb, value)
        }
 
        return sb.String()
diff --git a/table/partition_path_bench_test.go 
b/table/partition_path_bench_test.go
new file mode 100644
index 000000000..cd5822210
--- /dev/null
+++ b/table/partition_path_bench_test.go
@@ -0,0 +1,100 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package table
+
+import (
+       "net/url"
+       "strconv"
+       "strings"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+)
+
+var partitionPathBenchmarkSink string
+
+// referencePartitionPathQueryEscape is the previous format implementation,
+// kept in the benchmark to compare on the same Go version and machine.
+func referencePartitionPathQueryEscape(p partitionPathPlan, data 
iceberg.StructLike) string {
+       if len(p.fields) == 0 {
+               return ""
+       }
+
+       var builder strings.Builder
+       builder.Grow(p.estimatedSize)
+       for i, field := range p.fields {
+               if i > 0 {
+                       builder.WriteByte('/')
+               }
+               builder.WriteString(field.escapedName)
+               builder.WriteByte('=')
+               value := field.transform.ToHumanStrType(field.resultType, 
data.Get(i))
+               builder.WriteString(url.QueryEscape(value))
+       }
+
+       return builder.String()
+}
+
+func BenchmarkPartitionPathQueryEscapeComparison(b *testing.B) {
+       cases := []struct {
+               name  string
+               value string
+       }{
+               {"ASCII", "customer-region-2026-oct-europe"},
+               {"Escaped", "Paris / Île-de-France ? + travel / cities"},
+               {"LongEscaped", strings.Repeat("some/part?=%\t+é", 24)},
+               {"LongPlain", strings.Repeat("customerseg2026", 30)},
+       }
+
+       for _, tc := range cases {
+               for _, count := range []int{1, 4, 16} {
+                       fields := make([]partitionPathField, count)
+                       values := make(partitionRecord, count)
+                       estimatedSize := 0
+                       for j := range fields {
+                               name := "p" + strconv.Itoa(j)
+                               fields[j] = partitionPathField{
+                                       escapedName: name,
+                                       transform:   
iceberg.IdentityTransform{},
+                                       resultType:  
iceberg.PrimitiveTypes.String,
+                               }
+                               values[j] = tc.value
+                               estimatedSize += len(name) + 20
+                       }
+                       plan := partitionPathPlan{fields: fields, 
estimatedSize: estimatedSize}
+                       if want, got := referencePartitionPathQueryEscape(plan, 
values), plan.format(values); want != got {
+                               b.Fatalf("mismatched partition paths: %q vs 
%q", want, got)
+                       }
+
+                       b.Run(tc.name+"/"+strconv.Itoa(count)+"Fields", func(b 
*testing.B) {
+                               b.Run("QueryEscape", func(b *testing.B) {
+                                       b.ReportAllocs()
+                                       for range b.N {
+                                               partitionPathBenchmarkSink = 
referencePartitionPathQueryEscape(plan, values)
+                                       }
+                               })
+                               b.Run("DirectAppend", func(b *testing.B) {
+                                       b.ReportAllocs()
+                                       for range b.N {
+                                               partitionPathBenchmarkSink = 
plan.format(values)
+                                       }
+                               })
+                       })
+               }
+       }
+}

Reply via email to