feat: add DynamicParameter expression and plan bindings#215
feat: add DynamicParameter expression and plan bindings#215bvolpato merged 3 commits intosubstrait-io:mainfrom
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #215 +/- ##
==========================================
+ Coverage 68.33% 68.56% +0.23%
==========================================
Files 47 47
Lines 10727 10843 +116
==========================================
+ Hits 7330 7435 +105
- Misses 3043 3053 +10
- Partials 354 355 +1 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
3e5b607 to
1c86707
Compare
benbellick
left a comment
There was a problem hiding this comment.
Left a few comments but had to run. I think there is opportunity to simplify the tests a little bit so that it is easier to determine what they are doing with just a skim, e.g. making aroundtrip tests simply assert that the plan equals itself post-rountrip without any other checks.
I think the more important question for me is: do we want to validate that the types of dynamic parameters match the types of dynamic parameter values in the plan? This would probably be valuable. And if we aren't going to do it, probably makes sense to at least document that somewhere. (Sorry if you did this and I just missed it).
Anyways, thanks for doing this!
| } | ||
|
|
||
| assert.True(t, dp.IsScalar()) | ||
| assert.Equal(t, "$0:i32", dp.String()) |
There was a problem hiding this comment.
Is this syntax part of the core substrait spec? I don't see it on the core docs here at least.
There was a problem hiding this comment.
Nope, added a clarification:
// The $N:type String() format (e.g. "$0:i32") is an internal debugging
// representation used by this library; it is not part of the Substrait spec.
| fieldRef, err := expr.NewFieldRefFromType(dp, expr.NewStructFieldRef(0), | ||
| &types.Int32Type{Nullability: types.NullabilityRequired}) | ||
| require.NoError(t, err) | ||
| assert.NotNil(t, fieldRef) | ||
| } | ||
|
|
||
| func TestDynamicParameterNullableType(t *testing.T) { | ||
| dp := &expr.DynamicParameter{ | ||
| OutputType: &types.StringType{Nullability: types.NullabilityNullable}, |
There was a problem hiding this comment.
can we just add these two as test cases to TestDynamicParameterToProtoRoundtrip? I think that that covers these tests and makes it a bit easier to follow all of the tests.
|
|
||
| func TestDynamicParameterFromProtoJSON(t *testing.T) { | ||
| // Test parsing a plan from JSON containing dynamic parameters | ||
| const planJSON = `{ |
There was a problem hiding this comment.
Can we drop this in either plan/testdata or expr/testdata?
Add DynamicParameter expression type with full Expression interface implementation, ExprBuilder support, and plan-level DynamicParameterBinding for parameterized queries.
1c86707 to
2e8f213
Compare
benbellick
left a comment
There was a problem hiding this comment.
LGTM, just added some claude-assisted comments about ways to make the test intention more clear. Feel free to ignore if you disagree. Thanks!
There was a problem hiding this comment.
I asked claude to help reduce the size of these tests to make them a bit easier to follow and it produced this. What do you think? I think its a bit easier to see the intended test.
// SPDX-License-Identifier: Apache-2.0
package expr_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/substrait-io/substrait-go/v7/expr"
"github.com/substrait-io/substrait-go/v7/extensions"
"github.com/substrait-io/substrait-go/v7/types"
proto "github.com/substrait-io/substrait-protobuf/go/substraitpb"
pb "google.golang.org/protobuf/proto"
)
func TestDynamicParameterEquals(t *testing.T) {
i64Req := &types.Int64Type{Nullability: types.NullabilityRequired}
fp64Req := &types.Float64Type{Nullability: types.NullabilityRequired}
base := &expr.DynamicParameter{OutputType: i64Req, ParameterReference: 0}
tests := []struct {
name string
other expr.Expression
want bool
}{
{"same type and ref", &expr.DynamicParameter{OutputType: i64Req, ParameterReference: 0}, true},
{"different ref", &expr.DynamicParameter{OutputType: i64Req, ParameterReference: 1}, false},
{"different type", &expr.DynamicParameter{OutputType: fp64Req, ParameterReference: 0}, false},
{"different expression kind", expr.NewPrimitiveLiteral(int64(42), false), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, base.Equals(tt.other))
})
}
}
func TestDynamicParameterVisit(t *testing.T) {
dp := &expr.DynamicParameter{
OutputType: &types.Int32Type{Nullability: types.NullabilityRequired},
ParameterReference: 5,
}
visited := dp.Visit(func(e expr.Expression) expr.Expression { return e })
assert.Same(t, dp, visited, "Visit should return same pointer for leaf expression")
}
func TestDynamicParameterToProtoRoundtrip(t *testing.T) {
tests := []struct {
name string
dp *expr.DynamicParameter
}{
{"required i32", &expr.DynamicParameter{
OutputType: &types.Int32Type{Nullability: types.NullabilityRequired}, ParameterReference: 0}},
{"nullable string", &expr.DynamicParameter{
OutputType: &types.StringType{Nullability: types.NullabilityNullable}, ParameterReference: 1}},
}
reg := expr.NewEmptyExtensionRegistry(extensions.GetDefaultCollectionWithNoError())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.True(t, tt.dp.IsScalar())
assert.True(t, tt.dp.GetType().Equals(tt.dp.OutputType))
protoExpr := tt.dp.ToProto()
require.NotNil(t, protoExpr)
fromProto, err := expr.ExprFromProto(protoExpr, nil, reg)
require.NoError(t, err)
assert.True(t, tt.dp.Equals(fromProto), "roundtrip should produce equal expression")
protoRoundTrip := fromProto.ToProto()
assert.True(t, pb.Equal(protoExpr, protoRoundTrip), "proto roundtrip should be equal")
})
}
}
func TestDynamicParameterFromProtoNilDynamicParam(t *testing.T) {
protoExpr := &proto.Expression{
RexType: &proto.Expression_DynamicParameter{
DynamicParameter: nil,
},
}
_, err := expr.ExprFromProto(protoExpr, nil, expr.NewEmptyExtensionRegistry(extensions.GetDefaultCollectionWithNoError()))
require.Error(t, err)
assert.Contains(t, err.Error(), "dynamic parameter is nil")
}
func TestDynamicParameterBuilderNilType(t *testing.T) {
b := expr.ExprBuilder{
Reg: expr.NewEmptyExtensionRegistry(extensions.GetDefaultCollectionWithNoError()),
}
_, err := b.DynamicParam(nil, 0).BuildExpr()
require.Error(t, err)
assert.Contains(t, err.Error(), "dynamic parameter must have an output type")
}
func TestDynamicParameterBuilderAsFuncArg(t *testing.T) {
b := expr.ExprBuilder{
Reg: expr.NewEmptyExtensionRegistry(extensions.GetDefaultCollectionWithNoError()),
BaseSchema: types.NewRecordTypeFromStruct(boringSchema.Struct),
}
dpBuilder := b.DynamicParam(&types.Int8Type{Nullability: types.NullabilityRequired}, 0)
e, err := b.ScalarFunc(addID).Args(
dpBuilder,
b.Wrap(expr.NewLiteral(int8(5), false)),
).BuildExpr()
require.NoError(t, err)
assert.Contains(t, e.String(), "$0:i8")
}
func TestDynamicParameterTypeMismatchInFunction(t *testing.T) {
b := expr.ExprBuilder{
Reg: expr.NewEmptyExtensionRegistry(extensions.GetDefaultCollectionWithNoError()),
BaseSchema: types.NewRecordTypeFromStruct(boringSchema.Struct),
}
tests := []struct {
name string
funcID extensions.ID
dpType types.Type
lit func() (expr.Literal, error)
}{
{
name: "i32 where i8 expected",
funcID: extensions.ID{URN: extensions.SubstraitDefaultURNPrefix + "functions_arithmetic", Name: "add:i8_i8"},
dpType: &types.Int32Type{Nullability: types.NullabilityRequired},
lit: func() (expr.Literal, error) { return expr.NewLiteral(int8(5), false) },
},
{
name: "string where numeric expected",
funcID: addID,
dpType: &types.StringType{Nullability: types.NullabilityRequired},
lit: func() (expr.Literal, error) { return expr.NewLiteral(int32(5), false) },
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := b.ScalarFunc(tt.funcID).Args(
b.DynamicParam(tt.dpType, 0),
b.Wrap(tt.lit()),
).BuildExpr()
require.Error(t, err)
})
}
}There was a problem hiding this comment.
What do you think of just simplifying this to only be roundtrip tests from JSON?
// SPDX-License-Identifier: Apache-2.0
package plan_test
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/substrait-io/substrait-go/v7/extensions"
"github.com/substrait-io/substrait-go/v7/plan"
substraitproto "github.com/substrait-io/substrait-protobuf/go/substraitpb"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
)
func TestDynamicParameterPlanRoundtrip(t *testing.T) {
for _, name := range []string{
"dynamic_parameter_plan",
"dynamic_parameter_filter", // this would have to be added
} {
t.Run(name, func(t *testing.T) {
planJSON, err := testdata.ReadFile(fmt.Sprintf("testdata/%s.json", name))
require.NoError(t, err)
var protoPlan substraitproto.Plan
require.NoError(t, protojson.Unmarshal(planJSON, &protoPlan))
p, err := plan.FromProto(&protoPlan, extensions.GetDefaultCollectionWithNoError())
require.NoError(t, err)
backToProto, err := p.ToProto()
require.NoError(t, err)
assert.Truef(t, proto.Equal(&protoPlan, backToProto),
"expected: %s\ngot: %s",
protojson.Format(&protoPlan), protojson.Format(backToProto))
})
}
}| // NOTE: this library does not currently validate that the type of the | ||
| // literal Value matches the OutputType declared on the corresponding | ||
| // DynamicParameter expression. Consumers should perform their own | ||
| // type-checking if needed. |
There was a problem hiding this comment.
| // type-checking if needed. | |
| // type-checking if needed. | |
| // TODO(#216): Validate that binding and declaration match |
466c571 to
cbce572
Compare
Validate that each DynamicParameterBinding's literal type matches the OutputType declared on the corresponding DynamicParameter expression in the plan tree. Type comparison ignores nullability. Validation runs automatically in PlanWithBindings and is also available as the exported ValidateParameterBindings function. New tests cover type mismatches, missing anchors, nullability tolerance, and validation through filter conditions.
Expr tests: - Convert Equals test to table-driven subtests - Extract nil-type builder test into its own function - Remove redundant FuncArg, InProject, and MultipleInExpression tests (covered by the roundtrip and builder-as-func-arg tests) - Add TestDynamicParameterTypeMismatchInFunction Plan tests: - Consolidate JSON roundtrip tests into a single loop over testdata files - Add dynamic_parameter_filter.json testdata (generated from builder) - Remove redundant programmatic builder tests that duplicated the JSON roundtrip coverage
cbce572 to
db9f3f7
Compare
Summary
Add full support for
DynamicParameterexpressions and plan-levelDynamicParameterBinding, enabling parameterized queries in substrait-go.This mirrors the functionality recently added in substrait-java#752, ensuring Go consumers can create and read plans containing dynamic parameters.
Changes
Expression (
expr/expression.go)DynamicParameterstruct implementing the fullExpressioninterface (String,ToProto,Equals,Visit,IsScalar,GetType,ToProtoFuncArg)ExprFromProto()with nil safety checkBuilder (
expr/builder.go)ExprBuilder.DynamicParam(outputType, paramRef)method withdynamicParamBuilderBuildExpr()andBuildFuncArg()for use as function argumentsPlan bindings (
plan/common.go,plan/plan.go,plan/builders.go)DynamicParameterBindingstruct mapping parameter anchors to literal valuesPlan.ParameterBindings()accessorFromProto()/ToProto()serialization for parameter bindingsBuilder.PlanWithBindings()method on theBuilderinterfaceTesting
17 new tests across two test files:
expr/dynamic_parameter_test.go— 11 tests covering basic construction, nullability, equality, visit, proto roundtrip (5 types), nil proto error, builder (3 cases), builder as function argument, and multiple parametersplan/dynamic_parameter_test.go— 6 tests covering filter plan with bindings, project plan, multiple bindings, no bindings, JSON parsing roundtrip, and end-to-end builder usageAll existing tests continue to pass with zero regressions.