Skip to content

Commit

Permalink
Merge pull request #897 from go-kivik/putAttachment
Browse files Browse the repository at this point in the history
Begin PutAttachment support
  • Loading branch information
flimzy authored Feb 26, 2024
2 parents 0388fde + a1a5a19 commit 9022b7d
Show file tree
Hide file tree
Showing 9 changed files with 503 additions and 99 deletions.
8 changes: 0 additions & 8 deletions x/sqlite/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,6 @@ type db struct {

var _ driver.DB = (*db)(nil)

func (db) CreateDoc(context.Context, interface{}, driver.Options) (string, string, error) {
return "", "", nil
}

func (db) Stats(context.Context) (*driver.DBStats, error) {
return nil, nil
}
Expand All @@ -50,10 +46,6 @@ func (db) Changes(context.Context, driver.Options) (driver.Changes, error) {
return nil, nil
}

func (db) PutAttachment(context.Context, string, *driver.Attachment, driver.Options) (string, error) {
return "", nil
}

func (db) GetAttachment(context.Context, string, string, driver.Options) (*driver.Attachment, error) {
return nil, nil
}
Expand Down
37 changes: 37 additions & 0 deletions x/sqlite/get_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ package sqlite
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"

"gitlab.com/flimzy/testy"
Expand Down Expand Up @@ -1022,6 +1024,41 @@ func TestDBGet(t *testing.T) {
},
},
})
tests.Add("after PutAttachment", test{
setup: func(t *testing.T, d driver.DB) {
_, err := d.Put(context.Background(), "foo", map[string]string{
"foo": "aaa",
}, mock.NilOption)
if err != nil {
t.Fatal(err)
}
att := driver.Attachment{
ContentType: "text/plain",
Filename: "att.txt",
Content: io.NopCloser(strings.NewReader("test")),
}

_, err = d.PutAttachment(context.Background(), "foo", &att, kivik.Rev("1-8655eafbc9513d4857258c6d48f40399"))
if err != nil {
t.Fatal(err)
}
},
id: "foo",
wantDoc: map[string]interface{}{
"_id": "foo",
"_rev": "2-8655eafbc9513d4857258c6d48f40399",
"foo": "aaa",
"_attachments": map[string]interface{}{
"att.txt": map[string]interface{}{
"content_type": "text/plain",
"digest": "md5-CY9rzUYh03PK3k6DJie09g==",
"revpos": float64(2),
"length": float64(4),
"stub": true,
},
},
},
})

