Skip to content

Commit

Permalink
Merge pull request #880 from go-kivik/compoundRevs
Browse files Browse the repository at this point in the history
Compound revs in db store
  • Loading branch information
flimzy authored Feb 12, 2024
2 parents 72d0daf + f0fe95d commit 03eaf6c
Show file tree
Hide file tree
Showing 5 changed files with 176 additions and 47 deletions.
87 changes: 56 additions & 31 deletions x/sqlite/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ func (d *db) Put(ctx context.Context, docID string, doc interface{}, options dri
return "", err
}

tx, err := d.db.BeginTx(ctx, nil)
if err != nil {
return "", err
}
defer tx.Rollback()

if newEdits, _ := opts["new_edits"].(bool); !newEdits {
if docRev == "" {
return "", &internal.Error{Status: http.StatusBadRequest, Message: "When `new_edits: false`, the document needs `_rev` or `_revisions` specified"}
Expand All @@ -74,31 +80,41 @@ func (d *db) Put(ctx context.Context, docID string, doc interface{}, options dri
if err != nil {
return "", err
}
_, err = tx.ExecContext(ctx, fmt.Sprintf(`
INSERT INTO %[1]q (id, rev, rev_id)
VALUES ($1, $2, $3)
`, d.name+"_revs"), docID, rev.rev, rev.id)
if err != nil {
var sqliteErr *sqlite.Error
// A conflict here can be ignored, as we're not actually writing the
// document, and the rev can theoretically be updated without the doc
// during replication.
if !errors.As(err, &sqliteErr) || sqliteErr.Code() != sqlite3.SQLITE_CONSTRAINT_UNIQUE {
return "", err
}
}
var newRev string
err = d.db.QueryRowContext(ctx, fmt.Sprintf(`
INSERT INTO %q (id, rev_id, rev, doc)
err = tx.QueryRowContext(ctx, fmt.Sprintf(`
INSERT INTO %q (id, rev, rev_id, doc)
VALUES ($1, $2, $3, $4)
RETURNING rev_id || '-' || rev
`, d.name), docID, rev.id, rev.rev, jsonDoc).Scan(&newRev)
RETURNING rev || '-' || rev_id
`, d.name), docID, rev.rev, rev.id, jsonDoc).Scan(&newRev)
var sqliteErr *sqlite.Error
if errors.As(err, &sqliteErr) && sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE {
// In the case of a conflict for new_edits=false, we assume that the
// documents are identical, for the sake of idempotency, and return
// the current rev, to match CouchDB behavior.
return docRev, nil
}
return newRev, err
}

tx, err := d.db.BeginTx(ctx, nil)
if err != nil {
return "", err
if err != nil {
return "", err
}
return newRev, tx.Commit()
}
defer tx.Rollback()

var curRev string
err = tx.QueryRowContext(ctx, fmt.Sprintf(`
SELECT COALESCE(MAX(rev_id || '-' || rev),'')
SELECT COALESCE(MAX(rev || '-' || rev_id),'')
FROM %q
WHERE id = $1
`, d.name), docID).Scan(&curRev)
Expand All @@ -108,29 +124,36 @@ func (d *db) Put(ctx context.Context, docID string, doc interface{}, options dri
if curRev != docRev {
return "", &internal.Error{Status: http.StatusConflict, Message: "conflict"}
}
var newRev string
var r revision
err = tx.QueryRowContext(ctx, fmt.Sprintf(`
INSERT INTO %[1]q (id, rev_id, rev, doc)
SELECT $1, COALESCE(MAX(rev_id),0) + 1, $2, $3
INSERT INTO %[1]q (id, rev, rev_id)
SELECT $1, COALESCE(MAX(rev),0) + 1, $2
FROM %[1]q
WHERE id = $1
RETURNING rev_id || '-' || rev
`, d.name), docID, rev, jsonDoc).Scan(&newRev)
RETURNING rev, rev_id
`, d.name+"_revs"), docID, rev).Scan(&r.rev, &r.id)
if err != nil {
return "", err
}
_, err = tx.ExecContext(ctx, fmt.Sprintf(`
INSERT INTO %[1]q (id, rev, rev_id, doc)
VALUES ($1, $2, $3, $4)
`, d.name), docID, r.rev, r.id, jsonDoc)
if err != nil {
return "", err
}
return newRev, tx.Commit()
return r.String(), tx.Commit()
}

func (d *db) Get(ctx context.Context, id string, options driver.Options) (*driver.Document, error) {
opts := map[string]interface{}{}
options.Apply(opts)

var rev, body string
var r revision
var body string
var err error

if optsRev, _ := opts["rev"].(string); optsRev != "" {
var r revision
r, err = parseRev(optsRev)
if err != nil {
return nil, err
Expand All @@ -139,19 +162,21 @@ func (d *db) Get(ctx context.Context, id string, options driver.Options) (*drive
SELECT doc
FROM %q
WHERE id = $1
AND rev_id = $2
AND rev = $3
`, d.name), id, r.id, r.rev).Scan(&body)
rev = optsRev
AND rev = $2
AND rev_id = $3
`, d.name), id, r.rev, r.id).Scan(&body)
} else {
var rev int
var revID string
err = d.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT rev_id || '-' || rev, doc
SELECT rev, rev_id, doc
FROM %q
WHERE id = $1
AND deleted = FALSE
ORDER BY rev_id DESC, rev DESC
ORDER BY rev DESC, rev_id DESC
LIMIT 1
`, d.name), id).Scan(&rev, &body)
`, d.name), id).Scan(&rev, &revID, &body)
r = revision{rev: rev, id: revID}
}

if errors.Is(err, sql.ErrNoRows) {
Expand All @@ -164,12 +189,12 @@ func (d *db) Get(ctx context.Context, id string, options driver.Options) (*drive
if conflicts, _ := opts["conflicts"].(bool); conflicts {
var revs []string
rows, err := d.db.QueryContext(ctx, fmt.Sprintf(`
SELECT rev_id || '-' || rev
SELECT rev || '-' || rev_id
FROM %q
WHERE id = $1
AND rev_id || '-' || rev != $2
AND NOT (rev = $2 AND rev_id = $3)
AND DELETED = FALSE
`, d.name), id, rev)
`, d.name), id, r.rev, r.id)
if err != nil {
return nil, err
}
Expand All @@ -196,7 +221,7 @@ func (d *db) Get(ctx context.Context, id string, options driver.Options) (*drive
body = string(jonDoc)
}
return &driver.Document{
Rev: rev,
Rev: r.String(),
Body: io.NopCloser(strings.NewReader(body)),
}, nil
}
Expand Down
101 changes: 95 additions & 6 deletions x/sqlite/db_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,53 @@ package sqlite

