Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ require (
github.com/viant/parsly v0.3.3
github.com/viant/pgo v0.11.0
github.com/viant/scy v0.33.1
github.com/viant/sqlx v0.23.1-0.20260721202550-583cf232e734
github.com/viant/sqlx v0.23.1-0.20260729142839-24da78934871
github.com/viant/structql v0.5.4
github.com/viant/toolbox v0.37.0
github.com/viant/velty v0.4.1-0.20260408224432-5a1c31e1bd87
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -1198,6 +1198,8 @@ github.com/viant/sqlparser v0.12.1-0.20260409013525-147f8fc299b7 h1:2FdVturjHBSQ
github.com/viant/sqlparser v0.12.1-0.20260409013525-147f8fc299b7/go.mod h1:2QRGiGZYk2/pjhORGG1zLVQ9JO+bXFhqIVi31mkCRPg=
github.com/viant/sqlx v0.23.1-0.20260721202550-583cf232e734 h1:vZF9F8r3lUSfdRBMZyWje0eabeI0Q5sMwbd0QF3pq8c=
github.com/viant/sqlx v0.23.1-0.20260721202550-583cf232e734/go.mod h1:dizufL+nTNqDCpivUnE2HqtddTp2TdA6WFghGfZo11c=
github.com/viant/sqlx v0.23.1-0.20260729142839-24da78934871 h1:9RqxSYtQfUGiMoT9YNpBfPZVjtorUrJ3uQISvS43EKI=
github.com/viant/sqlx v0.23.1-0.20260729142839-24da78934871/go.mod h1:dizufL+nTNqDCpivUnE2HqtddTp2TdA6WFghGfZo11c=
github.com/viant/structology v0.9.0 h1:ibR/XmdQ3+/4XW3JK+pXRqugSnxJOm2bmIvbQ0hqztY=
github.com/viant/structology v0.9.0/go.mod h1:AAFeViwniqua61sTKdOz/zlbLpN5vE4OVhDoiZJaMgA=
github.com/viant/structql v0.5.4 h1:bMdcOpzU8UMoe5OBcyJVRxLAndvU1oj3ysvPUgBckCI=
Expand Down
60 changes: 59 additions & 1 deletion repository/locator/component/component.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import (
"net/http"
"net/url"
"reflect"
"strings"

"github.com/viant/datly/repository/contract"
"github.com/viant/datly/service/executor/uow"
"github.com/viant/datly/shared"
"github.com/viant/datly/view"
"github.com/viant/datly/view/state"
"github.com/viant/datly/view/state/kind"
"github.com/viant/datly/view/state/kind/locator"
Expand Down Expand Up @@ -48,11 +50,12 @@ func (l *componentLocator) Value(ctx context.Context, _ reflect.Type, name strin
if err != nil {
return nil, false, err
}
request = sanitizeSelectorRequest(request)
form := l.form
value, err := l.dispatch.Dispatch(ctx, &contract.Path{Method: method, URI: URI}, contract.WithRequest(request),
contract.WithConstants(l.constants),
contract.WithPath(l.path),
contract.WithQuery(l.query),
contract.WithQuery(sanitizeSelectorQuery(l.query)),
contract.WithForm(form),
contract.WithLogger(l.logger),
contract.WithHeader(l.header),
Expand All @@ -61,6 +64,61 @@ func (l *componentLocator) Value(ctx context.Context, _ reflect.Type, name strin
return value, err == nil, err
}

func sanitizeSelectorQuery(query url.Values) url.Values {
sanitized, _ := sanitizeSelectorQueryWithRemoval(query)
return sanitized
}

func sanitizeSelectorQueryWithRemoval(query url.Values) (url.Values, bool) {
if len(query) == 0 {
return query, false
}
removed := false
result := make(url.Values, len(query))
for key, values := range query {
if isSelectorQueryKey(key) {
removed = true
continue
}
result[key] = append([]string(nil), values...)
}
if !removed {
return query, false
}
return result, true
}

func sanitizeSelectorRequest(request *http.Request) *http.Request {
if request == nil || request.URL == nil || request.URL.RawQuery == "" {
return request
}
sanitized, removed := sanitizeSelectorQueryWithRemoval(request.URL.Query())
if !removed {
return request
}
cloned := request.Clone(request.Context())
cloned.URL = cloneURL(request.URL)
cloned.URL.RawQuery = sanitized.Encode()
return cloned
}

func cloneURL(src *url.URL) *url.URL {
if src == nil {
return nil
}
cloned := *src
return &cloned
}

func isSelectorQueryKey(name string) bool {
switch strings.ToLower(strings.TrimSpace(name)) {
case view.FieldsQuery, view.OrderByQuery, view.LimitQuery, view.OffsetQuery, view.PageQuery, view.CriteriaQuery:
return true
default:
return false
}
}

func updateErrWithResponseStatus(err error, response interface{}) error {
var statusErr error
responseStatus, ok := tryExtractResponseStatus(response)
Expand Down
127 changes: 127 additions & 0 deletions repository/locator/component/component_uow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"net/http"
"net/url"
"reflect"
"testing"

Expand Down Expand Up @@ -39,6 +40,18 @@ func (d *componentScopeDispatcher) Dispatch(ctx context.Context, path *contract.
return struct{}{}, nil
}

type componentQueryDispatcher struct {
query url.Values
request *http.Request
}

func (d *componentQueryDispatcher) Dispatch(_ context.Context, _ *contract.Path, opts ...contract.Option) (interface{}, error) {
options := contract.NewOptions(opts...)
d.query = options.Query
d.request = options.Request
return struct{}{}, nil
}

func TestComponentLocatorCreatesOrderedBindingFrames(t *testing.T) {
db, _ := sql.Open("sqlite3", ":memory:")
defer db.Close()
Expand Down Expand Up @@ -79,6 +92,120 @@ func TestComponentLocatorCreatesOrderedBindingFrames(t *testing.T) {
}
}

func TestComponentLocatorDropsSelectorQueryParamsForChildDispatch(t *testing.T) {
dispatcher := &componentQueryDispatcher{}
request, _ := http.NewRequest(http.MethodGet, "/?_fields=AudienceId&_orderby=AudienceId&_limit=10&_offset=5&_page=2&_criteria=AudienceId+%3D+1&criteria=business+criteria&audience_id=123&order_id=456&from=2026-07-01&to=2026-07-02", nil)
query := url.Values{
"_fields": {"AudienceId"},
"_orderby": {"AudienceId"},
"_limit": {"10"},
"_offset": {"5"},
"_page": {"2"},
"_criteria": {"AudienceId = 1"},
"criteria": {"business criteria"},
"audience_id": {"123"},
"order_id": {"456"},
"from": {"2026-07-01"},
"to": {"2026-07-02"},
}
componentLocator := &componentLocator{
dispatch: dispatcher,
query: query,
getRequest: func() (*http.Request, error) {
return request, nil
},
}

_, found, err := componentLocator.Value(context.Background(), reflect.TypeOf(""), "GET:/child")
if err != nil || !found {
t.Fatalf("Value() found=%v err=%v", found, err)
}

for _, key := range []string{"_fields", "_orderby", "_limit", "_offset", "_page", "_criteria"} {
if _, ok := dispatcher.query[key]; ok {
t.Fatalf("selector query key %q was forwarded: %v", key, dispatcher.query)
}
}
for key, want := range map[string]string{
"criteria": "business criteria",
"audience_id": "123",
"order_id": "456",
"from": "2026-07-01",
"to": "2026-07-02",
} {
if got := dispatcher.query.Get(key); got != want {
t.Fatalf("query[%s]=%q want %q; query=%v", key, got, want, dispatcher.query)
}
}
if dispatcher.request == nil || dispatcher.request.URL == nil {
t.Fatal("expected forwarded request")
}
requestQuery := dispatcher.request.URL.Query()
for _, key := range []string{"_fields", "_orderby", "_limit", "_offset", "_page", "_criteria"} {
if _, ok := requestQuery[key]; ok {
t.Fatalf("selector query key %q was forwarded on request URL: %s", key, dispatcher.request.URL.RawQuery)
}
}
for key, want := range map[string]string{
"criteria": "business criteria",
"audience_id": "123",
"order_id": "456",
"from": "2026-07-01",
"to": "2026-07-02",
} {
if got := requestQuery.Get(key); got != want {
t.Fatalf("request query[%s]=%q want %q; raw=%s", key, got, want, dispatcher.request.URL.RawQuery)
}
}
if request.URL.Query().Get("_fields") != "AudienceId" {
t.Fatal("original parent request was mutated")
}
}

func TestSanitizeSelectorQueryClonesForwardedValues(t *testing.T) {
query := url.Values{"_fields": {"AudienceId"}, "order_id": {"456"}}
sanitized := sanitizeSelectorQuery(query)

query.Set("order_id", "mutated")

if got, want := sanitized.Get("order_id"), "456"; got != want {
t.Fatalf("sanitized query was not cloned, got %q want %q", got, want)
}
}

func TestSanitizeSelectorRequestClonesForwardedRequest(t *testing.T) {
request, _ := http.NewRequest(http.MethodGet, "/?_fields=AudienceId&order_id=456", nil)

sanitized := sanitizeSelectorRequest(request)

if sanitized == request {
t.Fatal("expected sanitized request clone")
}
if got := sanitized.URL.Query().Get("_fields"); got != "" {
t.Fatalf("sanitized request still has _fields=%q", got)
}
if got, want := sanitized.URL.Query().Get("order_id"), "456"; got != want {
t.Fatalf("sanitized request order_id=%q want %q", got, want)
}
if got, want := request.URL.Query().Get("_fields"), "AudienceId"; got != want {
t.Fatalf("original request was mutated, _fields=%q want %q", got, want)
}
}

func TestSanitizeSelectorRequestDoesNotReencodeWhenNoSelectorParams(t *testing.T) {
request, _ := http.NewRequest(http.MethodGet, "/?b=two%20words&a=1", nil)
originalRawQuery := request.URL.RawQuery

sanitized := sanitizeSelectorRequest(request)

if sanitized != request {
t.Fatal("expected original request when no selector params are present")
}
if sanitized.URL.RawQuery != originalRawQuery {
t.Fatalf("raw query changed, got %q want %q", sanitized.URL.RawQuery, originalRawQuery)
}
}

func TestComponentLocatorRequiresInvocationDispatcher(t *testing.T) {
if _, err := newComponentLocator(locator.WithConstants(nil)); err == nil {
t.Fatal("expected missing dispatcher error")
Expand Down
16 changes: 16 additions & 0 deletions service.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,22 @@ func WithOutput(output interface{}) OperateOption {
}
}

func ContextWithOutputProjection(ctx context.Context, output interface{}) context.Context {
return session.ContextWithOutputProjection(ctx, output)
}

func ContextWithViewOutputProjection(ctx context.Context, viewName string, output interface{}) context.Context {
return session.ContextWithViewOutputProjection(ctx, viewName, output)
}

func ContextWithOutputFields(ctx context.Context, fields ...string) context.Context {
return session.ContextWithOutputFields(ctx, fields...)
}

func ContextWithViewOutputFields(ctx context.Context, viewName string, fields ...string) context.Context {
return session.ContextWithViewOutputFields(ctx, viewName, fields...)
}

func WithSession(session *session.Session) OperateOption {
return func(o *operateOptions) {
o.session = session
Expand Down
3 changes: 3 additions & 0 deletions service/reader/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ func (h *Handler) readData(ctx context.Context, aView *view.View, aState *sessio
return err
}
}
if err = aState.ApplyOutputProjection(ctx, aView); err != nil {
return err
}
if err = aState.Populate(ctx); err != nil {
return err
}
Expand Down
77 changes: 76 additions & 1 deletion service/reader/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -630,8 +630,83 @@ func (s *Service) warmupMatcher(ctx context.Context, aView *view.View, statelet
}
cloned := *statelet
cloned.Template = clonedTemplate
ok, err := applyWarmupIdentityProjection(aView, &cloned)
if err != nil {
return nil, err
}
if !ok {
return nil, nil
}

matcher, err := s.sqlBuilder.CacheSQLWithOptions(ctx, aView, &cloned, nil, nil, parent)
if err != nil || matcher == nil {
return matcher, err
}
if err = applyRequestedFields(aView, statelet, matcher); err != nil {
fmt.Printf("[INFO] datly warmup projection metadata error view=%s fields=%v error=%v\n", aView.Name, requestedFieldNames(statelet), err)
return nil, nil
}
return matcher, nil
}

return s.sqlBuilder.CacheSQLWithOptions(ctx, aView, &cloned, nil, nil, parent)
func applyWarmupIdentityProjection(aView *view.View, statelet *view.Statelet) (bool, error) {
if aView == nil || aView.Cache == nil || aView.Cache.Warmup == nil || statelet == nil {
return true, nil
}
fieldNames, ok := aView.Cache.WarmupFieldNamesForSelector(statelet)
if !ok {
return false, nil
}
if len(fieldNames) == 0 {
statelet.SetColumns(nil)
statelet.Fields = nil
return true, nil
}
columns, err := view.ProjectionColumnsForNames(aView, fieldNames)
if err != nil {
return false, err
}
fields := make([]string, 0, len(columns))
for _, columnName := range columns {
column, ok := aView.ColumnByName(columnName)
if !ok {
return false, fmt.Errorf("failed to map warmup identity column %s to view %s column", columnName, aView.Name)
}
fieldName := column.FieldName()
if fieldName == "" {
fieldName = column.Name
}
fields = append(fields, fieldName)
}
statelet.SetColumns(columns)
statelet.Fields = fields
return true, nil
}

func applyRequestedFields(aView *view.View, statelet *view.Statelet, matcher *cache.ParmetrizedQuery) error {
if aView == nil || statelet == nil || matcher == nil {
return nil
}
names := statelet.Columns
if len(names) == 0 {
names = statelet.Fields
}
fields, err := view.ProjectionFieldsForNames(aView, names)
if err != nil {
return err
}
matcher.RequestedFields = view.SQLXProjectionFields(fields)
return nil
}

func requestedFieldNames(statelet *view.Statelet) []string {
if statelet == nil {
return nil
}
if len(statelet.Columns) != 0 {
return statelet.Columns
}
return statelet.Fields
}

func warmupIndexParameter(aView *view.View) *state.Parameter {
Expand Down
Loading
Loading