diff --git a/go.mod b/go.mod index c26c5a30..1248afc6 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.10.1 github.com/stackql-labs/omnisdk v0.1.4-alpha05 - github.com/stackql/any-sdk v0.6.0-alpha01 + github.com/stackql/any-sdk v0.6.1-alpha02 github.com/stackql/go-suffix-map v0.0.1-alpha01 github.com/stackql/psql-wire v0.1.3-beta01 github.com/stackql/stackql-parser v0.0.16-alpha02 diff --git a/go.sum b/go.sum index 661bcd3d..e59edfab 100644 --- a/go.sum +++ b/go.sum @@ -370,8 +370,8 @@ github.com/spiffe/go-spiffe/v2 v2.7.0 h1:uXe1MflJoHw58wAUvxVlcM7WpKtijWG7I1UidcG github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= github.com/stackql-labs/omnisdk v0.1.4-alpha05 h1:Z22XGi99mjKPJEAAYv3qkW1J0iqqFRd9OANK+9cw4Xs= github.com/stackql-labs/omnisdk v0.1.4-alpha05/go.mod h1:2P2vFi+c/wEeQ3kFgIt0QuIlefpuzIoTm6bPJAiLAvQ= -github.com/stackql/any-sdk v0.6.0-alpha01 h1:mqy0bmZ1wghr7mUgLyaexoRU6rsUZCWXU9Mf60fC8dg= -github.com/stackql/any-sdk v0.6.0-alpha01/go.mod h1:ahkRgwHHEn7RTmSfKrshPVk6i4X1BdHknSeBflSyRac= +github.com/stackql/any-sdk v0.6.1-alpha02 h1:kXnL1SOORkwh47foKVX4GGDmD0gdOx2K4JGgvz+sxcw= +github.com/stackql/any-sdk v0.6.1-alpha02/go.mod h1:ahkRgwHHEn7RTmSfKrshPVk6i4X1BdHknSeBflSyRac= github.com/stackql/go-suffix-map v0.0.1-alpha01 h1:TDUDS8bySu41Oo9p0eniUeCm43mnRM6zFEd6j6VUaz8= github.com/stackql/go-suffix-map v0.0.1-alpha01/go.mod h1:QAi+SKukOyf4dBtWy8UMy+hsXXV+yyEE4vmBkji2V7g= github.com/stackql/psql-wire v0.1.3-beta01 h1:xDpR3e09xt74GBSkxYKIhfTMPnKY/Utgu2rl1ctEuF8= diff --git a/internal/stackql/dependencyplanner/dependencyplanner.go b/internal/stackql/dependencyplanner/dependencyplanner.go index 1b80ffc1..ed64a4a9 100644 --- a/internal/stackql/dependencyplanner/dependencyplanner.go +++ b/internal/stackql/dependencyplanner/dependencyplanner.go @@ -857,10 +857,22 @@ func (dp *standardDependencyPlanner) getStreamFromEdge( if err != nil { return nil, err } + projection = sourceResponseProjection(projection, e.GetSource().GetAnnotation().GetSchema().Tabulate(false, "")) + method, err := toAc.GetTableMeta().GetMethod() + if err != nil { + return nil, err + } + requestParameters := method.GetParametersIncludingNativeCasing() + projection = destinationRequestProjection(projection, requestParameters) incomingCols := make(map[string]struct{}) for _, v := range projection { incomingCols[v] = struct{}{} } + for alias, parameter := range requestParameters { + if _, dynamic := incomingCols[parameter.GetName()]; dynamic { + incomingCols[alias] = struct{}{} + } + } params := toAc.GetParameters() staticParams := make(map[string]interface{}) for k, v := range params { @@ -879,6 +891,35 @@ func (dp *standardDependencyPlanner) getStreamFromEdge( return streaming.NewSimpleProjectionMapStream(projection, staticParams), nil } +func sourceResponseProjection(projection map[string]string, tab formulation.Tabulation) map[string]string { + wireNames := make(map[string]string) + for _, column := range tab.GetColumns() { + wireNames[column.GetName()] = column.GetWireName() + } + translated := make(map[string]string, len(projection)) + for source, destination := range projection { + if wireName := wireNames[source]; wireName != "" { + source = wireName + } + translated[source] = destination + } + return translated +} + +func destinationRequestProjection( + projection map[string]string, + parameters map[string]formulation.Addressable, +) map[string]string { + translated := make(map[string]string, len(projection)) + for source, destination := range projection { + if parameter, ok := parameters[destination]; ok { + destination = parameter.GetName() + } + translated[source] = destination + } + return translated +} + // This is very naive but safe because there is only ever one source table. func (dp *standardDependencyPlanner) harvestFilter(sourceAnnotation taxonomy.AnnotationCtx) (string, bool) { var valz []string diff --git a/internal/stackql/dependencyplanner/dependencyplanner_test.go b/internal/stackql/dependencyplanner/dependencyplanner_test.go new file mode 100644 index 00000000..4039c603 --- /dev/null +++ b/internal/stackql/dependencyplanner/dependencyplanner_test.go @@ -0,0 +1,86 @@ +package dependencyplanner //nolint:testpackage // tests internal projection mapping + +import ( + "io" + "reflect" + "testing" + + "github.com/stackql/any-sdk/pkg/streaming" + "github.com/stackql/any-sdk/public/formulation" +) + +type projectionColumn struct { + formulation.ColumnDescriptor + name string + wireName string +} + +func (column projectionColumn) GetName() string { return column.name } +func (column projectionColumn) GetWireName() string { return column.wireName } + +type projectionTabulation struct { + formulation.Tabulation + columns []formulation.ColumnDescriptor +} + +func (tab projectionTabulation) GetColumns() []formulation.ColumnDescriptor { return tab.columns } + +type projectionParameter struct { + formulation.Addressable + name string +} + +func (parameter projectionParameter) GetName() string { return parameter.name } + +func TestDestinationRequestProjection(t *testing.T) { + projection := map[string]string{"AccessKeyId": "access_key_id", "other": "unchanged"} + parameters := map[string]formulation.Addressable{ + "access_key_id": projectionParameter{name: "AccessKeyId"}, + } + translated := destinationRequestProjection(projection, parameters) + want := map[string]string{"AccessKeyId": "AccessKeyId", "other": "unchanged"} + if !reflect.DeepEqual(translated, want) { + t.Fatalf("projection = %v, want %v", translated, want) + } + if projection["AccessKeyId"] != "access_key_id" { + t.Fatal("source projection was mutated") + } +} + +func TestSourceResponseProjection(t *testing.T) { + projection := map[string]string{ + "access_key_id": "access_key_id", + "unchanged": "destination", + "unknown": "unknown_destination", + "empty_wire": "empty_destination", + } + tab := projectionTabulation{columns: []formulation.ColumnDescriptor{ + projectionColumn{name: "access_key_id", wireName: "AccessKeyId"}, + projectionColumn{name: "unchanged", wireName: "unchanged"}, + projectionColumn{name: "empty_wire"}, + }} + translated := sourceResponseProjection(projection, tab) + want := map[string]string{ + "AccessKeyId": "access_key_id", "unchanged": "destination", + "unknown": "unknown_destination", "empty_wire": "empty_destination", + } + if !reflect.DeepEqual(translated, want) { + t.Fatalf("projection = %v, want %v", translated, want) + } + if projection["access_key_id"] != "access_key_id" { + t.Fatal("source projection was mutated") + } + stream := streaming.NewSimpleProjectionMapStream(translated, nil) + if err := stream.Write([]map[string]interface{}{{ + "AccessKeyId": "test-key", "unchanged": "value", "unknown": "other", "empty_wire": "empty", + }}); err != nil { + t.Fatal(err) + } + rows, err := stream.Read() + if err != io.EOF { + t.Fatalf("stream read: %v", err) + } + if len(rows) != 1 || rows[0]["access_key_id"] != "test-key" { + t.Fatalf("projected rows = %v", rows) + } +} diff --git a/test/python/stackql_test_tooling/flask/native_test/app.py b/test/python/stackql_test_tooling/flask/native_test/app.py index 7a5649eb..70465406 100644 --- a/test/python/stackql_test_tooling/flask/native_test/app.py +++ b/test/python/stackql_test_tooling/flask/native_test/app.py @@ -132,6 +132,10 @@ def casing_echo_strict_get(): } ) + @app.get("/casing/echo_filter") + def casing_echo_filter_get(): + return jsonify({"echoed_query": request.query_string.decode("utf-8")}) + # ---- OCI request signing (oci_signing_v1) target ----------------------- @app.get("/oci/buckets") diff --git a/test/registry/src/stackql_native_test/v0.1.0/services/casing.yaml b/test/registry/src/stackql_native_test/v0.1.0/services/casing.yaml index 01be57bd..3dac4b35 100644 --- a/test/registry/src/stackql_native_test/v0.1.0/services/casing.yaml +++ b/test/registry/src/stackql_native_test/v0.1.0/services/casing.yaml @@ -62,6 +62,11 @@ components: type: string echoed_query: type: string + echo_query_only_response: + type: object + properties: + echoed_query: + type: string bodyless_echo_response: type: object properties: @@ -153,6 +158,52 @@ components: insert: [] delete: [] update: [] + echo_request_only: + id: stackql_native_test.casing.echo_request_only + name: echo_request_only + title: Echo with a required parameter absent from the response schema + methods: + select: + operation: + $ref: '#/paths/~1casing~1echo_strict/get' + request: + nativeCasing: pascal + response: + mediaType: application/json + openAPIDocKey: '200' + overrideMediaType: application/json + objectKey: $ + schema_override: + $ref: '#/components/schemas/echo_query_only_response' + sqlVerbs: + select: + - $ref: '#/components/x-stackQL-resources/echo_request_only/methods/select' + insert: [] + delete: [] + update: [] + echo_filter: + id: stackql_native_test.casing.echo_filter + name: echo_filter + title: Echo a single-word native-cased required parameter + methods: + select: + operation: + $ref: '#/paths/~1casing~1echo_filter/get' + request: + nativeCasing: pascal + response: + mediaType: application/json + openAPIDocKey: '200' + overrideMediaType: application/json + objectKey: $ + schema_override: + $ref: '#/components/schemas/echo_query_only_response' + sqlVerbs: + select: + - $ref: '#/components/x-stackQL-resources/echo_filter/methods/select' + insert: [] + delete: [] + update: [] echo_header: id: stackql_native_test.casing.echo_header name: echo_header @@ -286,6 +337,27 @@ paths: servers: - url: http://localhost:1070 summary: Echo received query parameters, VpcId required + /casing/echo_filter: + get: + operationId: casing/echo_filter/get + parameters: + - in: query + name: Filter + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + echoed_query: + type: string + servers: + - url: http://localhost:1070 /casing/echo_header: get: operationId: casing/echo_header/get diff --git a/test/robot/functional/stackql_casing.robot b/test/robot/functional/stackql_casing.robot index e7338880..6bb15641 100644 --- a/test/robot/functional/stackql_casing.robot +++ b/test/robot/functional/stackql_casing.robot @@ -15,6 +15,42 @@ Documentation Functional coverage for the any-sdk casing engine, exercised t ... backward-compatibility block. *** Test Cases *** +Snake Case Join Source Projects Native Response Field + Should StackQL Exec Inline Contain + ... ${STACKQL_EXE} + ... ${OKTA_SECRET_STR} + ... ${GITHUB_SECRET_STR} + ... ${K8S_SECRET_STR} + ... ${REGISTRY_NO_VERIFY_CFG_STR} + ... ${AUTH_CFG_STR} + ... ${SQL_BACKEND_CFG_STR_CANONICAL} + ... select target.echoed_query from stackql_native_test.casing.echo source inner join stackql_native_test.casing.echo_strict target on target.vpc_id \= source.vpc_id where source.vpc_id \= 'vpc-join'; + ... VpcId\=vpc-join + +Snake Case Join Retains Request Only Destination Column + Should StackQL Exec Inline Contain + ... ${STACKQL_EXE} + ... ${OKTA_SECRET_STR} + ... ${GITHUB_SECRET_STR} + ... ${K8S_SECRET_STR} + ... ${REGISTRY_NO_VERIFY_CFG_STR} + ... ${AUTH_CFG_STR} + ... ${SQL_BACKEND_CFG_STR_CANONICAL} + ... select target.vpc_id, target.echoed_query from stackql_native_test.casing.echo source inner join stackql_native_test.casing.echo_request_only target on target.vpc_id \= source.vpc_id where source.vpc_id \= 'vpc-request-only'; + ... VpcId\=vpc-request-only + +Single Word Native Parameter Has One SQL Column + Should StackQL Exec Inline Contain + ... ${STACKQL_EXE} + ... ${OKTA_SECRET_STR} + ... ${GITHUB_SECRET_STR} + ... ${K8S_SECRET_STR} + ... ${REGISTRY_NO_VERIFY_CFG_STR} + ... ${AUTH_CFG_STR} + ... ${SQL_BACKEND_CFG_STR_CANONICAL} + ... select echoed_query from stackql_native_test.casing.echo_filter where filter \= 'SERVICE_CONTROL_POLICY'; + ... Filter\=SERVICE_CONTROL_POLICY + Snake Case Aliases Multi Word Response Column Projects Value [Documentation] Wire field VpcId is exposed as snake_case column vpc_id and its VALUE ... (echoed back by the mock) now projects via GetWireName (issue 108) instead of NULL.