import (
"context"
"database/sql"
"encoding/json"
"net/http"
"testing"

"github.com/google/go-cmp/cmp"
"gitlab.com/flimzy/testy"

"github.com/go-kivik/kivik/v4"
"github.com/go-kivik/kivik/v4/driver"
"github.com/go-kivik/kivik/v4/internal/mock"
)

type leaf struct {
ID string
Rev int
RevID string
ParentRev *int
ParentRevID *string
}

func readRevisions(t *testing.T, db *sql.DB, id string) []leaf {
t.Helper()
rows, err := db.Query(`
SELECT id, rev, rev_id, parent_rev, parent_rev_id
FROM "test_revs"
WHERE id=$1
ORDER BY rev, rev_id
`, id)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var leaves []leaf
for rows.Next() {
var l leaf
if err := rows.Scan(&l.ID, &l.Rev, &l.RevID, &l.ParentRev, &l.ParentRevID); err != nil {
t.Fatal(err)
}
leaves = append(leaves, l)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return leaves
}

func TestDBPut(t *testing.T) {
t.Parallel()
tests := []struct {
Expand All @@ -38,6 +74,7 @@ func TestDBPut(t *testing.T) {
options driver.Options
check func(*testing.T, driver.DB)
wantRev string
wantRevs []leaf
wantStatus int
wantErr string
}{
Expand All @@ -48,6 +85,13 @@ func TestDBPut(t *testing.T) {
"foo": "bar",
},
wantRev: "1-9bb58f26192e4ba00f01e2e7b136bbd8",
wantRevs: []leaf{
{
ID: "foo",
Rev: 1,
RevID: "9bb58f26192e4ba00f01e2e7b136bbd8",
},
},
},
{
name: "doc rev & option rev mismatch",
Expand Down Expand Up @@ -113,6 +157,18 @@ func TestDBPut(t *testing.T) {
"foo": "baz",
},
wantRev: "2-afa7ae8a1906f4bb061be63525974f92",
wantRevs: []leaf{
{
ID: "foo",
Rev: 1,
RevID: "9bb58f26192e4ba00f01e2e7b136bbd8",
},
{
ID: "foo",
Rev: 2,
RevID: "afa7ae8a1906f4bb061be63525974f92",
},
},
},
{
name: "update doc with new_edits=false, no existing doc",
Expand All @@ -123,6 +179,13 @@ func TestDBPut(t *testing.T) {
},
options: kivik.Param("new_edits", false),
wantRev: "1-6fe51f74859f3579abaccc426dd5104f",
wantRevs: []leaf{
{
ID: "foo",
Rev: 1,
RevID: "6fe51f74859f3579abaccc426dd5104f",
},
},
},
{
name: "update doc with new_edits=false, no rev",
Expand All @@ -149,6 +212,18 @@ func TestDBPut(t *testing.T) {
},
options: kivik.Param("new_edits", false),
wantRev: "1-asdf",
wantRevs: []leaf{
{
ID: "foo",
Rev: 1,
RevID: "9bb58f26192e4ba00f01e2e7b136bbd8",
},
{
ID: "foo",
Rev: 1,
RevID: "asdf",
},
},
},
{
name: "update doc with new_edits=false, existing doc and rev",
Expand All @@ -171,15 +246,22 @@ func TestDBPut(t *testing.T) {
SELECT doc
FROM test
WHERE id='foo'
AND rev_id=1
AND rev='9bb58f26192e4ba00f01e2e7b136bbd8'`).Scan(&doc)
AND rev=1
AND rev_id='9bb58f26192e4ba00f01e2e7b136bbd8'`).Scan(&doc)
if err != nil {
t.Fatal(err)
}
if doc != `{"foo":"bar"}` {
t.Errorf("Unexpected doc: %s", doc)
}
},
wantRevs: []leaf{
{
ID: "foo",
Rev: 1,
RevID: "9bb58f26192e4ba00f01e2e7b136bbd8",
},
},
},
{
name: "doc id in url and doc differ",
Expand All @@ -197,27 +279,34 @@ func TestDBPut(t *testing.T) {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
db := newDB(t)
dbc := newDB(t)
if tt.setup != nil {
tt.setup(t, db)
tt.setup(t, dbc)
}
opts := tt.options
if opts == nil {
opts = mock.NilOption
}
rev, err := db.Put(context.Background(), tt.docID, tt.doc, opts)
rev, err := dbc.Put(context.Background(), tt.docID, tt.doc, opts)
if !testy.ErrorMatches(tt.wantErr, err) {
t.Errorf("Unexpected error: %s", err)
}
if tt.check != nil {
tt.check(t, db)
tt.check(t, dbc)
}
if err != nil {
return
}
if rev != tt.wantRev {
t.Errorf("Unexpected rev: %s, want %s", rev, tt.wantRev)
}
if len(tt.wantRevs) == 0 {
t.Errorf("No leaves to check")
}
leaves := readRevisions(t, dbc.(*db).db, tt.docID)
if d := cmp.Diff(tt.wantRevs, leaves); d != "" {
t.Errorf("Unexpected leaves: %s", d)
}
})
}
}
Expand Down
10 changes: 5 additions & 5 deletions x/sqlite/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ import (
)

type revision struct {
id int
rev string
rev int
id string
}

func (r revision) String() string {
return strconv.Itoa(r.id) + "-" + r.rev
return strconv.Itoa(r.rev) + "-" + r.id
}

func parseRev(s string) (revision, error) {
Expand All @@ -46,9 +46,9 @@ func parseRev(s string) (revision, error) {
}
if len(parts) == 1 {
// A rev that contains only a number is technically valid.
return revision{id: int(id)}, nil
return revision{rev: int(id)}, nil
}
return revision{id: int(id), rev: parts[1]}, nil
return revision{rev: int(id), id: parts[1]}, nil
}

// prepareDoc prepares the doc for insertion. It returns the new docID, rev, and
Expand Down
Loading

0 comments on commit 03eaf6c

Please sign in to comment.