Repository: genjidb/genji
Pull request #445 (2022-02-11 19:18:12)

Add .timer command.
This is a proposal for #443.

codecov[bot]: # [Codecov](https://codecov.io/gh/genjidb/genji/pull/445?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=genjidb) Report
> Merging [#445](https://codecov.io/gh/genjidb/genji/pull/445?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=genjidb) (152393e) into [main](https://codecov.io/gh/genjidb/genji/commit/d8a0bffbd8c8182af20fdf854f5f191b7c79fb99?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=genjidb) (d8a0bff) will **not change** coverage.
> The diff coverage is `n/a`.

> :exclamation: Current head 152393e differs from pull request most recent head a521b49. Consider uploading reports for the commit a521b49 to get more accurate results

[![Impacted file tree graph](https://codecov.io/gh/genjidb/genji/pull/445/graphs/tree.svg?width=650&height=150&src=pr&token=nwR1GpvtyH&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=genjidb)](https://codecov.io/gh/genjidb/genji/pull/445?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=genjidb)

```diff
@@           Coverage Diff           @@
##             main     #445   +/-   ##
=======================================
  Coverage   79.50%   79.50%           
=======================================
  Files          95       95           
  Lines        9655     9655           
=======================================
  Hits         7676     7676           
  Misses       1327     1327           
  Partials      652      652           
```



------

[Continue to review full report at Codecov](https://codecov.io/gh/genjidb/genji/pull/445?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=genjidb).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=genjidb)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/genjidb/genji/pull/445?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=genjidb). Last update [d8a0bff...a521b49](https://codecov.io/gh/genjidb/genji/pull/445?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=genjidb). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=genjidb).


Patch:
diff --git a/cmd/genji/shell/command.go b/cmd/genji/shell/command.go
index 3b3fe7da..c428486a 100644
--- a/cmd/genji/shell/command.go
+++ b/cmd/genji/shell/command.go
@@ -10,6 +10,7 @@ import (
 	"strings"
 
 	"github.com/cockroachdb/errors"
+
 	"github.com/genjidb/genji"
 	"github.com/genjidb/genji/cmd/genji/dbutil"
 	"github.com/genjidb/genji/cmd/genji/doc"
@@ -84,6 +85,12 @@ var commands = []command{
 		DisplayName: ".import",
 		Description: "Import data from a file. Only supported type is 'csv'",
 	},
+	{
+		Name:        ".timer",
+		Options:     "[on|off]",
+		DisplayName: ".timer",
+		Description: "Display the execution time after each query or hide it.",
+	},
 }
 
 func getUsage(cmdName string) string {
diff --git a/cmd/genji/shell/shell.go b/cmd/genji/shell/shell.go
index 1cef197d..5c88ccf7 100644
--- a/cmd/genji/shell/shell.go
+++ b/cmd/genji/shell/shell.go
@@ -10,17 +10,19 @@ import (
 	"strings"
 	"sync"
 	"syscall"
+	"time"
 
 	"github.com/agnivade/levenshtein"
 	"github.com/c-bata/go-prompt"
 	"github.com/cockroachdb/errors"
+	"go.uber.org/multierr"
+	"golang.org/x/sync/errgroup"
+
 	"github.com/genjidb/genji"
 	"github.com/genjidb/genji/cmd/genji/dbutil"
 	"github.com/genjidb/genji/document"
 	"github.com/genjidb/genji/internal/sql/parser"
 	"github.com/genjidb/genji/types"
-	"go.uber.org/multierr"
-	"golang.org/x/sync/errgroup"
 )
 
 const (
@@ -45,6 +47,8 @@ type Shell struct {
 	livePrefix string
 	multiLine  bool
 
+	showTime bool
+
 	history []string
 
 	cmdSuggestions []prompt.Suggest
@@ -173,6 +177,8 @@ func (sh *Shell) runExecutor(ctx context.Context, promptExecCh chan string) erro
 		case <-ctx.Done():
 			return ctx.Err()
 		case input := <-promptExecCh:
+			showTime := sh.showTime
+			start := time.Now().UTC()
 			err := sh.executeInput(sh.getExecContext(ctx), input)
 			// if the context has been canceled
 			// there is no way to tell at this point
@@ -189,6 +195,12 @@ func (sh *Shell) runExecutor(ctx context.Context, promptExecCh chan string) erro
 			}
 			if err != nil {
 				fmt.Println(err)
+				continue
+			}
+
+			// if showtime is true, ensure it's a query, and it was executed.
+			if showTime && !strings.HasPrefix(input, ".") && strings.HasSuffix(input, ";") {
+				fmt.Printf("Time: %s\n", time.Since(start))
 			}
 		case promptExecCh <- "":
 		}
@@ -415,6 +427,13 @@ func (sh *Shell) runCommand(ctx context.Context, in string) error {
 	in = strings.TrimSuffix(in, ";")
 	cmd := strings.Fields(in)
 	switch cmd[0] {
+	case ".timer":
+		if len(cmd) != 2 || (cmd[1] != "on" && cmd[1] != "off") {
+			return fmt.Errorf(getUsage(".timer"))
+		}
+
+		sh.showTime = cmd[1] == "on"
+		return nil
 	case ".help", "help":
 		return runHelpCmd()
 	case ".tables":
diff --git a/document/diff.go b/document/diff.go
new file mode 100644
index 00000000..876287ff
--- /dev/null
+++ b/document/diff.go
@@ -0,0 +1,188 @@
+package document
+
+import (
+	"github.com/cockroachdb/errors"
+	"github.com/genjidb/genji/types"
+)
+
+// Diff returns the operations needed to transform the first document into the second.
+func Diff(d1, d2 types.Document) ([]Op, error) {
+	return diff(nil, d1, d2)
+}
+
+func diff(path Path, d1, d2 types.Document) ([]Op, error) {
+	var ops []Op
+	f1, err := types.Fields(d1)
+	if err != nil {
+		return nil, err
+	}
+
+	f2, err := types.Fields(d2)
+	if err != nil {
+		return nil, err
+	}
+
+	var i, j int
+	for {
+		for i < len(f1) && (j >= len(f2) || f1[i] < f2[j]) {
+			v, err := d1.GetByField(f1[j])
+			if err != nil {
+				return nil, err
+			}
+			ops = append(ops, NewDeleteOp(path.ExtendField(f1[i]), v))
+			i++
+		}
+
+		for j < len(f2) && (i >= len(f1) || f1[i] > f2[j]) {
+			v, err := d2.GetByField(f2[j])
+			if err != nil {
+				return nil, err
+			}
+			ops = append(ops, NewSetOp(path.ExtendField(f2[j]), v))
+			j++
+		}
+
+		if i == len(f1) && j == len(f2) {
+			break
+		}
+
+		v1, err := d1.GetByField(f1[i])
+		if err != nil {
+			return nil, err
+		}
+
+		v2, err := d2.GetByField(f2[j])
+		if err != nil {
+			return nil, err
+		}
+
+		if v1.Type() != v2.Type() {
+			v, err := d2.GetByField(f2[j])
+			if err != nil {
+				return nil, err
+			}
+			ops = append(ops, NewSetOp(path.ExtendField(f2[j]), v))
+		} else {
+			switch v1.Type() {
+			case types.DocumentValue:
+				subOps, err := diff(append(path, PathFragment{FieldName: f1[i]}), v1.V().(types.Document), v2.V().(types.Document))
+				if err != nil {
+					return nil, err
+				}
+				ops = append(ops, subOps...)
+			case types.ArrayValue:
+				subOps, err := arrayDiff(append(path, PathFragment{FieldName: f1[i]}), v1.V().(types.Array), v2.V().(types.Array))
+				if err != nil {
+					return nil, err
+				}
+				ops = append(ops, subOps...)
+			default:
+				ok, err := types.IsEqual(v1, v2)
+				if err != nil {
+					return nil, err
+				}
+				if !ok {
+					ops = append(ops, NewSetOp(path.ExtendField(f2[j]), v2))
+				}
+			}
+		}
+		i++
+		j++
+	}
+
+	return ops, nil
+}
+
+func arrayDiff(path Path, a1, a2 types.Array) ([]Op, error) {
+	var ops []Op
+
+	var i int
+	for {
+		v1, err := a1.GetByIndex(i)
+		nov1 := errors.Is(err, types.ErrFieldNotFound)
+		if !nov1 && err != nil {
+			return nil, err
+		}
+
+		v2, err := a2.GetByIndex(i)
+		nov2 := errors.Is(err, types.ErrFieldNotFound)
+		if !nov2 && err != nil {
+			return nil, err
+		}
+
+		if nov1 && nov2 {
+			break
+		}
+
+		if nov1 && !nov2 {
+			ops = append(ops, NewSetOp(path.ExtendIndex(i), v2))
+			i++
+			continue
+		}
+		if !nov1 && nov2 {
+			ops = append(ops, NewDeleteOp(path.ExtendIndex(i), v1))
+			i++
+			continue
+		}
+
+		if v1.Type() != v2.Type() {
+			ops = append(ops, NewSetOp(path.ExtendIndex(i), v2))
+			i++
+			continue
+		}
+
+		switch v1.Type() {
+		case types.DocumentValue:
+			subOps, err := diff(append(path, PathFragment{ArrayIndex: i}), v1.V().(types.Document), v2.V().(types.Document))
+			if err != nil {
+				return nil, err
+			}
+			ops = append(ops, subOps...)
+		case types.ArrayValue:
+			subOps, err := arrayDiff(append(path, PathFragment{ArrayIndex: i}), v1.V().(types.Array), v2.V().(types.Array))
+			if err != nil {
+				return nil, err
+			}
+			ops = append(ops, subOps...)
+		default:
+			ok, err := types.IsEqual(v1, v2)
+			if err != nil {
+				return nil, err
+			}
+			if !ok {
+				ops = append(ops, NewSetOp(path.ExtendIndex(i), v2))
+			}
+		}
+		i++
+	}
+
+	return ops, nil
+}
+
+// Op represents a single operation on a document.
+// It is returned by the Diff function.
+type Op struct {
+	Type  string
+	Path  Path
+	Value types.Value
+}
+
+func NewSetOp(path Path, v types.Value) Op {
+	return newOp("set", path, v)
+}
+
+func NewDeleteOp(path Path, v types.Value) Op {
+	return newOp("delete", path, v)
+}
+
+func newOp(op string, path Path, v types.Value) Op {
+	return Op{
+		Type:  op,
+		Path:  path,
+		Value: v,
+	}
+}
+
+func (o *Op) MarshalBinary() ([]byte, error) {
+	panic("not implemented") // TODO: Implement
+}
diff --git a/document/document.go b/document/document.go
index 320bce39..06effde1 100644
--- a/document/document.go
+++ b/document/document.go
@@ -3,8 +3,6 @@ package document
 
 import (
 	"fmt"
-	"strconv"
-	"strings"
 
 	"github.com/buger/jsonparser"
 	"github.com/cockroachdb/errors"
@@ -404,159 +402,6 @@ func (fb *FieldBuffer) Reset() {
 	fb.fields = fb.fields[:0]
 }
 
-// A Path represents the path to a particular value within a document.
-type Path []PathFragment
-
-// NewPath creates a path from a list of strings representing either a field name
-// or an array index in string form.
-func NewPath(fragments ...string) Path {
-	var path Path
-
-	for _, frag := range fragments {
-		idx, err := strconv.Atoi(frag)
-		if err != nil {
-			path = append(path, PathFragment{FieldName: frag})
-		} else {
-			path = append(path, PathFragment{ArrayIndex: idx})
-		}
-	}
-
-	return path
-}
-
-// PathFragment is a fragment of a path representing either a field name or
-// the index of an array.
-type PathFragment struct {
-	FieldName  string
-	ArrayIndex int
-}
-
-// String representation of all the fragments of the path.
-// It implements the Stringer interface.
-func (p Path) String() string {
-	var b strings.Builder
-
-	for i := range p {
-		if p[i].FieldName != "" {
-			if i != 0 {
-				b.WriteRune('.')
-			}
-			b.WriteString(p[i].FieldName)
-		} else {
-			b.WriteString("[" + strconv.Itoa(p[i].ArrayIndex) + "]")
-		}
-	}
-	return b.String()
-}
-
-// IsEqual returns whether other is equal to p.
-func (p Path) IsEqual(other Path) bool {
-	if len(other) != len(p) {
-		return false
-	}
-
-	for i := range p {
-		if other[i] != p[i] {
-			return false
-		}
-	}
-
-	return true
-}
-
-// GetValueFromDocument returns the value at path p from d.
-func (p Path) GetValueFromDocument(d types.Document) (types.Value, error) {
-	if len(p) == 0 {
-		return nil, errors.WithStack(types.ErrFieldNotFound)
-	}
-	if p[0].FieldName == "" {
-		return nil, errors.WithStack(types.ErrFieldNotFound)
-	}
-
-	v, err := d.GetByField(p[0].FieldName)
-	if err != nil {
-		return nil, err
-	}
-
-	if len(p) == 1 {
-		return v, nil
-	}
-
-	return p[1:].getValueFromValue(v)
-}
-
-// GetValueFromArray returns the value at path p from a.
-func (p Path) GetValueFromArray(a types.Array) (types.Value, error) {
-	if len(p) == 0 {
-		return nil, errors.WithStack(types.ErrFieldNotFound)
-	}
-	if p[0].FieldName != "" {
-		return nil, errors.WithStack(types.ErrFieldNotFound)
-	}
-
-	v, err := a.GetByIndex(p[0].ArrayIndex)
-	if err != nil {
-		if errors.Is(err, types.ErrValueNotFound) {
-			return nil, errors.WithStack(types.ErrFieldNotFound)
-		}
-
-		return nil, err
-	}
-
-	if len(p) == 1 {
-		return v, nil
-	}
-
-	return p[1:].getValueFromValue(v)
-}
-
-func (p Path) Clone() Path {
-	c := make(Path, len(p))
-	copy(c, p)
-	return c
-}
-
-func (p Path) getValueFromValue(v types.Value) (types.Value, error) {
-	switch v.Type() {
-	case types.DocumentValue:
-		return p.GetValueFromDocument(v.V().(types.Document))
-	case types.ArrayValue:
-		return p.GetValueFromArray(v.V().(types.Array))
-	}
-
-	return nil, types.ErrFieldNotFound
-}
-
-type Paths []Path
-
-func (p Paths) String() string {
-	var sb strings.Builder
-
-	for i, pt := range p {
-		if i > 0 {
-			sb.WriteString(", ")
-		}
-		sb.WriteString(pt.String())
-	}
-
-	return sb.String()
-}
-
-// IsEqual returns whether other is equal to p.
-func (p Paths) IsEqual(other Paths) bool {
-	if len(other) != len(p) {
-		return false
-	}
-
-	for i := range p {
-		if !other[i].IsEqual(p[i]) {
-			return false
-		}
-	}
-
-	return true
-}
-
 // MaskFields returns a new document that masks the given fields.
 func MaskFields(d types.Document, fields ...string) types.Document {
 	return &maskDocument{d, fields}
diff --git a/document/path.go b/document/path.go
new file mode 100644
index 00000000..affe53ce
--- /dev/null
+++ b/document/path.go
@@ -0,0 +1,180 @@
+package document
+
+import (
+	"strconv"
+	"strings"
+
+	"github.com/genjidb/genji/types"
+	"github.com/pkg/errors"
+)
+
+// A Path represents the path to a particular value within a document.
+type Path []PathFragment
+
+// NewPath creates a path from a list of strings representing either a field name
+// or an array index in string form.
+func NewPath(fragments ...string) Path {
+	var path Path
+
+	for _, frag := range fragments {
+		idx, err := strconv.Atoi(frag)
+		if err != nil {
+			path = append(path, PathFragment{FieldName: frag})
+		} else {
+			path = append(path, PathFragment{ArrayIndex: idx})
+		}
+	}
+
+	return path
+}
+
+// PathFragment is a fragment of a path representing either a field name or
+// the index of an array.
+type PathFragment struct {
+	FieldName  string
+	ArrayIndex int
+}
+
+// String representation of all the fragments of the path.
+// It implements the Stringer interface.
+func (p Path) String() string {
+	var b strings.Builder
+
+	for i := range p {
+		if p[i].FieldName != "" {
+			if i != 0 {
+				b.WriteRune('.')
+			}
+			b.WriteString(p[i].FieldName)
+		} else {
+			b.WriteString("[" + strconv.Itoa(p[i].ArrayIndex) + "]")
+		}
+	}
+	return b.String()
+}
+
+// IsEqual returns whether other is equal to p.
+func (p Path) IsEqual(other Path) bool {
+	if len(other) != len(p) {
+		return false
+	}
+
+	for i := range p {
+		if other[i] != p[i] {
+			return false
+		}
+	}
+
+	return true
+}
+
+// GetValueFromDocument returns the value at path p from d.
+func (p Path) GetValueFromDocument(d types.Document) (types.Value, error) {
+	if len(p) == 0 {
+		return nil, errors.WithStack(types.ErrFieldNotFound)
+	}
+	if p[0].FieldName == "" {
+		return nil, errors.WithStack(types.ErrFieldNotFound)
+	}
+
+	v, err := d.GetByField(p[0].FieldName)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(p) == 1 {
+		return v, nil
+	}
+
+	return p[1:].getValueFromValue(v)
+}
+
+// GetValueFromArray returns the value at path p from a.
+func (p Path) GetValueFromArray(a types.Array) (types.Value, error) {
+	if len(p) == 0 {
+		return nil, errors.WithStack(types.ErrFieldNotFound)
+	}
+	if p[0].FieldName != "" {
+		return nil, errors.WithStack(types.ErrFieldNotFound)
+	}
+
+	v, err := a.GetByIndex(p[0].ArrayIndex)
+	if err != nil {
+		if errors.Is(err, types.ErrValueNotFound) {
+			return nil, errors.WithStack(types.ErrFieldNotFound)
+		}
+
+		return nil, err
+	}
+
+	if len(p) == 1 {
+		return v, nil
+	}
+
+	return p[1:].getValueFromValue(v)
+}
+
+func (p Path) Clone() Path {
+	c := make(Path, len(p))
+	copy(c, p)
+	return c
+}
+
+// Extend clones the path and appends the fragment to it.
+func (p Path) Extend(f PathFragment) Path {
+	c := make(Path, len(p)+1)
+	copy(c, p)
+	c[len(p)] = f
+	return c
+}
+
+// Extend clones the path and appends the field to it.
+func (p Path) ExtendField(field string) Path {
+	return p.Extend(PathFragment{FieldName: field})
+}
+
+// Extend clones the path and appends the array index to it.
+func (p Path) ExtendIndex(index int) Path {
+	return p.Extend(PathFragment{ArrayIndex: index})
+}
+
+func (p Path) getValueFromValue(v types.Value) (types.Value, error) {
+	switch v.Type() {
+	case types.DocumentValue:
+		return p.GetValueFromDocument(v.V().(types.Document))
+	case types.ArrayValue:
+		return p.GetValueFromArray(v.V().(types.Array))
+	}
+
+	return nil, types.ErrFieldNotFound
+}
+
+type Paths []Path
+
+func (p Paths) String() string {
+	var sb strings.Builder
+
+	for i, pt := range p {
+		if i > 0 {
+			sb.WriteString(", ")
+		}
+		sb.WriteString(pt.String())
+	}
+
+	return sb.String()
+}
+
+// IsEqual returns whether other is equal to p.
+func (p Paths) IsEqual(other Paths) bool {
+	if len(other) != len(p) {
+		return false
+	}
+
+	for i := range p {
+		if !other[i].IsEqual(p[i]) {
+			return false
+		}
+	}
+
+	return true
+}
diff --git a/internal/sql/parser/alter.go b/internal/sql/parser/alter.go
index b347f0ed..9fa6e4fd 100644
--- a/internal/sql/parser/alter.go
+++ b/internal/sql/parser/alter.go
@@ -1,6 +1,8 @@
 package parser
 
 import (
+	"github.com/cockroachdb/errors"
+
 	"github.com/genjidb/genji/internal/database"
 	"github.com/genjidb/genji/internal/query/statement"
 	"github.com/genjidb/genji/internal/sql/scanner"
@@ -66,7 +68,7 @@ func (p *Parser) parseAlterStatement() (statement.Statement, error) {
 	// Parse table name.
 	tableName, err := p.parseIdent()
 	if err != nil {
-		pErr := err.(*ParseError)
+		pErr := errors.Unwrap(err).(*ParseError)
 		pErr.Expected = []string{"table_name"}
 		return nil, pErr
 	}
diff --git a/internal/sql/parser/delete.go b/internal/sql/parser/delete.go
index 7fcceb28..957ecbd7 100644
--- a/internal/sql/parser/delete.go
+++ b/internal/sql/parser/delete.go
@@ -1,6 +1,8 @@
 package parser
 
 import (
+	"github.com/cockroachdb/errors"
+
 	"github.com/genjidb/genji/internal/query/statement"
 	"github.com/genjidb/genji/internal/sql/scanner"
 )
@@ -18,7 +20,7 @@ func (p *Parser) parseDeleteStatement() (statement.Statement, error) {
 	// Parse table name
 	stmt.TableName, err = p.parseIdent()
 	if err != nil {
-		pErr := err.(*ParseError)
+		pErr := errors.Unwrap(err).(*ParseError)
 		pErr.Expected = []string{"table_name"}
 		return nil, pErr
 	}
diff --git a/internal/sql/parser/drop.go b/internal/sql/parser/drop.go
index e36c3f68..d9ebcaee 100644
--- a/internal/sql/parser/drop.go
+++ b/internal/sql/parser/drop.go
@@ -1,6 +1,8 @@
 package parser
 
 import (
+	"github.com/cockroachdb/errors"
+
 	"github.com/genjidb/genji/internal/query/statement"
 	"github.com/genjidb/genji/internal/sql/scanner"
 )
@@ -39,7 +41,7 @@ func (p *Parser) parseDropTableStatement() (statement.DropTableStmt, error) {
 	// Parse table name
 	stmt.TableName, err = p.parseIdent()
 	if err != nil {
-		pErr := err.(*ParseError)
+		pErr := errors.Unwrap(err).(*ParseError)
 		pErr.Expected = []string{"table_name"}
 		return stmt, pErr
 	}
@@ -61,7 +63,7 @@ func (p *Parser) parseDropIndexStatement() (statement.DropIndexStmt, error) {
 	// Parse index name
 	stmt.IndexName, err = p.parseIdent()
 	if err != nil {
-		pErr := err.(*ParseError)
+		pErr := errors.Unwrap(err).(*ParseError)
 		pErr.Expected = []string{"index_name"}
 		return stmt, pErr
 	}
@@ -83,7 +85,7 @@ func (p *Parser) parseDropSequenceStatement() (statement.DropSequenceStmt, error
 	// Parse sequence name
 	stmt.SequenceName, err = p.parseIdent()
 	if err != nil {
-		pErr := err.(*ParseError)
+		pErr := errors.Unwrap(err).(*ParseError)
 		pErr.Expected = []string{"sequence_name"}
 		return stmt, pErr
 	}
diff --git a/types/encoding/encoding.go b/types/encoding/encoding.go
index 4e8433d4..b10316be 100644
--- a/types/encoding/encoding.go
+++ b/types/encoding/encoding.go
@@ -4,6 +4,7 @@ import (
 	"io"
 	"sync"
 
+	"github.com/cockroachdb/errors"
 	"github.com/genjidb/genji/types"
 )
 
@@ -119,9 +120,18 @@ func appendArray(buf []byte, a types.Array) ([]byte, error) {
 func appendDocument(buf []byte, d types.Document) ([]byte, error) {
 	l := len(buf)
 
+	// prevent duplicate field names
+	fieldNames := make(map[string]bool)
+
 	err := d.Iterate(func(field string, value types.Value) error {
 		var err error
 
+		if fieldNames[field] {
+			return errors.New("duplicate field name: " + field)
+		}
+
+		fieldNames[field] = true
+
 		// encode the field as text
 		buf = append(buf, byte(types.TextValue))
 		buf, err = AppendBase64(buf, []byte(field))


Test patch:
diff --git a/document/diff_test.go b/document/diff_test.go
new file mode 100644
index 00000000..91f7322f
--- /dev/null
+++ b/document/diff_test.go
@@ -0,0 +1,157 @@
+package document_test
+
+import (
+	"testing"
+
+	"github.com/genjidb/genji/document"
+	"github.com/genjidb/genji/internal/testutil"
+	"github.com/genjidb/genji/types"
+	"github.com/stretchr/testify/require"
+)
+
+func TestDiff(t *testing.T) {
+	tests := []struct {
+		name   string
+		d1, d2 string
+		want   []document.Op
+	}{
+		{
+			name: "empty",
+			d1:   `{}`,
+			d2:   `{}`,
+			want: nil,
+		},
+		{
+			name: "add field",
+			d1:   `{}`,
+			d2:   `{"a": 1}`,
+			want: []document.Op{
+				{"set", document.NewPath("a"), types.NewIntegerValue(1)},
+			},
+		},
+		{
+			name: "remove field",
+			d1:   `{"a": 1}`,
+			d2:   `{}`,
+			want: []document.Op{
+				{"delete", document.NewPath("a"), types.NewIntegerValue(1)},
+			},
+		},
+		{
+			name: "same",
+			d1:   `{"a": 1}`,
+			d2:   `{"a": 1}`,
+			want: nil,
+		},
+		{
+			name: "replace field",
+			d1:   `{"a": 1}`,
+			d2:   `{"a": 2}`,
+			want: []document.Op{
+				{"set", document.NewPath("a"), types.NewIntegerValue(2)},
+			},
+		},
+		{
+			name: "replace field: different type",
+			d1:   `{"a": 1}`,
+			d2:   `{"a": "hello"}`,
+			want: []document.Op{
+				{"set", document.NewPath("a"), types.NewTextValue("hello")},
+			},
+		},
+		{
+			name: "nested document: replace field",
+			d1:   `{"a": {"b": 1}}`,
+			d2:   `{"a": {"b": 2}}`,
+			want: []document.Op{
+				{"set", document.NewPath("a", "b"), types.NewIntegerValue(2)},
+			},
+		},
+		{
+			name: "nested document: add field",
+			d1:   `{"a": {"b": 1}}`,
+			d2:   `{"a": {"b": 1, "c": 2}}`,
+			want: []document.Op{
+				{"set", document.NewPath("a", "c"), types.NewIntegerValue(2)},
+			},
+		},
+		{
+			name: "nested document: remove field",
+			d1:   `{"a": {"b": 1, "c": 2}}`,
+			d2:   `{"a": {"b": 1}}`,
+			want: []document.Op{
+				{"delete", document.NewPath("a", "c"), types.NewIntegerValue(2)},
+			},
+		},
+		{
+			name: "nested array: replace index",
+			d1:   `{"a": [1, 2, 3]}`,
+			d2:   `{"a": [1, 2, 4]}`,
+			want: []document.Op{
+				{"set", document.NewPath("a", "2"), types.NewIntegerValue(4)},
+			},
+		},
+		{
+			name: "nested array: replace index with different type",
+			d1:   `{"a": [1, 2, 3]}`,
+			d2:   `{"a": [1, 2, 4.5]}`,
+			want: []document.Op{
+				{"set", document.NewPath("a", "2"), types.NewDoubleValue(4.5)},
+			},
+		},
+		{
+			name: "nested array: add index",
+			d1:   `{"a": [1, 2, 3]}`,
+			d2:   `{"a": [1, 2, 3, 4]}`,
+			want: []document.Op{
+				{"set", document.NewPath("a", "3"), types.NewIntegerValue(4)},
+			},
+		},
+		{
+			name: "nested array: remove index",
+			d1:   `{"a": [1, 2, 3, 4]}`,
+			d2:   `{"a": [1, 2, 3]}`,
+			want: []document.Op{
+				{"delete", document.NewPath("a", "3"), types.NewIntegerValue(4)},
+			},
+		},
+		{
+			name: "nested array: add in the middle",
+			d1:   `{"a": [1, 2, 3]}`,
+			d2:   `{"a": [1, 2, 2.5, 3]}`,
+			want: []document.Op{
+				{"set", document.NewPath("a", "2"), types.NewDoubleValue(2.5)},
+				{"set", document.NewPath("a", "3"), types.NewIntegerValue(3)},
+			},
+		},
+		{
+			name: "nested array: with nested array",
+			d1:   `{"a": [1, 2, []]}`,
+			d2:   `{"a": [1, 2, [1], 3]}`,
+			want: []document.Op{
+				{"set", document.NewPath("a", "2", "0"), types.NewIntegerValue(1)},
+				{"set", document.NewPath("a", "3"), types.NewIntegerValue(3)},
+			},
+		},
+		{
+			name: "nested array: with nested document",
+			d1:   `{"a": [1, 2, {"b": [1]}]}`,
+			d2:   `{"a": [1, 2, {"b": [2]}, 3]}`,
+			want: []document.Op{
+				{"set", document.NewPath("a", "2", "b", "0"), types.NewIntegerValue(2)},
+				{"set", document.NewPath("a", "3"), types.NewIntegerValue(3)},
+			},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			d1 := testutil.MakeDocument(t, test.d1)
+			d2 := testutil.MakeDocument(t, test.d2)
+
+			got, err := document.Diff(d1, d2)
+			require.NoError(t, err)
+			require.Equal(t, test.want, got)
+		})
+	}
+}
diff --git a/document/document_test.go b/document/document_test.go
index 2ce7998e..d3a82478 100644
--- a/document/document_test.go
+++ b/document/document_test.go
@@ -593,44 +593,6 @@ func (f *foo) GetByField(field string) (types.Value, error) {
 	return nil, errors.New("unknown field")
 }
 
-func TestPath(t *testing.T) {
-	tests := []struct {
-		name   string
-		data   string
-		path   string
-		result string
-		fails  bool
-	}{
-		{"root", `{"a": {"b": [1, 2, 3]}}`, `a`, `{"b": [1, 2, 3]}`, false},
-		{"nested doc", `{"a": {"b": [1, 2, 3]}}`, `a.b`, `[1, 2, 3]`, false},
-		{"nested array", `{"a": {"b": [1, 2, 3]}}`, `a.b[1]`, `2`, false},
-		{"index out of range", `{"a": {"b": [1, 2, 3]}}`, `a.b[1000]`, ``, true},
-		{"number field", `{"a": {"0": [1, 2, 3]}}`, "a.`0`", `[1, 2, 3]`, false},
-		{"letter index", `{"a": {"b": [1, 2, 3]}}`, `a.b.c`, ``, true},
-		{"unknown path", `{"a": {"b": [1, 2, 3]}}`, `a.e.f`, ``, true},
-	}
-
-	for _, test := range tests {
-		t.Run(test.name, func(t *testing.T) {
-			var buf document.FieldBuffer
-
-			err := json.Unmarshal([]byte(test.data), &buf)
-			assert.NoError(t, err)
-			p, err := parser.ParsePath(test.path)
-			assert.NoError(t, err)
-			v, err := p.GetValueFromDocument(&buf)
-			if test.fails {
-				assert.Error(t, err)
-			} else {
-				assert.NoError(t, err)
-				res, err := json.Marshal(v)
-				assert.NoError(t, err)
-				require.JSONEq(t, test.result, string(res))
-			}
-		})
-	}
-}
-
 func TestJSONDocument(t *testing.T) {
 	tests := []struct {
 		name     string
diff --git a/document/path_test.go b/document/path_test.go
new file mode 100644
index 00000000..973674dc
--- /dev/null
+++ b/document/path_test.go
@@ -0,0 +1,49 @@
+package document_test
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/genjidb/genji/document"
+	"github.com/genjidb/genji/internal/sql/parser"
+	"github.com/genjidb/genji/internal/testutil/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestPath(t *testing.T) {
+	tests := []struct {
+		name   string
+		data   string
+		path   string
+		result string
+		fails  bool
+	}{
+		{"root", `{"a": {"b": [1, 2, 3]}}`, `a`, `{"b": [1, 2, 3]}`, false},
+		{"nested doc", `{"a": {"b": [1, 2, 3]}}`, `a.b`, `[1, 2, 3]`, false},
+		{"nested array", `{"a": {"b": [1, 2, 3]}}`, `a.b[1]`, `2`, false},
+		{"index out of range", `{"a": {"b": [1, 2, 3]}}`, `a.b[1000]`, ``, true},
+		{"number field", `{"a": {"0": [1, 2, 3]}}`, "a.`0`", `[1, 2, 3]`, false},
+		{"letter index", `{"a": {"b": [1, 2, 3]}}`, `a.b.c`, ``, true},
+		{"unknown path", `{"a": {"b": [1, 2, 3]}}`, `a.e.f`, ``, true},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			var buf document.FieldBuffer
+
+			err := json.Unmarshal([]byte(test.data), &buf)
+			assert.NoError(t, err)
+			p, err := parser.ParsePath(test.path)
+			assert.NoError(t, err)
+			v, err := p.GetValueFromDocument(&buf)
+			if test.fails {
+				assert.Error(t, err)
+			} else {
+				assert.NoError(t, err)
+				res, err := json.Marshal(v)
+				assert.NoError(t, err)
+				require.JSONEq(t, test.result, string(res))
+			}
+		})
+	}
+}
diff --git a/sqltests/INSERT/base.sql b/sqltests/INSERT/base.sql
index 9818beb1..403adebc 100644
--- a/sqltests/INSERT/base.sql
+++ b/sqltests/INSERT/base.sql
@@ -422,3 +422,13 @@ SELECT * FROM test_df;
 CREATE TABLE test_df (a.b[0].c TEXT DEFAULT "foo");
 INSERT INTO test_df VALUES {};
 -- error:
+
+-- test: duplicate field names: root
+CREATE TABLE test_df ;
+INSERT INTO test_df(a, a) VALUES (1, 10);
+-- error:
+
+-- test: duplicate field names: nested
+CREATE TABLE test_df;
+insert into test_df(a) values ({b: 1, b: 10});
+-- error:
diff --git a/types/encoding/encoding_test.go b/types/encoding/encoding_test.go
index aa4415a8..23b01fa8 100644
--- a/types/encoding/encoding_test.go
+++ b/types/encoding/encoding_test.go
@@ -38,11 +38,13 @@ func TestEncodeDecode(t *testing.T) {
 		name     string
 		d        types.Document
 		expected string
+		fails    bool
 	}{
 		{
 			"empty doc",
 			document.NewFieldBuffer(),
 			`{}`,
+			false,
 		},
 		{
 			"document.FieldBuffer",
@@ -50,11 +52,21 @@ func TestEncodeDecode(t *testing.T) {
 				Add("age", types.NewIntegerValue(10)).
 				Add("name", types.NewTextValue("john")),
 			`{"age": 10, "name": "john"}`,
+			false,
 		},
 		{
 			"Map",
 			userMapDoc,
 			`{"age": 10, "name": "john"}`,
+			false,
+		},
+		{
+			"duplicate field name",
+			document.NewFieldBuffer().
+				Add("age", types.NewIntegerValue(10)).
+				Add("age", types.NewIntegerValue(10)),
+			``,
+			true,
 		},
 		{
 			"Nested types.Document",
@@ -64,6 +76,7 @@ func TestEncodeDecode(t *testing.T) {
 				Add("address", types.NewDocumentValue(addressMapDoc)).
 				Add("array", types.NewArrayValue(complexArray)),
 			`{"age": 10, "name": "john", "address": {"city": "Ajaccio", "country": "France"}, "array": [true, -40, -3.14, 3, "YmxvYg==", "hello", {"city": "Ajaccio", "country": "France"}, [11]]}`,
+			false,
 		},
 	}
 
@@ -72,6 +85,10 @@ func TestEncodeDecode(t *testing.T) {
 		t.Run(test.name, func(t *testing.T) {
 			buf.Reset()
 			err := encoding.EncodeValue(&buf, types.NewDocumentValue(test.d))
+			if test.fails {
+				assert.Error(t, err)
+				return
+			}
 			assert.NoError(t, err)
 			v, err := encoding.DecodeValue(buf.Bytes())
 			assert.NoError(t, err)