/*
TODO:
Expand Down
9 changes: 6 additions & 3 deletions x/sqlite/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,15 @@ type attachment struct {
Content []byte `json:"-"`
}

// calculate calculates the length, digest, and content of the attachment.
func (a *attachment) calculate(filename string) error {
if a.Data == nil {
if a.Data == nil && len(a.Content) == 0 {
return &internal.Error{Status: http.StatusBadRequest, Err: fmt.Errorf("invalid attachment data for %q", filename)}
}
if err := json.Unmarshal(a.Data, &a.Content); err != nil {
return &internal.Error{Status: http.StatusBadRequest, Err: fmt.Errorf("invalid attachment data for %q: %w", filename, err)}
if len(a.Content) == 0 {
if err := json.Unmarshal(a.Data, &a.Content); err != nil {
return &internal.Error{Status: http.StatusBadRequest, Err: fmt.Errorf("invalid attachment data for %q: %w", filename, err)}
}
}
a.Length = int64(len(a.Content))
h := md5.New()
Expand Down
5 changes: 5 additions & 0 deletions x/sqlite/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,8 @@ func (o optsMap) startKey() string {
}
return ""
}

func (o optsMap) rev() string {
rev, _ := o["rev"].(string)
return rev
}
89 changes: 2 additions & 87 deletions x/sqlite/put.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import (
"errors"
"fmt"
"net/http"
"sort"

"github.com/go-kivik/kivik/v4/driver"
"github.com/go-kivik/kivik/v4/internal"
Expand Down Expand Up @@ -145,98 +144,14 @@ func (d *db) Put(ctx context.Context, docID string, doc interface{}, options dri
return newRev, tx.Commit()
}

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), docID).Scan(&curRev.rev, &curRev.id)
curRev, err := d.currentRev(ctx, tx, docID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
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, $5)
`, d.name), data.ID, r.rev, r.id, data.Doc, data.Deleted)
if err != nil {
return "", err
}

if len(data.Attachments) == 0 {
return r.String(), tx.Commit()
}

// order the filenames to insert for consistency
orderedFilenames := make([]string, 0, len(data.Attachments))
for filename := range data.Attachments {
orderedFilenames = append(orderedFilenames, filename)
}
sort.Strings(orderedFilenames)

stmt, err := tx.PrepareContext(ctx, fmt.Sprintf(`
INSERT INTO %[1]q (id, rev, rev_id, filename, content_type, length, digest, data)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, d.name+"_attachments"))
if err != nil {
return "", err
}
defer stmt.Close()
for _, filename := range orderedFilenames {
att := data.Attachments[filename]
if att.Stub {
continue
}
if err := att.calculate(filename); err != nil {
return "", err
}
contentType := att.ContentType
if contentType == "" {
contentType = "application/octet-stream"
}
_, err := stmt.ExecContext(ctx, data.ID, r.rev, r.id, filename, contentType, att.Length, att.Digest, att.Content)
if err != nil {
return "", err
}
}

// Delete any attachments not included in the new revision
args := []interface{}{r.rev, r.id, data.ID}
for _, filename := range orderedFilenames {
args = append(args, filename)
}
query := fmt.Sprintf(`
UPDATE %[1]q
SET deleted_rev = $1, deleted_rev_id = $2
WHERE id = $3
AND filename NOT IN (`+placeholders(len(args)-len(orderedFilenames)+1, len(orderedFilenames))+`)
AND deleted_rev IS NULL
AND deleted_rev_id IS NULL
`, d.name+"_attachments")
_, err = tx.ExecContext(ctx, query, args...)
r, err := d.createRev(ctx, tx, data, curRev)
if err != nil {
return "", err
}
Expand Down
6 changes: 5 additions & 1 deletion x/sqlite/put_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ func TestDBPut(t *testing.T) {
"new_edits": false,
"rev": "1-abc",
}),
wantStatus: http.StatusConflict,
wantStatus: http.StatusBadRequest,
wantErr: "Document rev and option have different values",
})
tests.Add("new_edits=false, with _revisions replayed", test{
Expand Down Expand Up @@ -915,6 +915,7 @@ func TestDBPut(t *testing.T) {
})
/*
TODO:
- update conflicting leaf
- delete attachments only in one branch of a document
- Omit attachments to delete
- Include stub to update doc without deleting attachments
Expand All @@ -939,6 +940,9 @@ func TestDBPut(t *testing.T) {
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 tt.check != nil {
tt.check(t, dbc)
}
Expand Down
78 changes: 78 additions & 0 deletions x/sqlite/putattachment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// 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"
"io"
"net/http"

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

// revIDEmpty is the revision ID for an empty document, i.e. `{}`
const revIDEmpty = "99914b932bd37a50b983c5e7c90ae93b"

func (d *db) PutAttachment(ctx context.Context, docID string, att *driver.Attachment, options driver.Options) (string, error) {
opts := newOpts(options)

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

data := &docData{
ID: docID,
}

curRev, err := d.currentRev(ctx, tx, docID)
switch {
case errors.Is(err, sql.ErrNoRows):
data.RevID = revIDEmpty
data.Doc = []byte("{}")
case err != nil:
return "", err
default:
data.RevID = curRev.id
}

if rev := opts.rev(); rev != "" && rev != curRev.String() {
return "", &internal.Error{Status: http.StatusConflict, Message: "conflict"}
}

content, err := io.ReadAll(att.Content)
if err != nil {
return "", err
}
file := attachment{
ContentType: att.ContentType,
Content: content,
}
if err := file.calculate(att.Filename); err != nil {
return "", err
}
data.Attachments = map[string]attachment{
att.Filename: file,
}

r, err := d.createRev(ctx, tx, data, curRev)
if err != nil {
return "", err
}

return r.String(), tx.Commit()
}
Loading

0 comments on commit 9022b7d

Please sign in to comment.