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)
+ }
+ })
+ })
+ }
+ }
+}