diff --git a/compiler/kernel/expr.go b/compiler/kernel/expr.go index 80d5dae6d4..6c689dfea9 100644 --- a/compiler/kernel/expr.go +++ b/compiler/kernel/expr.go @@ -263,15 +263,41 @@ func (b *Builder) compileDotExpr(dot *dag.Dot) (expr.Evaluator, error) { return expr.NewDotExpr(b.zctx(), record, dot.RHS), nil } -func compileLval(e dag.Expr) (field.Path, error) { - if this, ok := e.(*dag.This); ok { - return field.Path(this.Path), nil +func (b *Builder) compileLval(e dag.Expr) (*expr.Lval, error) { + switch e := e.(type) { + case *dag.BinaryExpr: + if e.Op != "[" { + return nil, fmt.Errorf("internal error: invalid lval %#v", e) + } + lhs, err := b.compileLval(e.LHS) + if err != nil { + return nil, err + } + rhs, err := b.compileExpr(e.RHS) + if err != nil { + return nil, err + } + lhs.Elems = append(lhs.Elems, expr.NewExprLvalElem(b.zctx(), rhs)) + return lhs, nil + case *dag.Dot: + lhs, err := b.compileLval(e.LHS) + if err != nil { + return nil, err + } + lhs.Elems = append(lhs.Elems, &expr.StaticLvalElem{Name: e.RHS}) + return lhs, nil + case *dag.This: + var elems []expr.LvalElem + for _, elem := range e.Path { + elems = append(elems, &expr.StaticLvalElem{Name: elem}) + } + return expr.NewLval(elems), nil } - return nil, errors.New("invalid expression on lhs of assignment") + return nil, fmt.Errorf("internal error: invalid lval %#v", e) } func (b *Builder) compileAssignment(node *dag.Assignment) (expr.Assignment, error) { - lhs, err := compileLval(node.LHS) + lhs, err := b.compileLval(node.LHS) if err != nil { return expr.Assignment{}, err } diff --git a/compiler/kernel/groupby.go b/compiler/kernel/groupby.go index d0ab780abb..4604266dd1 100644 --- a/compiler/kernel/groupby.go +++ b/compiler/kernel/groupby.go @@ -44,12 +44,12 @@ func (b *Builder) compileAggAssignment(assignment dag.Assignment) (field.Path, * if !ok { return nil, nil, errors.New("aggregator is not an aggregation expression") } - lhs, err := compileLval(assignment.LHS) - if err != nil { - return nil, nil, fmt.Errorf("lhs of aggregation: %w", err) + this, ok := assignment.LHS.(*dag.This) + if !ok { + return nil, nil, fmt.Errorf("internal error: aggregator assignment LHS is not a static path: %#v", assignment.LHS) } m, err := b.compileAgg(aggAST) - return lhs, m, err + return this.Path, m, err } func (b *Builder) compileAgg(agg *dag.Agg) (*expr.Aggregator, error) { diff --git a/compiler/kernel/op.go b/compiler/kernel/op.go index e51e960209..ba81ef9969 100644 --- a/compiler/kernel/op.go +++ b/compiler/kernel/op.go @@ -109,10 +109,7 @@ func (b *Builder) compileLeaf(o dag.Op, parent zbuf.Puller) (zbuf.Puller, error) return nil, err } lhs, rhs := splitAssignments(assignments) - cutter, err := expr.NewCutter(b.octx.Zctx, lhs, rhs) - if err != nil { - return nil, err - } + cutter := expr.NewCutter(b.octx.Zctx, lhs, rhs) if v.Quiet { cutter.Quiet() } @@ -174,36 +171,13 @@ func (b *Builder) compileLeaf(o dag.Op, parent zbuf.Puller) (zbuf.Puller, error) if err != nil { return nil, err } - putter, err := expr.NewPutter(b.octx.Zctx, clauses) - if err != nil { - return nil, err - } + putter := expr.NewPutter(b.octx.Zctx, clauses) return op.NewApplier(b.octx, parent, putter), nil case *dag.Rename: var srcs, dsts field.List - for _, fa := range v.Args { - dst, err := compileLval(fa.LHS) - if err != nil { - return nil, err - } - // We call CompileLval on the RHS because renames are - // restricted to dotted field name expressions. - src, err := compileLval(fa.RHS) - if err != nil { - return nil, err - } - if len(dst) != len(src) { - return nil, fmt.Errorf("cannot rename %s to %s", src, dst) - } - // Check that the prefixes match and, if not, report first place - // that they don't. - for i := 0; i <= len(src)-2; i++ { - if src[i] != dst[i] { - return nil, fmt.Errorf("cannot rename %s to %s (differ in %s vs %s)", src, dst, src[i], dst[i]) - } - } - dsts = append(dsts, dst) - srcs = append(srcs, src) + for _, a := range v.Args { + dsts = append(dsts, a.LHS.(*dag.This).Path) + srcs = append(srcs, a.RHS.(*dag.This).Path) } renamer := expr.NewRenamer(b.octx.Zctx, srcs, dsts) return op.NewApplier(b.octx, parent, renamer), nil @@ -388,9 +362,9 @@ func (b *Builder) compileAssignments(assignments []dag.Assignment) ([]expr.Assig return keys, nil } -func splitAssignments(assignments []expr.Assignment) (field.List, []expr.Evaluator) { +func splitAssignments(assignments []expr.Assignment) ([]*expr.Lval, []expr.Evaluator) { n := len(assignments) - lhs := make(field.List, 0, n) + lhs := make([]*expr.Lval, 0, n) rhs := make([]expr.Evaluator, 0, n) for _, a := range assignments { lhs = append(lhs, a.LHS) diff --git a/compiler/semantic/expr.go b/compiler/semantic/expr.go index 68c21341eb..1e5245859b 100644 --- a/compiler/semantic/expr.go +++ b/compiler/semantic/expr.go @@ -507,10 +507,10 @@ func (a *analyzer) semExprs(in []ast.Expr) ([]dag.Expr, error) { return exprs, nil } -func (a *analyzer) semAssignments(assignments []ast.Assignment, summarize bool) ([]dag.Assignment, error) { +func (a *analyzer) semAssignments(assignments []ast.Assignment) ([]dag.Assignment, error) { out := make([]dag.Assignment, 0, len(assignments)) for _, e := range assignments { - a, err := a.semAssignment(e, summarize) + a, err := a.semAssignment(e) if err != nil { return nil, err } @@ -519,64 +519,69 @@ func (a *analyzer) semAssignments(assignments []ast.Assignment, summarize bool) return out, nil } -func (a *analyzer) semAssignment(assign ast.Assignment, summarize bool) (dag.Assignment, error) { +func (a *analyzer) semAssignment(assign ast.Assignment) (dag.Assignment, error) { rhs, err := a.semExpr(assign.RHS) if err != nil { - return dag.Assignment{}, fmt.Errorf("rhs of assignment expression: %w", err) - } - if _, ok := rhs.(*dag.Agg); ok { - summarize = true + return dag.Assignment{}, fmt.Errorf("right-hand side of assignment: %w", err) } var lhs dag.Expr - if assign.LHS != nil { - lhs, err = a.semExpr(assign.LHS) + if assign.LHS == nil { + path, err := deriveLHSPath(rhs) if err != nil { - return dag.Assignment{}, fmt.Errorf("lhs of assigment expression: %w", err) + return dag.Assignment{}, err } - } else if call, ok := assign.RHS.(*ast.Call); ok { - path := []string{call.Name} - switch call.Name { + lhs = &dag.This{Kind: "This", Path: path} + } else if lhs, err = a.semExpr(assign.LHS); err != nil { + return dag.Assignment{}, fmt.Errorf("left-hand side of assignment: %w", err) + } + if !isLval(lhs) { + return dag.Assignment{}, errors.New("illegal left-hand side of assignment") + } + if this, ok := lhs.(*dag.This); ok && len(this.Path) == 0 { + return dag.Assignment{}, errors.New("cannot assign to 'this'") + } + return dag.Assignment{Kind: "Assignment", LHS: lhs, RHS: rhs}, nil +} + +func isLval(e dag.Expr) bool { + switch e := e.(type) { + case *dag.BinaryExpr: + return e.Op == "[" && isLval(e.LHS) + case *dag.Dot: + return isLval(e.LHS) + case *dag.This: + return true + } + return false +} + +func deriveLHSPath(rhs dag.Expr) ([]string, error) { + var path []string + switch rhs := rhs.(type) { + case *dag.Call: + path = []string{rhs.Name} + switch rhs.Name { case "every": // If LHS is nil and the call is every() make the LHS field ts since // field ts assumed with every. path = []string{"ts"} case "quiet": - if len(call.Args) > 0 { - if p, ok := rhs.(*dag.Call).Args[0].(*dag.This); ok { - path = p.Path + if len(rhs.Args) > 0 { + if this, ok := rhs.Args[0].(*dag.This); ok { + path = this.Path } } } - lhs = &dag.This{Kind: "This", Path: path} - } else if agg, ok := assign.RHS.(*ast.Agg); ok { - lhs = &dag.This{Kind: "This", Path: []string{agg.Name}} - } else if v, ok := rhs.(*dag.Var); ok { - lhs = &dag.This{Kind: "This", Path: []string{v.Name}} - } else { - lhs, err = a.semExpr(assign.RHS) - if err != nil { - return dag.Assignment{}, errors.New("assignment name could not be inferred from rhs expression") - } - } - if summarize { - // Summarize always outputs its results as new records of "this" - // so if we have an "as" that overrides "this", we just - // convert it back to a local this. - if dot, ok := lhs.(*dag.Dot); ok { - if v, ok := dot.LHS.(*dag.Var); ok && v.Name == "this" { - lhs = &dag.This{Kind: "This", Path: []string{dot.RHS}} - } - } - } - // Make sure we have a valid lval for lhs. - this, ok := lhs.(*dag.This) - if !ok { - return dag.Assignment{}, errors.New("illegal left-hand side of assignment") - } - if len(this.Path) == 0 { - return dag.Assignment{}, errors.New("cannot assign to 'this'") + case *dag.Agg: + path = []string{rhs.Name} + case *dag.Var: + path = []string{rhs.Name} + case *dag.This: + path = rhs.Path + default: + return nil, errors.New("cannot infer field from expression") } - return dag.Assignment{Kind: "Assignment", LHS: lhs, RHS: rhs}, nil + return path, nil } func (a *analyzer) semFields(exprs []ast.Expr) ([]dag.Expr, error) { diff --git a/compiler/semantic/op.go b/compiler/semantic/op.go index be1f5b658e..cc20581d92 100644 --- a/compiler/semantic/op.go +++ b/compiler/semantic/op.go @@ -15,6 +15,7 @@ import ( "github.com/brimdata/zed/pkg/field" "github.com/brimdata/zed/pkg/plural" "github.com/brimdata/zed/pkg/reglob" + "github.com/brimdata/zed/runtime/expr" "github.com/brimdata/zed/runtime/expr/function" "github.com/brimdata/zed/zson" "github.com/segmentio/ksuid" @@ -410,19 +411,25 @@ func (a *analyzer) semOp(o ast.Op, seq dag.Seq) (dag.Seq, error) { case *ast.From: return a.semFrom(o, seq) case *ast.Summarize: - keys, err := a.semAssignments(o.Keys, true) + keys, err := a.semAssignments(o.Keys) if err != nil { return nil, err } + if assignmentHasDynamicLHS(keys) { + return nil, errors.New("summarize: key output field must be static") + } if len(keys) == 0 && len(o.Aggs) == 1 { if seq := a.singletonAgg(o.Aggs[0], seq); seq != nil { return seq, nil } } - aggs, err := a.semAssignments(o.Aggs, true) + aggs, err := a.semAssignments(o.Aggs) if err != nil { return nil, err } + if assignmentHasDynamicLHS(aggs) { + return nil, errors.New("summarize: aggregate output field must be static") + } // Note: InputSortDir is copied in here but it's not meaningful // coming from a parser AST, only from a worker using the kernel DSL, // which is another reason why we need separate parser and kernel ASTs. @@ -497,10 +504,20 @@ func (a *analyzer) semOp(o ast.Op, seq dag.Seq) (dag.Seq, error) { case *ast.Shape: return append(seq, &dag.Shape{Kind: "Shape"}), nil case *ast.Cut: - assignments, err := a.semAssignments(o.Args, false) + assignments, err := a.semAssignments(o.Args) if err != nil { return nil, err } + // Collect static paths so we can check on what is available. + var fields field.List + for _, a := range assignments { + if this, ok := a.LHS.(*dag.This); ok { + fields = append(fields, this.Path) + } + } + if _, err = zed.NewRecordBuilder(a.zctx, fields); err != nil { + return nil, fmt.Errorf("cut: %w", err) + } return append(seq, &dag.Cut{ Kind: "Cut", Args: assignments, @@ -596,10 +613,20 @@ func (a *analyzer) semOp(o ast.Op, seq dag.Seq) (dag.Seq, error) { Limit: o.Limit, }), nil case *ast.Put: - assignments, err := a.semAssignments(o.Args, false) + assignments, err := a.semAssignments(o.Args) if err != nil { return nil, err } + // We can do collision checking on static paths, so check what we can. + var fields field.List + for _, a := range assignments { + if this, ok := a.LHS.(*dag.This); ok { + fields = append(fields, this.Path) + } + } + if err := expr.CheckPutFields(fields); err != nil { + return nil, fmt.Errorf("put: %w", err) + } return append(seq, &dag.Put{ Kind: "Put", Args: assignments, @@ -615,20 +642,20 @@ func (a *analyzer) semOp(o ast.Op, seq dag.Seq) (dag.Seq, error) { for _, fa := range o.Args { dst, err := a.semField(fa.LHS) if err != nil { - return nil, errors.New("'rename' requires explicit field references") + return nil, errors.New("rename: requires explicit field references") } src, err := a.semField(fa.RHS) if err != nil { - return nil, errors.New("'rename' requires explicit field references") + return nil, errors.New("rename: requires explicit field references") } if len(dst.Path) != len(src.Path) { - return nil, fmt.Errorf("cannot rename %s to %s", src, dst) + return nil, fmt.Errorf("rename: cannot rename %s to %s", src, dst) } // Check that the prefixes match and, if not, report first place // that they don't. for i := 0; i <= len(src.Path)-2; i++ { if src.Path[i] != dst.Path[i] { - return nil, fmt.Errorf("cannot rename %s to %s (differ in %s vs %s)", src, dst, src.Path[i], dst.Path[i]) + return nil, fmt.Errorf("rename: cannot rename %s to %s (differ in %s vs %s)", src, dst, src.Path[i], dst.Path[i]) } } assignments = append(assignments, dag.Assignment{Kind: "Assignment", LHS: dst, RHS: src}) @@ -652,7 +679,7 @@ func (a *analyzer) semOp(o ast.Op, seq dag.Seq) (dag.Seq, error) { if err != nil { return nil, err } - assignments, err := a.semAssignments(o.Args, false) + assignments, err := a.semAssignments(o.Args) if err != nil { return nil, err } @@ -763,14 +790,14 @@ func (a *analyzer) semOp(o ast.Op, seq dag.Seq) (dag.Seq, error) { Aggs: []dag.Assignment{ { Kind: "Assignment", - LHS: &dag.This{Kind: "This", Path: field.Path{"sample"}}, + LHS: pathOf("sample"), RHS: &dag.Agg{Kind: "Agg", Name: "any", Expr: e}, }, }, Keys: []dag.Assignment{ { Kind: "Assignment", - LHS: &dag.This{Kind: "This", Path: field.Path{"shape"}}, + LHS: pathOf("shape"), RHS: &dag.Call{Kind: "Call", Name: "typeof", Args: []dag.Expr{e}}, }, }, @@ -812,7 +839,7 @@ func (a *analyzer) singletonAgg(agg ast.Assignment, seq dag.Seq) dag.Seq { if agg.LHS != nil { return nil } - out, err := a.semAssignment(agg, true) + out, err := a.semAssignment(agg) if err != nil { return nil } @@ -940,16 +967,17 @@ func (a *analyzer) semOpAssignment(p *ast.OpAssignment) (dag.Op, error) { var aggs, puts []dag.Assignment for _, assign := range p.Assignments { // Parition assignments into agg vs. puts. - // It's okay to pass false here for the summarize bool because - // semAssignment will check if the RHS is a dag.Agg and override. - assignment, err := a.semAssignment(assign, false) + a, err := a.semAssignment(assign) if err != nil { return nil, err } - if _, ok := assignment.RHS.(*dag.Agg); ok { - aggs = append(aggs, assignment) + if _, ok := a.RHS.(*dag.Agg); ok { + if _, ok := a.LHS.(*dag.This); !ok { + return nil, errors.New("summarize: aggregate output field must be static") + } + aggs = append(aggs, a) } else { - puts = append(puts, assignment) + puts = append(puts, a) } } if len(puts) > 0 && len(aggs) > 0 { @@ -967,6 +995,15 @@ func (a *analyzer) semOpAssignment(p *ast.OpAssignment) (dag.Op, error) { }, nil } +func assignmentHasDynamicLHS(assignments []dag.Assignment) bool { + for _, a := range assignments { + if _, ok := a.LHS.(*dag.This); !ok { + return true + } + } + return false +} + func (a *analyzer) semOpExpr(e ast.Expr, seq dag.Seq) (dag.Seq, error) { if call, ok := e.(*ast.Call); ok { if seq, err := a.semCallOp(call, seq); seq != nil || err != nil { @@ -1031,7 +1068,7 @@ func (a *analyzer) semCallOp(call *ast.Call, seq dag.Seq) (dag.Seq, error) { Aggs: []dag.Assignment{ { Kind: "Assignment", - LHS: &dag.This{Kind: "This", Path: field.Path{call.Name}}, + LHS: pathOf(call.Name), RHS: agg, }, }, diff --git a/compiler/semantic/sql.go b/compiler/semantic/sql.go index 6c6f1d9f8b..3d75bae0c3 100644 --- a/compiler/semantic/sql.go +++ b/compiler/semantic/sql.go @@ -291,7 +291,7 @@ func (a *analyzer) convertSQLJoin(leftPath []dag.Op, sqlJoin ast.SQLJoin) ([]dag } alias := dag.Assignment{ Kind: "Assignment", - LHS: &dag.This{Kind: "This", Path: field.Path{aliasID}}, + LHS: pathOf(aliasID), RHS: &dag.This{Kind: "This", Path: field.Path{aliasID}}, } join := &dag.Join{ @@ -433,7 +433,7 @@ func (a *analyzer) newSQLSelection(assignments []ast.Assignment) (sqlSelection, if err != nil { return nil, err } - assignment, err := a.semAssignment(assign, false) + assignment, err := a.semAssignment(assign) if err != nil { return nil, err } @@ -515,12 +515,12 @@ func (a *analyzer) isAgg(e ast.Expr) (*dag.Agg, error) { } func (a *analyzer) deriveAs(as ast.Assignment) (field.Path, error) { - sa, err := a.semAssignment(as, false) + sa, err := a.semAssignment(as) if err != nil { return nil, fmt.Errorf("AS clause of SELECT: %w", err) } - if f, ok := sa.LHS.(*dag.This); ok { - return f.Path, nil + if this, ok := sa.LHS.(*dag.This); ok { + return this.Path, nil } return nil, fmt.Errorf("AS clause not a field: %w", err) } diff --git a/compiler/ztests/summarize-lhs-error.yaml b/compiler/ztests/summarize-lhs-error.yaml new file mode 100644 index 0000000000..1f9e865c1d --- /dev/null +++ b/compiler/ztests/summarize-lhs-error.yaml @@ -0,0 +1,11 @@ +script: | + ! zc -s 'count() by this[a] := key' + ! zc -s 'this[a] := count() by key' + ! zc -s 'this[a] := count()' + +outputs: + - name: stderr + data: | + summarize: key output field must be static + summarize: aggregate output field must be static + summarize: aggregate output field must be static diff --git a/compiler/ztests/where-on-func.yaml b/compiler/ztests/where-on-func.yaml index 3e6ca5dd70..21ae7245d6 100644 --- a/compiler/ztests/where-on-func.yaml +++ b/compiler/ztests/where-on-func.yaml @@ -1,3 +1,3 @@ zed: cut hex := hex(this) where this % 2 == 0 -errorRE: "rhs of assignment expression: 'where' clause on non-aggregation function: hex" +errorRE: "'where' clause on non-aggregation function: hex" diff --git a/docs/language/operators/rename.md b/docs/language/operators/rename.md index f224f84ab5..70be76e194 100644 --- a/docs/language/operators/rename.md +++ b/docs/language/operators/rename.md @@ -49,7 +49,7 @@ echo '{a:1,r:{b:2,c:3}}' | zq -z 'rename w:=r.b' - ``` => ```mdtest-output -cannot rename r.b to w +rename: cannot rename r.b to w ``` _Record literals can be used instead of rename for mutation_ ```mdtest-command diff --git a/docs/tutorials/schools.md b/docs/tutorials/schools.md index 1369a1cfb0..790e3edde8 100644 --- a/docs/tutorials/schools.md +++ b/docs/tutorials/schools.md @@ -843,7 +843,7 @@ zq -Z 'rename toplevel:=outer.inner' nested.zson ``` produces this compile-time error message and the query is not run: ```mdtest-output -cannot rename outer.inner to toplevel +rename: cannot rename outer.inner to toplevel ``` This goal could instead be achieved by combining [`put`](#44-put) and [`drop`](#42-drop), e.g., diff --git a/index/keys.go b/index/keys.go index d9c021fcd8..c1accb5e68 100644 --- a/index/keys.go +++ b/index/keys.go @@ -13,13 +13,9 @@ type Keyer struct { func NewKeyer(zctx *zed.Context, keys []field.Path) (*Keyer, error) { fields, resolvers := expr.NewAssignments(zctx, keys, keys) - cutter, err := expr.NewCutter(zctx, fields, resolvers) - if err != nil { - return nil, err - } return &Keyer{ keys: keys, - cutter: cutter, + cutter: expr.NewCutter(zctx, fields, resolvers), }, nil } diff --git a/runtime/expr/cutter.go b/runtime/expr/cutter.go index 1a8a10b6cd..18136ecdfa 100644 --- a/runtime/expr/cutter.go +++ b/runtime/expr/cutter.go @@ -2,6 +2,7 @@ package expr import ( "errors" + "fmt" "github.com/brimdata/zed" "github.com/brimdata/zed/pkg/field" @@ -9,14 +10,15 @@ import ( type Cutter struct { zctx *zed.Context - builder *zed.RecordBuilder fieldRefs field.List fieldExprs []Evaluator - typeCache []zed.Type + lvals []*Lval outTypes *zed.TypeVectorTable recordTypes map[int]*zed.TypeRecord + typeCache []zed.Type - droppers []*Dropper + builders map[string]*zed.RecordBuilder + droppers map[string]*Dropper dropperCache []*Dropper dirty bool quiet bool @@ -26,33 +28,20 @@ type Cutter struct { // the Cutter copies fields that are not in fieldnames. If complement // is false, the Cutter copies any fields in fieldnames, where targets // specifies the copied field names. -func NewCutter(zctx *zed.Context, fieldRefs field.List, fieldExprs []Evaluator) (*Cutter, error) { - for _, f := range fieldRefs { - if f.IsEmpty() { - return nil, errors.New("cut: 'this' not allowed (use record literal)") - } - } - var b *zed.RecordBuilder - if len(fieldRefs) == 0 || !fieldRefs[0].IsEmpty() { - // A root field will cause NewFieldBuilder to panic. - var err error - b, err = zed.NewRecordBuilder(zctx, fieldRefs) - if err != nil { - return nil, err - } - } +func NewCutter(zctx *zed.Context, fieldRefs []*Lval, fieldExprs []Evaluator) *Cutter { n := len(fieldRefs) return &Cutter{ zctx: zctx, - builder: b, - fieldRefs: fieldRefs, + builders: make(map[string]*zed.RecordBuilder), + fieldRefs: make(field.List, n), fieldExprs: fieldExprs, - typeCache: make([]zed.Type, len(fieldRefs)), + lvals: fieldRefs, outTypes: zed.NewTypeVectorTable(), recordTypes: make(map[int]*zed.TypeRecord), - droppers: make([]*Dropper, n), + typeCache: make([]zed.Type, n), + droppers: make(map[string]*Dropper), dropperCache: make([]*Dropper, n), - }, nil + } } func (c *Cutter) Quiet() { @@ -67,30 +56,36 @@ func (c *Cutter) FoundCut() bool { // receiver's configuration. If the resulting record would be empty, Apply // returns zed.Missing. func (c *Cutter) Eval(ectx Context, in *zed.Value) *zed.Value { + rb, paths, err := c.lookupBuilder(ectx, in) + if err != nil { + return ectx.CopyValue(*c.zctx.WrapError(fmt.Sprintf("cut: %s", err), in)) + } types := c.typeCache - b := c.builder - b.Reset() + rb.Reset() droppers := c.dropperCache[:0] for k, e := range c.fieldExprs { val := e.Eval(ectx, in) if val.IsQuiet() { // ignore this field - if c.droppers[k] == nil { - c.droppers[k] = NewDropper(c.zctx, c.fieldRefs[k:k+1]) + pathID := paths[k].String() + if c.droppers[pathID] == nil { + c.droppers[pathID] = NewDropper(c.zctx, field.List{paths[k]}) } - droppers = append(droppers, c.droppers[k]) - b.Append(val.Bytes()) + droppers = append(droppers, c.droppers[pathID]) + rb.Append(val.Bytes()) types[k] = zed.TypeNull continue } - b.Append(val.Bytes()) + rb.Append(val.Bytes()) types[k] = val.Type } - bytes, err := b.Encode() + // check paths + bytes, err := rb.Encode() if err != nil { panic(err) } - rec := ectx.NewValue(c.lookupTypeRecord(types), bytes) + typ := c.lookupTypeRecord(types, rb) + rec := ectx.NewValue(typ, bytes) for _, d := range droppers { rec = d.Eval(ectx, rec) } @@ -100,11 +95,34 @@ func (c *Cutter) Eval(ectx Context, in *zed.Value) *zed.Value { return rec } -func (c *Cutter) lookupTypeRecord(types []zed.Type) *zed.TypeRecord { +func (c *Cutter) lookupBuilder(ectx Context, in *zed.Value) (*zed.RecordBuilder, field.List, error) { + paths := c.fieldRefs[:0] + for _, p := range c.lvals { + path, err := p.Eval(ectx, in) + if err != nil { + return nil, nil, err + } + if path.IsEmpty() { + return nil, nil, errors.New("'this' not allowed (use record literal)") + } + paths = append(paths, path) + } + builder, ok := c.builders[paths.String()] + if !ok { + var err error + if builder, err = zed.NewRecordBuilder(c.zctx, paths); err != nil { + return nil, nil, err + } + c.builders[paths.String()] = builder + } + return builder, paths, nil +} + +func (c *Cutter) lookupTypeRecord(types []zed.Type, builder *zed.RecordBuilder) *zed.TypeRecord { id := c.outTypes.Lookup(types) typ, ok := c.recordTypes[id] if !ok { - typ = c.builder.Type(types) + typ = builder.Type(types) c.recordTypes[id] = typ } return typ diff --git a/runtime/expr/eval.go b/runtime/expr/eval.go index b62be5c94d..d635a2c94d 100644 --- a/runtime/expr/eval.go +++ b/runtime/expr/eval.go @@ -807,19 +807,23 @@ func (c *Call) Eval(ectx Context, this *zed.Value) *zed.Value { } type Assignment struct { - LHS field.Path + LHS *Lval RHS Evaluator } -func NewAssignments(zctx *zed.Context, dsts field.List, srcs field.List) (field.List, []Evaluator) { +func NewAssignments(zctx *zed.Context, dsts field.List, srcs field.List) ([]*Lval, []Evaluator) { if len(srcs) != len(dsts) { panic("NewAssignments: argument mismatch") } var resolvers []Evaluator - var fields field.List + var lvals []*Lval for k, dst := range dsts { - fields = append(fields, dst) + elems := make([]LvalElem, 0, len(dst)) + for _, d := range dst { + elems = append(elems, &StaticLvalElem{Name: d}) + } + lvals = append(lvals, NewLval(elems)) resolvers = append(resolvers, NewDottedExpr(zctx, srcs[k])) } - return fields, resolvers + return lvals, resolvers } diff --git a/runtime/expr/lval.go b/runtime/expr/lval.go new file mode 100644 index 0000000000..34e044152e --- /dev/null +++ b/runtime/expr/lval.go @@ -0,0 +1,91 @@ +package expr + +import ( + "errors" + + "github.com/brimdata/zed" + "github.com/brimdata/zed/pkg/field" + "github.com/brimdata/zed/zson" +) + +type Lval struct { + Elems []LvalElem + cache field.Path +} + +func NewLval(evals []LvalElem) *Lval { + return &Lval{Elems: evals} +} + +// Eval returns the path of the lval. If there's an error the returned *zed.Value +// will not be nill. +func (l *Lval) Eval(ectx Context, this *zed.Value) (field.Path, error) { + l.cache = l.cache[:0] + for _, e := range l.Elems { + name, err := e.Eval(ectx, this) + if err != nil { + return nil, err + } + l.cache = append(l.cache, name) + } + return l.cache, nil +} + +// Path returns the receiver's path. Path returns false when the receiver +// contains a dynamic element. +func (l *Lval) Path() (field.Path, bool) { + var path field.Path + for _, e := range l.Elems { + s, ok := e.(*StaticLvalElem) + if !ok { + return nil, false + } + path = append(path, s.Name) + } + return path, true +} + +type LvalElem interface { + Eval(ectx Context, this *zed.Value) (string, error) +} + +type StaticLvalElem struct { + Name string +} + +func (l *StaticLvalElem) Eval(_ Context, _ *zed.Value) (string, error) { + return l.Name, nil +} + +type ExprLvalElem struct { + caster Evaluator + eval Evaluator +} + +func NewExprLvalElem(zctx *zed.Context, e Evaluator) *ExprLvalElem { + return &ExprLvalElem{ + eval: e, + caster: LookupPrimitiveCaster(zctx, zed.TypeString), + } +} + +func (l *ExprLvalElem) Eval(ectx Context, this *zed.Value) (string, error) { + val := l.eval.Eval(ectx, this) + if val.IsError() { + return "", lvalErr(ectx, val) + } + if !val.IsString() { + if val = l.caster.Eval(ectx, val); val.IsError() { + return "", errors.New("field reference is not a string") + } + } + return val.AsString(), nil +} + +func lvalErr(ectx Context, errVal *zed.Value) error { + val := ectx.NewValue(errVal.Type.(*zed.TypeError).Type, errVal.Bytes()) + if val.IsString() { + return errors.New(val.AsString()) + } + return errors.New(zson.FormatValue(val)) +} diff --git a/runtime/expr/putter.go b/runtime/expr/putter.go index cc0e21dfa5..11f14fc6f2 100644 --- a/runtime/expr/putter.go +++ b/runtime/expr/putter.go @@ -19,12 +19,12 @@ type Putter struct { zctx *zed.Context builder zcode.Builder clauses []Assignment - // valClauses is a slice to avoid re-allocating for every value - valClauses []Assignment + rules map[int]map[string]putRule + warned map[string]struct{} // vals is a slice to avoid re-allocating for every value - vals []zed.Value - rules map[int]putRule - warned map[string]struct{} + vals []zed.Value + // paths is a slice to avoid re-allocating for every path + paths field.List } // A putRule describes how a given record type is modified by describing @@ -39,43 +39,32 @@ type putRule struct { step putStep } -func NewPutter(zctx *zed.Context, clauses []Assignment) (*Putter, error) { - for i, p := range clauses { - if p.LHS.IsEmpty() { - return nil, fmt.Errorf("put: LHS cannot be 'this' (use 'yield' operator)") - } - for j, c := range clauses { - if i == j { - continue - } - if p.LHS.Equal(c.LHS) { - return nil, fmt.Errorf("put: multiple assignments to %s", p.LHS) - } - if c.LHS.HasStrictPrefix(p.LHS) { - return nil, fmt.Errorf("put: conflicting nested assignments to %s and %s", p.LHS, c.LHS) - } - } - } +func NewPutter(zctx *zed.Context, clauses []Assignment) *Putter { return &Putter{ zctx: zctx, clauses: clauses, vals: make([]zed.Value, len(clauses)), - rules: make(map[int]putRule), + rules: make(map[int]map[string]putRule), warned: make(map[string]struct{}), - }, nil + } } -func (p *Putter) eval(ectx Context, this *zed.Value) ([]zed.Value, []Assignment) { - p.valClauses = p.valClauses[:0] +func (p *Putter) eval(ectx Context, this *zed.Value) ([]zed.Value, field.List, error) { p.vals = p.vals[:0] + p.paths = p.paths[:0] for _, cl := range p.clauses { val := *cl.RHS.Eval(ectx, this) - if !val.IsQuiet() { - p.vals = append(p.vals, val) - p.valClauses = append(p.valClauses, cl) + if val.IsQuiet() { + continue + } + p.vals = append(p.vals, val) + path, err := cl.LHS.Eval(ectx, this) + if err != nil { + return nil, nil, err } + p.paths = append(p.paths, path) } - return p.vals, p.valClauses + return p.vals, p.paths, nil } // A putStep is a recursive data structure encoding a series of steps to be @@ -175,20 +164,20 @@ func (ig *getter) nth(n int) (zcode.Bytes, error) { return nil, fmt.Errorf("getter.nth: array index %d out of bounds", n) } -func findOverwriteClause(path field.Path, clauses []Assignment) (int, field.Path, bool) { - for i, cand := range clauses { - if path.Equal(cand.LHS) || cand.LHS.HasStrictPrefix(path) { - return i, cand.LHS, true +func findOverwriteClause(path field.Path, paths field.List) (int, field.Path, bool) { + for i, lpath := range paths { + if path.Equal(lpath) || lpath.HasStrictPrefix(path) { + return i, lpath, true } } return -1, nil, false } -func (p *Putter) deriveSteps(inType *zed.TypeRecord, vals []zed.Value, clauses []Assignment) (putStep, zed.Type) { - return p.deriveRecordSteps(field.Path{}, inType.Fields, vals, clauses) +func (p *Putter) deriveSteps(inType *zed.TypeRecord, vals []zed.Value, paths field.List) (putStep, zed.Type) { + return p.deriveRecordSteps(field.Path{}, inType.Fields, vals, paths) } -func (p *Putter) deriveRecordSteps(parentPath field.Path, inFields []zed.Field, vals []zed.Value, clauses []Assignment) (putStep, *zed.TypeRecord) { +func (p *Putter) deriveRecordSteps(parentPath field.Path, inFields []zed.Field, vals []zed.Value, paths field.List) (putStep, *zed.TypeRecord) { s := putStep{op: putRecord} var fields []zed.Field @@ -197,7 +186,7 @@ func (p *Putter) deriveRecordSteps(parentPath field.Path, inFields []zed.Field, // assignments. for i, f := range inFields { path := append(parentPath, f.Name) - matchIndex, matchPath, found := findOverwriteClause(path, clauses) + matchIndex, matchPath, found := findOverwriteClause(path, paths) switch { // input not overwritten by assignment: copy input value. case !found: @@ -217,13 +206,13 @@ func (p *Putter) deriveRecordSteps(parentPath field.Path, inFields []zed.Field, fields = append(fields, zed.NewField(f.Name, vals[matchIndex].Type)) // input record field overwritten by nested assignment: recurse. case len(path) < len(matchPath) && zed.IsRecordType(f.Type): - nestedStep, typ := p.deriveRecordSteps(path, zed.TypeRecordOf(f.Type).Fields, vals, clauses) + nestedStep, typ := p.deriveRecordSteps(path, zed.TypeRecordOf(f.Type).Fields, vals, paths) nestedStep.index = i s.append(nestedStep) fields = append(fields, zed.NewField(f.Name, typ)) // input non-record field overwritten by nested assignment(s): recurse. case len(path) < len(matchPath) && !zed.IsRecordType(f.Type): - nestedStep, typ := p.deriveRecordSteps(path, []zed.Field{}, vals, clauses) + nestedStep, typ := p.deriveRecordSteps(path, []zed.Field{}, vals, paths) nestedStep.index = i s.append(nestedStep) fields = append(fields, zed.NewField(f.Name, typ)) @@ -232,30 +221,30 @@ func (p *Putter) deriveRecordSteps(parentPath field.Path, inFields []zed.Field, } } - appendClause := func(cl Assignment) bool { - if !cl.LHS.HasPrefix(parentPath) { + appendClause := func(lpath field.Path) bool { + if !lpath.HasPrefix(parentPath) { return false } - return !hasField(cl.LHS[len(parentPath)], fields) + return !hasField(lpath[len(parentPath)], fields) } // Then, look at put assignments to see if there are any new fields to append. - for i, cl := range clauses { - if appendClause(cl) { + for i, lpath := range paths { + if appendClause(lpath) { switch { // Append value at this level - case len(cl.LHS) == len(parentPath)+1: + case len(lpath) == len(parentPath)+1: s.append(putStep{ op: putFromClause, container: zed.IsContainerType(vals[i].Type), index: i, }) - fields = append(fields, zed.NewField(cl.LHS[len(parentPath)], vals[i].Type)) + fields = append(fields, zed.NewField(lpath[len(parentPath)], vals[i].Type)) // Appended and nest. For example, this would happen with "put b.c=1" applied to a record {"a": 1}. - case len(cl.LHS) > len(parentPath)+1: - path := append(parentPath, cl.LHS[len(parentPath)]) - nestedStep, typ := p.deriveRecordSteps(path, []zed.Field{}, vals, clauses) + case len(lpath) > len(parentPath)+1: + path := append(parentPath, lpath[len(parentPath)]) + nestedStep, typ := p.deriveRecordSteps(path, []zed.Field{}, vals, paths) nestedStep.index = -1 - fields = append(fields, zed.NewField(cl.LHS[len(parentPath)], typ)) + fields = append(fields, zed.NewField(lpath[len(parentPath)], typ)) s.append(nestedStep) } } @@ -273,19 +262,48 @@ func hasField(name string, fields []zed.Field) bool { }) } -func (p *Putter) lookupRule(inType *zed.TypeRecord, vals []zed.Value, clauses []Assignment) putRule { - rule, ok := p.rules[inType.ID()] +func (p *Putter) lookupRule(inType *zed.TypeRecord, vals []zed.Value, fields field.List) (putRule, error) { + m, ok := p.rules[inType.ID()] + if !ok { + m = make(map[string]putRule) + p.rules[inType.ID()] = m + } + rule, ok := m[fields.String()] if ok && sameTypes(rule.clauseTypes, vals) { - return rule + return rule, nil } - step, typ := p.deriveSteps(inType, vals, clauses) + // first check fields + if err := CheckPutFields(fields); err != nil { + return putRule{}, fmt.Errorf("put: %w", err) + } + step, typ := p.deriveSteps(inType, vals, fields) var clauseTypes []zed.Type for _, val := range vals { clauseTypes = append(clauseTypes, val.Type) } rule = putRule{typ, clauseTypes, step} - p.rules[inType.ID()] = rule - return rule + p.rules[inType.ID()][fields.String()] = rule + return rule, nil +} + +func CheckPutFields(fields field.List) error { + for i, f := range fields { + if f.IsEmpty() { + return fmt.Errorf("left-hand side cannot be 'this' (use 'yield' operator)") + } + for _, c := range fields[i+1:] { + if f.Equal(c) { + return fmt.Errorf("multiple assignments to %s", f) + } + if c.HasStrictPrefix(f) { + return fmt.Errorf("conflicting nested assignments to %s and %s", f, c) + } + if f.HasStrictPrefix(c) { + return fmt.Errorf("conflicting nested assignments to %s and %s", c, f) + } + } + } + return nil } func sameTypes(types []zed.Type, vals []zed.Value) bool { @@ -303,11 +321,17 @@ func (p *Putter) Eval(ectx Context, this *zed.Value) *zed.Value { } return ectx.CopyValue(*p.zctx.WrapError("put: not a record", this)) } - vals, clauses := p.eval(ectx, this) + vals, paths, err := p.eval(ectx, this) + if err != nil { + return ectx.CopyValue(*p.zctx.WrapError(fmt.Sprintf("put: %s", err), this)) + } if len(vals) == 0 { return this } - rule := p.lookupRule(recType, vals, clauses) + rule, err := p.lookupRule(recType, vals, paths) + if err != nil { + return ectx.CopyValue(*p.zctx.WrapError(err.Error(), this)) + } bytes := rule.step.build(this.Bytes(), &p.builder, vals) return ectx.NewValue(rule.typ, bytes) } diff --git a/runtime/expr/ztests/cut-dup-fields.yaml b/runtime/expr/ztests/cut-dup-fields.yaml index c476641af2..4ffd78207c 100644 --- a/runtime/expr/ztests/cut-dup-fields.yaml +++ b/runtime/expr/ztests/cut-dup-fields.yaml @@ -12,7 +12,7 @@ inputs: outputs: - name: stderr data: | - duplicate field: "rec" - duplicate field: "rec.sub1" - duplicate field: "rec.sub.sub" - duplicate field: "rec.sub" + cut: duplicate field: "rec" + cut: duplicate field: "rec.sub1" + cut: duplicate field: "rec.sub.sub" + cut: duplicate field: "rec.sub" diff --git a/runtime/expr/ztests/cut-not-adjacent.yaml b/runtime/expr/ztests/cut-not-adjacent.yaml index ac4bcd713b..de13172053 100644 --- a/runtime/expr/ztests/cut-not-adjacent.yaml +++ b/runtime/expr/ztests/cut-not-adjacent.yaml @@ -12,7 +12,7 @@ inputs: outputs: - name: stderr data: | - fields in record rec must be adjacent - fields in record rec1 must be adjacent - fields in record rec1 must be adjacent - fields in record t.rec must be adjacent + cut: fields in record rec must be adjacent + cut: fields in record rec1 must be adjacent + cut: fields in record rec1 must be adjacent + cut: fields in record t.rec must be adjacent diff --git a/runtime/expr/ztests/rename-error-move.yaml b/runtime/expr/ztests/rename-error-move.yaml index 136fa0cc39..9fc181586e 100644 --- a/runtime/expr/ztests/rename-error-move.yaml +++ b/runtime/expr/ztests/rename-error-move.yaml @@ -3,4 +3,4 @@ zed: rename dst:=id.resp_h input: | {id:{orig_h:10.164.94.120,orig_p:39681(port=uint16),resp_h:10.47.3.155,resp_p:3389(port)}} -errorRE: "cannot rename id.resp_h to dst" +errorRE: "rename: cannot rename id.resp_h to dst" diff --git a/runtime/op/groupby/groupby.go b/runtime/op/groupby/groupby.go index 1b3a5d8895..633376dd66 100644 --- a/runtime/op/groupby/groupby.go +++ b/runtime/op/groupby/groupby.go @@ -3,6 +3,7 @@ package groupby import ( "context" "encoding/binary" + "errors" "sync" "github.com/brimdata/zed" @@ -112,7 +113,11 @@ func NewAggregator(ctx context.Context, zctx *zed.Context, keyRefs, keyExprs, ag func New(octx *op.Context, parent zbuf.Puller, keys []expr.Assignment, aggNames field.List, aggs []*expr.Aggregator, limit int, inputSortDir order.Direction, partialsIn, partialsOut bool) (*Op, error) { names := make(field.List, 0, len(keys)+len(aggNames)) for _, e := range keys { - names = append(names, e.LHS) + p, ok := e.LHS.Path() + if !ok { + return nil, errors.New("invalid lval in groupby key") + } + names = append(names, p) } names = append(names, aggNames...) builder, err := zed.NewRecordBuilder(octx.Zctx, names) @@ -125,9 +130,9 @@ func New(octx *op.Context, parent zbuf.Puller, keys []expr.Assignment, aggNames } keyRefs := make([]expr.Evaluator, 0, len(keys)) keyExprs := make([]expr.Evaluator, 0, len(keys)) - for _, e := range keys { - keyRefs = append(keyRefs, expr.NewDottedExpr(octx.Zctx, e.LHS)) - keyExprs = append(keyExprs, e.RHS) + for i := range keys { + keyRefs = append(keyRefs, expr.NewDottedExpr(octx.Zctx, names[i])) + keyExprs = append(keyExprs, keys[i].RHS) } agg, err := NewAggregator(octx.Context, octx.Zctx, keyRefs, keyExprs, valRefs, aggs, builder, limit, inputSortDir, partialsIn, partialsOut) if err != nil { diff --git a/runtime/op/join/join.go b/runtime/op/join/join.go index d4238a93b4..5ff97c40ce 100644 --- a/runtime/op/join/join.go +++ b/runtime/op/join/join.go @@ -7,7 +7,6 @@ import ( "github.com/brimdata/zed" "github.com/brimdata/zed/order" - "github.com/brimdata/zed/pkg/field" "github.com/brimdata/zed/runtime/expr" "github.com/brimdata/zed/runtime/op" "github.com/brimdata/zed/runtime/op/sort" @@ -35,12 +34,8 @@ type Op struct { } func New(octx *op.Context, anti, inner bool, left, right zbuf.Puller, leftKey, rightKey expr.Evaluator, - leftDir, rightDir order.Direction, lhs field.List, + leftDir, rightDir order.Direction, lhs []*expr.Lval, rhs []expr.Evaluator) (*Op, error) { - cutter, err := expr.NewCutter(octx.Zctx, lhs, rhs) - if err != nil { - return nil, err - } var o order.Which switch { case leftDir != order.Unknown: @@ -48,6 +43,7 @@ func New(octx *op.Context, anti, inner bool, left, right zbuf.Puller, leftKey, r case rightDir != order.Unknown: o = rightDir == order.Down } + var err error // Add sorts if needed. if !leftDir.HasOrder(o) { left, err = sort.New(octx, left, []expr.Evaluator{leftKey}, o, true) @@ -73,7 +69,7 @@ func New(octx *op.Context, anti, inner bool, left, right zbuf.Puller, leftKey, r left: newPuller(left, ctx), right: zio.NewPeeker(newPuller(right, ctx)), compare: expr.NewValueCompareFn(o, true), - cutter: cutter, + cutter: expr.NewCutter(octx.Zctx, lhs, rhs), types: make(map[int]map[int]*zed.TypeRecord), }, nil } diff --git a/runtime/op/ztests/cut-dynamic-field.yaml b/runtime/op/ztests/cut-dynamic-field.yaml new file mode 100644 index 0000000000..138974229a --- /dev/null +++ b/runtime/op/ztests/cut-dynamic-field.yaml @@ -0,0 +1,41 @@ +script: | + echo '{a:"hi",b:"hello"}' | zq -z 'cut this[a][b] := "world"' - + echo "// ===" + echo '{a:{b:"hello"}}' | zq -z 'cut this[a.b]:="world"' - + echo "// ===" + echo '{a:"hello"}' | zq -z 'cut this[this["a"]] := "world"' - + echo "// ===" + echo '{a:{},b:"hello"}' | zq -z 'cut a[b] := "world"' - + echo "// ===" + echo '{a:"foo"}' | zq -z 'cut this[a]["bar"] := "baz"' - + echo "// ===" + # runtime error cases + echo '{a:"hello",b:"hello"}' | zq -z 'cut this[a] := "world1", this[b] := "world2"' - + echo "// ===" + echo '{a:"foo",b:"bar"}' | zq -z 'cut this[a][b] := "world", this[a] := "world"' - + echo "// ===" + echo {} | zq -z 'cut this[doesnotexist] := "world"' - + # semantic error cases + ! zc -s 'op foo(): ( yield "error" ) cut this[foo] := "hello world"' + +outputs: + - name: stdout + data: | + {hi:{hello:"world"}} + // === + {hello:"world"} + // === + {hello:"world"} + // === + {a:{hello:"world"}} + // === + {foo:{bar:"baz"}} + // === + error({message:"cut: duplicate field: \"hello\"",on:{a:"hello",b:"hello"}}) + // === + error({message:"cut: duplicate field: \"foo\"",on:{a:"foo",b:"bar"}}) + // === + error({message:"cut: missing",on:{}}) + - name: stderr + data: | + left-hand side of assignment: symbol "foo" is not bound to an expression diff --git a/runtime/op/ztests/put-dynamic-field.yaml b/runtime/op/ztests/put-dynamic-field.yaml new file mode 100644 index 0000000000..7566f09213 --- /dev/null +++ b/runtime/op/ztests/put-dynamic-field.yaml @@ -0,0 +1,41 @@ +script: | + echo '{a:"hi",b:"hello"}' | zq -z 'this[a][b] := "world" | drop a, b' - + echo "// ===" + echo '{a:{b:"hello"}}' | zq -z 'this[a.b]:="world" | drop a' - + echo "// ===" + echo '{a:"hello"}' | zq -z 'this[this["a"]] := "world" | drop a' - + echo "// ===" + echo '{a:{},b:"hello"}' | zq -z 'a[b] := "world" | drop b' - + echo "// ===" + echo '{a:"foo"}' | zq -z 'this[a]["bar"] := "baz" | cut foo' - + echo "// ===" + # runtime error cases + echo '{a:"hello",b:"hello"}' | zq -z 'this[a] := "world1", this[b] := "world2"' - + echo "// ===" + echo '{a:"foo",b:"bar"}' | zq -z 'this[a][b] := "world", this[a] := "world"' - + echo "// ===" + echo {} | zq -z 'this[doesnotexist] := "world"' - + # semantic error cases + ! zc -s 'op foo(): ( yield "error" ) put this[foo] := "hello world"' + +outputs: + - name: stdout + data: | + {hi:{hello:"world"}} + // === + {hello:"world"} + // === + {hello:"world"} + // === + {a:{hello:"world"}} + // === + {foo:{bar:"baz"}} + // === + error({message:"put: multiple assignments to hello",on:{a:"hello",b:"hello"}}) + // === + error({message:"put: conflicting nested assignments to foo and foo.bar",on:{a:"foo",b:"bar"}}) + // === + error({message:"put: missing",on:{}}) + - name: stderr + data: | + left-hand side of assignment: symbol "foo" is not bound to an expression