diff --git a/x/sqlite/db.go b/x/sqlite/db.go index 0e4292d53..17817ef45 100644 --- a/x/sqlite/db.go +++ b/x/sqlite/db.go @@ -15,12 +15,8 @@ package sqlite import ( "context" "database/sql" - "errors" - "fmt" - "net/http" "github.com/go-kivik/kivik/v4/driver" - "github.com/go-kivik/kivik/v4/internal" ) type db struct { @@ -38,68 +34,6 @@ func (db) CreateDoc(context.Context, interface{}, driver.Options) (string, strin return "", "", nil } -func (d *db) Delete(ctx context.Context, docID string, options driver.Options) (string, error) { - opts := map[string]interface{}{} - options.Apply(opts) - docRev, _ := opts["rev"].(string) - - data, err := prepareDoc(docID, map[string]interface{}{"_deleted": true}) - if err != nil { - return "", err - } - - tx, err := d.db.BeginTx(ctx, nil) - if err != nil { - return "", err - } - defer tx.Rollback() - - var curRev revision - err = tx.QueryRowContext(ctx, fmt.Sprintf(` - SELECT rev, rev_id - FROM %q - WHERE id = $1 - ORDER BY rev DESC, rev_id DESC - LIMIT 1 - `, d.name), data.ID).Scan(&curRev.rev, &curRev.id) - switch { - case errors.Is(err, sql.ErrNoRows): - return "", &internal.Error{Status: http.StatusNotFound, Message: "not found"} - case err != nil: - return "", err - } - if curRev.String() != docRev { - return "", &internal.Error{Status: http.StatusConflict, Message: "conflict"} - } - var ( - r revision - curRevRev *int - curRevID *string - ) - if curRev.rev != 0 { - curRevRev = &curRev.rev - curRevID = &curRev.id - } - err = tx.QueryRowContext(ctx, fmt.Sprintf(` - INSERT INTO %[1]q (id, rev, rev_id, parent_rev, parent_rev_id) - SELECT $1, COALESCE(MAX(rev),0) + 1, $2, $3, $4 - FROM %[1]q - WHERE id = $1 - RETURNING rev, rev_id - `, d.name+"_revs"), data.ID, data.RevID, curRevRev, curRevID).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, deleted) - VALUES ($1, $2, $3, $4, TRUE) - `, d.name), data.ID, r.rev, r.id, data.Doc) - if err != nil { - return "", err - } - return r.String(), tx.Commit() -} - func (db) Stats(context.Context) (*driver.DBStats, error) { return nil, nil } diff --git a/x/sqlite/db_test.go b/x/sqlite/db_test.go index 183c3c0ec..7ada0088a 100644 --- a/x/sqlite/db_test.go +++ b/x/sqlite/db_test.go @@ -16,16 +16,8 @@ package sqlite import ( - "context" "database/sql" - "net/http" "testing" - - "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 { @@ -61,129 +53,3 @@ func readRevisions(t *testing.T, db *sql.DB, id string) []leaf { } return leaves } - -func TestDBDelete(t *testing.T) { - t.Parallel() - tests := []struct { - name string - setup func(*testing.T, driver.DB) - id string - options driver.Options - wantRev string - check func(*testing.T, driver.DB) - wantStatus int - wantErr string - }{ - { - name: "not found", - id: "foo", - wantStatus: http.StatusNotFound, - wantErr: "not found", - }, - { - name: "success", - setup: func(t *testing.T, d driver.DB) { - _, err := d.Put(context.Background(), "foo", map[string]string{"foo": "bar"}, mock.NilOption) - if err != nil { - t.Fatal(err) - } - }, - id: "foo", - options: kivik.Rev("1-9bb58f26192e4ba00f01e2e7b136bbd8"), - wantRev: "2-df2a4fe30cde39c357c8d1105748d1b9", - check: func(t *testing.T, d driver.DB) { - var deleted bool - err := d.(*db).db.QueryRow(` - SELECT deleted - FROM test - WHERE id='foo' - ORDER BY rev DESC, rev_id DESC - LIMIT 1 - `).Scan(&deleted) - if err != nil { - t.Fatal(err) - } - if !deleted { - t.Errorf("Document not marked deleted") - } - }, - }, - { - name: "replay delete should conflict", - setup: func(t *testing.T, d driver.DB) { - rev, err := d.Put(context.Background(), "foo", map[string]string{"foo": "bar"}, mock.NilOption) - if err != nil { - t.Fatal(err) - } - _, err = d.Delete(context.Background(), "foo", kivik.Rev(rev)) - if err != nil { - t.Fatal(err) - } - }, - id: "foo", - options: kivik.Rev("1-9bb58f26192e4ba00f01e2e7b136bbd8"), - wantStatus: http.StatusConflict, - wantErr: "conflict", - }, - { - name: "delete deleted doc should succeed", - setup: func(t *testing.T, d driver.DB) { - rev, err := d.Put(context.Background(), "foo", map[string]string{"foo": "bar"}, mock.NilOption) - if err != nil { - t.Fatal(err) - } - _, err = d.Delete(context.Background(), "foo", kivik.Rev(rev)) - if err != nil { - t.Fatal(err) - } - }, - id: "foo", - options: kivik.Rev("2-df2a4fe30cde39c357c8d1105748d1b9"), - wantRev: "3-df2a4fe30cde39c357c8d1105748d1b9", - }, - { - name: "delete without rev", - setup: func(t *testing.T, d driver.DB) { - _, err := d.Put(context.Background(), "foo", map[string]string{"foo": "bar"}, mock.NilOption) - if err != nil { - t.Fatal(err) - } - }, - id: "foo", - wantStatus: http.StatusConflict, - wantErr: "conflict", - }, - /* _revisions */ - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - db := newDB(t) - if tt.setup != nil { - tt.setup(t, db) - } - opts := tt.options - if opts == nil { - opts = mock.NilOption - } - rev, err := db.Delete(context.Background(), tt.id, opts) - if !testy.ErrorMatches(tt.wantErr, err) { - t.Errorf("Unexpected error: %s", err) - } - if status := kivik.HTTPStatus(err); status != tt.wantStatus { - t.Errorf("Unexpected status: %d", status) - } - if err != nil { - return - } - if rev != tt.wantRev { - t.Errorf("Unexpected rev: %s", rev) - } - if tt.check != nil { - tt.check(t, db) - } - }) - } -} diff --git a/x/sqlite/delete.go b/x/sqlite/delete.go new file mode 100644 index 000000000..28ddd221e --- /dev/null +++ b/x/sqlite/delete.go @@ -0,0 +1,89 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); you may not +// use this file except in compliance with the License. You may obtain a copy of +// the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations under +// the License. + +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/http" + + "github.com/go-kivik/kivik/v4/driver" + "github.com/go-kivik/kivik/v4/internal" +) + +func (d *db) Delete(ctx context.Context, docID string, options driver.Options) (string, error) { + opts := map[string]interface{}{} + options.Apply(opts) + optRev, ok := opts["rev"].(string) + if !ok { + // Special case: No rev for DELETE is always a conflict, since you can't + // delete a doc without a rev. + return "", &internal.Error{Status: http.StatusConflict, Message: "conflict"} + } + delRev, err := parseRev(optRev) + if err != nil { + return "", err + } + + data, err := prepareDoc(docID, map[string]interface{}{"_deleted": true}) + if err != nil { + return "", err + } + + tx, err := d.db.BeginTx(ctx, nil) + if err != nil { + return "", err + } + defer tx.Rollback() + + var found bool + err = tx.QueryRowContext(ctx, fmt.Sprintf(` + SELECT child.id IS NULL + FROM %[2]q AS rev + LEFT JOIN %[2]q AS child ON rev.id = child.id AND rev.rev = child.parent_rev AND rev.rev_id = child.parent_rev_id + JOIN %[1]q AS doc ON rev.id = doc.id AND rev.rev = doc.rev AND rev.rev_id = doc.rev_id + WHERE rev.id = $1 + AND rev.rev = $2 + AND rev.rev_id = $3 + `, d.name, d.name+"_revs"), data.ID, delRev.rev, delRev.id).Scan(&found) + switch { + case errors.Is(err, sql.ErrNoRows): + return "", &internal.Error{Status: http.StatusNotFound, Message: "not found"} + case err != nil: + return "", err + } + if !found { + return "", &internal.Error{Status: http.StatusConflict, Message: "conflict"} + } + var r revision + err = tx.QueryRowContext(ctx, fmt.Sprintf(` + INSERT INTO %[1]q (id, rev, rev_id, parent_rev, parent_rev_id) + SELECT $1, COALESCE(MAX(rev),0) + 1, $2, $3, $4 + FROM %[1]q + WHERE id = $1 + RETURNING rev, rev_id + `, d.name+"_revs"), data.ID, data.RevID, delRev.rev, delRev.id).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, deleted) + VALUES ($1, $2, $3, $4, TRUE) + `, d.name), data.ID, r.rev, r.id, data.Doc) + if err != nil { + return "", err + } + return r.String(), tx.Commit() +} diff --git a/x/sqlite/delete_test.go b/x/sqlite/delete_test.go new file mode 100644 index 000000000..27445fb12 --- /dev/null +++ b/x/sqlite/delete_test.go @@ -0,0 +1,186 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); you may not +// use this file except in compliance with the License. You may obtain a copy of +// the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations under +// the License. + +//go:build !js +// +build !js + +package sqlite + +import ( + "context" + "net/http" + "testing" + + "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" +) + +func TestDBDelete(t *testing.T) { + t.Parallel() + tests := []struct { + name string + setup func(*testing.T, driver.DB) + id string + options driver.Options + wantRev string + check func(*testing.T, driver.DB) + wantStatus int + wantErr string + }{ + { + name: "not found", + id: "foo", + options: kivik.Rev("1-9bb58f26192e4ba00f01e2e7b136bbd8"), + wantStatus: http.StatusNotFound, + wantErr: "not found", + }, + { + name: "success", + setup: func(t *testing.T, d driver.DB) { + _, err := d.Put(context.Background(), "foo", map[string]string{"foo": "bar"}, mock.NilOption) + if err != nil { + t.Fatal(err) + } + }, + id: "foo", + options: kivik.Rev("1-9bb58f26192e4ba00f01e2e7b136bbd8"), + wantRev: "2-df2a4fe30cde39c357c8d1105748d1b9", + check: func(t *testing.T, d driver.DB) { + var deleted bool + err := d.(*db).db.QueryRow(` + SELECT deleted + FROM test + WHERE id='foo' + ORDER BY rev DESC, rev_id DESC + LIMIT 1 + `).Scan(&deleted) + if err != nil { + t.Fatal(err) + } + if !deleted { + t.Errorf("Document not marked deleted") + } + }, + }, + { + name: "replay delete should conflict", + setup: func(t *testing.T, d driver.DB) { + rev, err := d.Put(context.Background(), "foo", map[string]string{"foo": "bar"}, mock.NilOption) + if err != nil { + t.Fatal(err) + } + _, err = d.Delete(context.Background(), "foo", kivik.Rev(rev)) + if err != nil { + t.Fatal(err) + } + }, + id: "foo", + options: kivik.Rev("1-9bb58f26192e4ba00f01e2e7b136bbd8"), + wantStatus: http.StatusConflict, + wantErr: "conflict", + }, + { + name: "delete deleted doc should succeed", + setup: func(t *testing.T, d driver.DB) { + rev, err := d.Put(context.Background(), "foo", map[string]string{"foo": "bar"}, mock.NilOption) + if err != nil { + t.Fatal(err) + } + _, err = d.Delete(context.Background(), "foo", kivik.Rev(rev)) + if err != nil { + t.Fatal(err) + } + }, + id: "foo", + options: kivik.Rev("2-df2a4fe30cde39c357c8d1105748d1b9"), + wantRev: "3-df2a4fe30cde39c357c8d1105748d1b9", + }, + { + name: "delete without rev", + setup: func(t *testing.T, d driver.DB) { + _, err := d.Put(context.Background(), "foo", map[string]string{"foo": "bar"}, mock.NilOption) + if err != nil { + t.Fatal(err) + } + }, + id: "foo", + wantStatus: http.StatusConflict, + wantErr: "conflict", + }, + { + name: "delete losing rev for conflict should succeed", + setup: func(t *testing.T, db driver.DB) { + _, err := db.Put(context.Background(), "foo", map[string]string{ + "cat": "meow", + "_rev": "1-xxx", + }, kivik.Param("new_edits", false)) + if err != nil { + t.Fatal(err) + } + _, err = db.Put(context.Background(), "foo", map[string]string{ + "cat": "purr", + "_rev": "1-aaa", + }, kivik.Param("new_edits", false)) + if err != nil { + t.Fatal(err) + } + }, + id: "foo", + options: kivik.Rev("1-aaa"), + wantRev: "2-df2a4fe30cde39c357c8d1105748d1b9", + }, + { + name: "invalid rev format", + id: "foo", + options: kivik.Rev("not a rev"), + wantStatus: http.StatusBadRequest, + wantErr: `strconv.ParseInt: parsing "not a rev": invalid syntax`, + }, + /* + - _revisions + */ + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + db := newDB(t) + if tt.setup != nil { + tt.setup(t, db) + } + opts := tt.options + if opts == nil { + opts = mock.NilOption + } + rev, err := db.Delete(context.Background(), tt.id, opts) + if !testy.ErrorMatches(tt.wantErr, err) { + t.Errorf("Unexpected error: %s", err) + } + if status := kivik.HTTPStatus(err); status != tt.wantStatus { + t.Errorf("Unexpected status: %d", status) + } + if err != nil { + return + } + if rev != tt.wantRev { + t.Errorf("Unexpected rev: %s", rev) + } + if tt.check != nil { + tt.check(t, db) + } + }) + } +}