mirror of
https://github.com/ent/ent.git
synced 2026-05-24 09:31:56 +03:00
2167 lines
72 KiB
Go
2167 lines
72 KiB
Go
// Copyright 2019-present Facebook Inc. All rights reserved.
|
|
// This source code is licensed under the Apache 2.0 license found
|
|
// in the LICENSE file in the root directory of this source tree.
|
|
|
|
package integration
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"entgo.io/ent/dialect"
|
|
entsql "entgo.io/ent/dialect/sql"
|
|
"entgo.io/ent/entc/integration/blob"
|
|
"entgo.io/ent/entc/integration/ent"
|
|
"entgo.io/ent/entc/integration/ent/document"
|
|
"entgo.io/ent/entc/integration/ent/enttest"
|
|
"entgo.io/ent/entc/integration/ent/migrate"
|
|
"entgo.io/ent/entc/integration/ent/schema"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
"github.com/stretchr/testify/require"
|
|
_ "gocloud.dev/blob/fileblob"
|
|
)
|
|
|
|
// readBlob reads all data from a blob field reader and closes it.
|
|
func readBlob(t *testing.T, rc io.ReadCloser, err error) []byte {
|
|
t.Helper()
|
|
require.NoError(t, err)
|
|
if rc == nil {
|
|
return nil
|
|
}
|
|
data, readErr := io.ReadAll(rc)
|
|
require.NoError(t, readErr)
|
|
require.NoError(t, rc.Close())
|
|
return data
|
|
}
|
|
|
|
// blobContent is a shorthand for readBlob(t, entity.Field(ctx)).
|
|
func blobContent(t *testing.T, fn func(context.Context) (io.ReadCloser, error), ctx context.Context) []byte {
|
|
t.Helper()
|
|
rc, err := fn(ctx)
|
|
return readBlob(t, rc, err)
|
|
}
|
|
|
|
// blobDir creates a temp directory with subdirectories for blob fields.
|
|
func blobDir(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "documents"), 0o755))
|
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "thumbnails"), 0o755))
|
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "attachments"), 0o755))
|
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "metadata"), 0o755))
|
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "payloads"), 0o755))
|
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "descriptions"), 0o755))
|
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "archives"), 0o755))
|
|
return dir
|
|
}
|
|
|
|
// newBlobOpeners returns BlobOpeners that route to the correct
|
|
// file:// bucket based on the field name, using absolute paths under dir.
|
|
func newBlobOpeners(dir string) ent.BlobOpeners {
|
|
return ent.BlobOpeners{
|
|
Document: func(ctx context.Context, field string) (ent.Blob, error) {
|
|
switch field {
|
|
case document.FieldContent:
|
|
return blob.OpenBucket(ctx, "file://"+filepath.Join(dir, "documents"))
|
|
case document.FieldThumbnail:
|
|
return blob.OpenBucket(ctx, "file://"+filepath.Join(dir, "thumbnails"))
|
|
case document.FieldAttachment:
|
|
return blob.OpenBucket(ctx, "file://"+filepath.Join(dir, "attachments"))
|
|
case document.FieldMetadata:
|
|
return blob.OpenBucket(ctx, "file://"+filepath.Join(dir, "metadata"))
|
|
case document.FieldPayload:
|
|
return blob.OpenBucket(ctx, "file://"+filepath.Join(dir, "payloads"))
|
|
case document.FieldDescription:
|
|
return blob.OpenBucket(ctx, "file://"+filepath.Join(dir, "descriptions"))
|
|
case document.FieldArchive:
|
|
return blob.OpenBucket(ctx, "file://"+filepath.Join(dir, "archives"))
|
|
default:
|
|
return nil, fmt.Errorf("unknown blob field: %s", field)
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
// setupBlob creates a temp directory with subdirectories for each blob field,
|
|
// opens an in-memory SQLite client with auto-migration, and registers cleanup.
|
|
func setupBlob(t *testing.T, opts ...ent.Option) (*ent.Client, context.Context, string) {
|
|
t.Helper()
|
|
dir := blobDir(t)
|
|
allOpts := append([]ent.Option{ent.WithBlobOpeners(newBlobOpeners(dir))}, opts...)
|
|
entOpts := []enttest.Option{
|
|
enttest.WithMigrateOptions(migrate.WithDropIndex(true), migrate.WithDropColumn(true)),
|
|
enttest.WithOptions(allOpts...),
|
|
}
|
|
client := enttest.Open(t, dialect.SQLite, "file:ent?mode=memory&cache=shared&_fk=1",
|
|
entOpts...,
|
|
)
|
|
t.Cleanup(func() {
|
|
client.Close()
|
|
})
|
|
return client, context.Background(), dir
|
|
}
|
|
|
|
func TestBlobCreateAndRead(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
data := []byte("Hello from blob integration test!")
|
|
doc := client.Document.Create().
|
|
SetName("test-doc").
|
|
SetContent(bytes.NewReader(data)).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SaveX(ctx)
|
|
|
|
// Read the blob back through the entity method.
|
|
got := blobContent(t, doc.ContentReader, ctx)
|
|
require.Equal(t, data, got)
|
|
}
|
|
|
|
func TestBlobQueryAndRead(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
data := []byte("queried blob content")
|
|
created := client.Document.Create().
|
|
SetName("query-doc").
|
|
SetContent(bytes.NewReader(data)).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SaveX(ctx)
|
|
|
|
// Query it back from the database (no content column, just ID/name).
|
|
queried := client.Document.GetX(ctx, created.ID)
|
|
|
|
// Read blob content from the queried entity.
|
|
got := blobContent(t, queried.ContentReader, ctx)
|
|
require.Equal(t, data, got)
|
|
}
|
|
|
|
func TestBlobUpdateData(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
v1 := []byte("version 1")
|
|
doc := client.Document.Create().
|
|
SetName("update-doc").
|
|
SetContent(bytes.NewReader(v1)).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SaveX(ctx)
|
|
|
|
// Update blob data through mutation (overwrites same key).
|
|
v2 := []byte("version 2 - updated via mutation")
|
|
doc = doc.Update().
|
|
SetContent(bytes.NewReader(v2)).
|
|
SaveX(ctx)
|
|
|
|
// Read the new blob content.
|
|
got := blobContent(t, doc.ContentReader, ctx)
|
|
require.Equal(t, v2, got)
|
|
}
|
|
|
|
func TestBlobRequiredValidation(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
// Creating a document without required blob fields should fail.
|
|
_, err := client.Document.Create().
|
|
SetName("no-blob-doc").
|
|
Save(ctx)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "missing required field")
|
|
}
|
|
|
|
func TestBlobMultipleDocuments(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
contents := []string{
|
|
"document one content",
|
|
"document two content - larger payload with more data",
|
|
"document three",
|
|
}
|
|
|
|
var ids []int
|
|
for i, c := range contents {
|
|
doc := client.Document.Create().
|
|
SetName("multi-" + string(rune('a'+i))).
|
|
SetContent(strings.NewReader(c)).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SaveX(ctx)
|
|
ids = append(ids, doc.ID)
|
|
}
|
|
|
|
// Read each document and verify content is correct.
|
|
for i, id := range ids {
|
|
doc := client.Document.GetX(ctx, id)
|
|
got := blobContent(t, doc.ContentReader, ctx)
|
|
require.Equal(t, contents[i], string(got), "document %d content mismatch", i)
|
|
}
|
|
}
|
|
|
|
func TestBlobBulkCreate(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
bulk := make([]*ent.DocumentCreate, 5)
|
|
for i := range bulk {
|
|
bulk[i] = client.Document.Create().
|
|
SetName(strings.Repeat("bulk-", i+1)).
|
|
SetContent(bytes.NewReader([]byte(strings.Repeat("x", i+1)))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att"))
|
|
}
|
|
docs, err := client.Document.CreateBulk(bulk...).Save(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, docs, 5)
|
|
|
|
// Verify each document's blob can be read back with correct data.
|
|
for i, doc := range docs {
|
|
got := blobContent(t, doc.ContentReader, ctx)
|
|
require.Equal(t, []byte(strings.Repeat("x", i+1)), got)
|
|
}
|
|
}
|
|
|
|
func TestBlobThumbnailCreateAndRead(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
thumbData := []byte("fake-png-thumbnail-data")
|
|
doc := client.Document.Create().
|
|
SetName("doc-with-thumb").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader(thumbData)).
|
|
SetAttachment([]byte("att")).
|
|
SaveX(ctx)
|
|
|
|
got := blobContent(t, doc.ThumbnailReader, ctx)
|
|
require.Equal(t, thumbData, got)
|
|
}
|
|
|
|
func TestBlobBothFields(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
contentData := []byte("document body content")
|
|
thumbData := []byte("thumbnail image bytes")
|
|
doc := client.Document.Create().
|
|
SetName("doc-both").
|
|
SetContent(bytes.NewReader(contentData)).
|
|
SetThumbnail(bytes.NewReader(thumbData)).
|
|
SetAttachment([]byte("att")).
|
|
SaveX(ctx)
|
|
|
|
// Read content.
|
|
cGot := blobContent(t, doc.ContentReader, ctx)
|
|
require.Equal(t, contentData, cGot)
|
|
|
|
// Read thumbnail.
|
|
tGot := blobContent(t, doc.ThumbnailReader, ctx)
|
|
require.Equal(t, thumbData, tGot)
|
|
|
|
// Update only thumbnail, content should remain unchanged.
|
|
newThumb := []byte("updated thumbnail")
|
|
doc = doc.Update().
|
|
SetThumbnail(bytes.NewReader(newThumb)).
|
|
SetAttachment([]byte("att")).
|
|
SaveX(ctx)
|
|
|
|
cGot2 := blobContent(t, doc.ContentReader, ctx)
|
|
require.Equal(t, contentData, cGot2)
|
|
|
|
tGot2 := blobContent(t, doc.ThumbnailReader, ctx)
|
|
require.Equal(t, newThumb, tGot2)
|
|
}
|
|
|
|
func TestBlobBulkCreateBothFields(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
bulk := make([]*ent.DocumentCreate, 3)
|
|
for i := range bulk {
|
|
bulk[i] = client.Document.Create().
|
|
SetName(strings.Repeat("bulk-both-", i+1)).
|
|
SetContent(bytes.NewReader([]byte(strings.Repeat("c", i+1)))).
|
|
SetThumbnail(bytes.NewReader([]byte(strings.Repeat("t", i+1)))).
|
|
SetAttachment([]byte("att"))
|
|
}
|
|
docs, err := client.Document.CreateBulk(bulk...).Save(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, docs, 3)
|
|
|
|
for i, doc := range docs {
|
|
cGot := blobContent(t, doc.ContentReader, ctx)
|
|
require.Equal(t, []byte(strings.Repeat("c", i+1)), cGot)
|
|
|
|
tGot := blobContent(t, doc.ThumbnailReader, ctx)
|
|
require.Equal(t, []byte(strings.Repeat("t", i+1)), tGot)
|
|
}
|
|
}
|
|
|
|
func TestBlobReader(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
data := []byte("streaming read test data")
|
|
doc := client.Document.Create().
|
|
SetName("reader-doc").
|
|
SetContent(bytes.NewReader(data)).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SaveX(ctx)
|
|
|
|
// Read back via Content (io.ReadCloser).
|
|
r, err := doc.ContentReader(ctx)
|
|
require.NoError(t, err)
|
|
defer r.Close()
|
|
|
|
got, err := io.ReadAll(r)
|
|
require.NoError(t, err)
|
|
require.Equal(t, data, got)
|
|
}
|
|
|
|
func TestBlobOnConflict(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
// Create initial document.
|
|
doc := client.Document.Create().
|
|
SetName("conflict-doc").
|
|
SetContent(strings.NewReader("content-v1")).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb-v1"))).
|
|
SetAttachment([]byte("att-v1")).
|
|
SaveX(ctx)
|
|
|
|
// Read back initial content.
|
|
got := blobContent(t, doc.ContentReader, ctx)
|
|
require.Equal(t, []byte("content-v1"), got)
|
|
|
|
// Upsert with OnConflict — same name, new content.
|
|
// Because keys are hash-based, new content produces a new key,
|
|
// and ON CONFLICT UPDATE SET content_key = excluded.content_key
|
|
// points the row to the new blob.
|
|
doc2 := client.Document.Create().
|
|
SetName("conflict-doc").
|
|
SetContent(strings.NewReader("content-v2")).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb-v2"))).
|
|
SetAttachment([]byte("att-v2")).
|
|
OnConflictColumns(document.FieldName).
|
|
UpdateNewValues().
|
|
IDX(ctx)
|
|
|
|
// Verify the upsert updated the existing row (same ID).
|
|
require.Equal(t, doc.ID, doc2)
|
|
|
|
// Read back the updated blob content via a fresh query.
|
|
doc = client.Document.GetX(ctx, doc2)
|
|
got = blobContent(t, doc.ContentReader, ctx)
|
|
require.Equal(t, []byte("content-v2"), got)
|
|
|
|
// Thumbnail also updated.
|
|
got = blobContent(t, doc.ThumbnailReader, ctx)
|
|
require.Equal(t, []byte("thumb-v2"), got)
|
|
|
|
// Upsert with identical content — hash key is the same,
|
|
// blob write is idempotent, SQL updates key to same value (no-op).
|
|
doc3 := client.Document.Create().
|
|
SetName("conflict-doc").
|
|
SetContent(strings.NewReader("content-v2")).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb-v2"))).
|
|
SetAttachment([]byte("att-v2")).
|
|
OnConflictColumns(document.FieldName).
|
|
UpdateNewValues().
|
|
IDX(ctx)
|
|
|
|
require.Equal(t, doc.ID, doc3)
|
|
doc = client.Document.GetX(ctx, doc3)
|
|
got = blobContent(t, doc.ContentReader, ctx)
|
|
require.Equal(t, []byte("content-v2"), got)
|
|
|
|
// Upsert with per-field Update<Field>() — only update attachment.
|
|
err := client.Document.Create().
|
|
SetName("conflict-doc").
|
|
SetContent(strings.NewReader("content-v3")).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb-v3"))).
|
|
SetAttachment([]byte("att-v3")).
|
|
OnConflictColumns(document.FieldName).
|
|
UpdateAttachment().
|
|
Exec(ctx)
|
|
require.NoError(t, err)
|
|
|
|
// Attachment was updated, but content/thumbnail remain at v2.
|
|
doc = client.Document.Query().Where(document.Name("conflict-doc")).OnlyX(ctx)
|
|
require.Equal(t, []byte("att-v3"), doc.Attachment)
|
|
got = blobContent(t, doc.ContentReader, ctx)
|
|
require.Equal(t, []byte("content-v2"), got)
|
|
got = blobContent(t, doc.ThumbnailReader, ctx)
|
|
require.Equal(t, []byte("thumb-v2"), got)
|
|
}
|
|
|
|
func TestBlobEncryption(t *testing.T) {
|
|
// Demonstrate per-tenant encryption using a master seed + tenant from context.
|
|
// Each tenant derives a unique AES-256 key via SHA-256(tenant || seed).
|
|
// Data written by one tenant cannot be decrypted by another.
|
|
dir := blobDir(t)
|
|
|
|
// Master seed shared across all tenants (kept secret server-side).
|
|
masterSeed := []byte("super-secret-master-seed-for-test")
|
|
|
|
encryptedOpeners := ent.BlobOpeners{
|
|
Document: func(ctx context.Context, field string) (ent.Blob, error) {
|
|
var subdir string
|
|
switch field {
|
|
case document.FieldContent:
|
|
subdir = "documents"
|
|
case document.FieldThumbnail:
|
|
subdir = "thumbnails"
|
|
case document.FieldAttachment:
|
|
subdir = "attachments"
|
|
default:
|
|
return nil, fmt.Errorf("unknown blob field: %s", field)
|
|
}
|
|
b, err := blob.OpenBucket(ctx, "file://"+filepath.Join(dir, subdir))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Wrap the bucket with per-tenant encryption.
|
|
return blob.NewEncrypted(b, masterSeed), nil
|
|
},
|
|
}
|
|
|
|
// Tenant "acme" writes encrypted data.
|
|
client, _, _ := setupBlob(t, ent.WithBlobOpeners(encryptedOpeners))
|
|
acmeCtx := blob.WithTenant(context.Background(), "acme")
|
|
|
|
plaintext := []byte("top secret document content for acme")
|
|
doc := client.Document.Create().
|
|
SetName("encrypted-doc").
|
|
SetContent(bytes.NewReader(plaintext)).
|
|
SetThumbnail(bytes.NewReader([]byte("secret-thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SaveX(acmeCtx)
|
|
|
|
// Reading with the same tenant returns decrypted plaintext.
|
|
got := blobContent(t, doc.ContentReader, acmeCtx)
|
|
require.Equal(t, plaintext, got)
|
|
|
|
gotThumb := blobContent(t, doc.ThumbnailReader, acmeCtx)
|
|
require.Equal(t, []byte("secret-thumb"), gotThumb)
|
|
|
|
// A different tenant ("evil") cannot decrypt acme's data — the derived key
|
|
// differs, so AES-CTR produces garbage (not the original plaintext).
|
|
evilCtx := blob.WithTenant(context.Background(), "evil")
|
|
evilData := blobContent(t, doc.ContentReader, evilCtx)
|
|
require.NotEqual(t, plaintext, evilData, "different tenant must not read acme's plaintext")
|
|
|
|
// No tenant in context → error.
|
|
noTenantCtx := context.Background()
|
|
_, err := doc.ContentReader(noTenantCtx)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "requires a tenant")
|
|
|
|
// Update works through encryption too.
|
|
v2 := []byte("updated secret content")
|
|
doc = doc.Update().SetContent(bytes.NewReader(v2)).SaveX(acmeCtx)
|
|
got = blobContent(t, doc.ContentReader, acmeCtx)
|
|
require.Equal(t, v2, got)
|
|
}
|
|
|
|
func TestBlobPrefix(t *testing.T) {
|
|
dir := blobDir(t)
|
|
openers := newBlobOpeners(dir)
|
|
prefixedOpeners := ent.BlobOpeners{
|
|
Document: func(ctx context.Context, field string) (ent.Blob, error) {
|
|
if field == "content" {
|
|
b, err := blob.OpenBucket(ctx, "file://"+filepath.Join(dir, "documents"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return b.Prefixed("tenant-1/"), nil
|
|
}
|
|
return openers.Document(ctx, field)
|
|
},
|
|
}
|
|
client, ctx, _ := setupBlob(t, ent.WithBlobOpeners(prefixedOpeners))
|
|
|
|
data := []byte("prefixed blob content")
|
|
doc := client.Document.Create().
|
|
SetName("prefixed-doc").
|
|
SetContent(bytes.NewReader(data)).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SaveX(ctx)
|
|
|
|
// Read through the entity — uses the same opener.
|
|
got := blobContent(t, doc.ContentReader, ctx)
|
|
require.Equal(t, data, got)
|
|
|
|
// Create a second client with the default opener — reading should return nil (not found).
|
|
defaultClient := enttest.Open(t, dialect.SQLite, "file:ent?mode=memory&cache=shared&_fk=1",
|
|
enttest.WithOptions(ent.WithBlobOpeners(openers)),
|
|
)
|
|
t.Cleanup(func() { defaultClient.Close() })
|
|
doc2 := defaultClient.Document.Query().OnlyX(ctx)
|
|
got = blobContent(t, doc2.ContentReader, ctx)
|
|
require.Nil(t, got, "expected nil when reading without prefix")
|
|
}
|
|
|
|
func TestBlobPrefixUpdate(t *testing.T) {
|
|
dir := blobDir(t)
|
|
openers := newBlobOpeners(dir)
|
|
prefixedOpeners := ent.BlobOpeners{
|
|
Document: func(ctx context.Context, field string) (ent.Blob, error) {
|
|
if field == "content" {
|
|
b, err := blob.OpenBucket(ctx, "file://"+filepath.Join(dir, "documents"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return b.Prefixed("tenant-2/"), nil
|
|
}
|
|
return openers.Document(ctx, field)
|
|
},
|
|
}
|
|
client, ctx, _ := setupBlob(t, ent.WithBlobOpeners(prefixedOpeners))
|
|
|
|
data := []byte("initial")
|
|
doc := client.Document.Create().
|
|
SetName("prefix-update-doc").
|
|
SetContent(bytes.NewReader(data)).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SaveX(ctx)
|
|
|
|
updated := []byte("updated under prefix")
|
|
doc = doc.Update().SetContent(bytes.NewReader(updated)).SaveX(ctx)
|
|
|
|
got := blobContent(t, doc.ContentReader, ctx)
|
|
require.Equal(t, updated, got)
|
|
}
|
|
|
|
func TestBlobPrefixBulkCreate(t *testing.T) {
|
|
dir := blobDir(t)
|
|
openers := newBlobOpeners(dir)
|
|
prefixedOpeners := ent.BlobOpeners{
|
|
Document: func(ctx context.Context, field string) (ent.Blob, error) {
|
|
if field == "content" {
|
|
b, err := blob.OpenBucket(ctx, "file://"+filepath.Join(dir, "documents"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return b.Prefixed("bulk-tenant/"), nil
|
|
}
|
|
return openers.Document(ctx, field)
|
|
},
|
|
}
|
|
client, ctx, _ := setupBlob(t, ent.WithBlobOpeners(prefixedOpeners))
|
|
|
|
docs := client.Document.CreateBulk(
|
|
client.Document.Create().SetName("bulk-p1").SetContent(strings.NewReader("b1")).SetThumbnail(bytes.NewReader([]byte("t1"))).SetAttachment([]byte("att")),
|
|
client.Document.Create().SetName("bulk-p2").SetContent(strings.NewReader("b2")).SetThumbnail(bytes.NewReader([]byte("t2"))).SetAttachment([]byte("att")),
|
|
).SaveX(ctx)
|
|
|
|
for i, doc := range docs {
|
|
got := blobContent(t, doc.ContentReader, ctx)
|
|
require.Equal(t, []byte(fmt.Sprintf("b%d", i+1)), got)
|
|
}
|
|
}
|
|
|
|
func TestBlobDualWriteCreate(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
data := []byte("dual-write attachment data")
|
|
doc := client.Document.Create().
|
|
SetName("dualwrite-doc").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment(data).
|
|
SaveX(ctx)
|
|
|
|
// 1. The public struct field holds the bytes.
|
|
require.Equal(t, data, doc.Attachment)
|
|
|
|
// 2. Read via AttachmentReader — reads from blob storage.
|
|
got := blobContent(t, doc.AttachmentReader, ctx)
|
|
require.Equal(t, data, got)
|
|
}
|
|
|
|
func TestBlobDualWriteQueryFallback(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
data := []byte("attachment for query fallback test")
|
|
created := client.Document.Create().
|
|
SetName("dualwrite-query").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment(data).
|
|
SaveX(ctx)
|
|
|
|
// Query back — the entity has both the bytes column and the blob key.
|
|
queried := client.Document.GetX(ctx, created.ID)
|
|
|
|
// The struct field is populated from the SQL column.
|
|
require.Equal(t, data, queried.Attachment)
|
|
|
|
// Reading via AttachmentReader also works (reads from blob).
|
|
got := blobContent(t, queried.AttachmentReader, ctx)
|
|
require.Equal(t, data, got)
|
|
}
|
|
|
|
func TestBlobDualWriteUpdate(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
v1 := []byte("attachment v1")
|
|
doc := client.Document.Create().
|
|
SetName("dualwrite-update").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment(v1).
|
|
SaveX(ctx)
|
|
|
|
// Update attachment.
|
|
v2 := []byte("attachment v2 - updated")
|
|
doc = doc.Update().
|
|
SetAttachment(v2).
|
|
SaveX(ctx)
|
|
|
|
// The struct field holds the updated bytes.
|
|
require.Equal(t, v2, doc.Attachment)
|
|
|
|
// Read updated data from blob.
|
|
got := blobContent(t, doc.AttachmentReader, ctx)
|
|
require.Equal(t, v2, got)
|
|
}
|
|
|
|
func TestBlobDualWriteBulkCreate(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
bulk := make([]*ent.DocumentCreate, 3)
|
|
for i := range bulk {
|
|
bulk[i] = client.Document.Create().
|
|
SetName(fmt.Sprintf("bulk-dw-%d", i)).
|
|
SetContent(bytes.NewReader([]byte("c"))).
|
|
SetThumbnail(bytes.NewReader([]byte("t"))).
|
|
SetAttachment([]byte(strings.Repeat("a", i+1)))
|
|
}
|
|
docs, err := client.Document.CreateBulk(bulk...).Save(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, docs, 3)
|
|
|
|
for i, doc := range docs {
|
|
// Struct field has the data.
|
|
require.Equal(t, []byte(strings.Repeat("a", i+1)), doc.Attachment)
|
|
|
|
// Blob reader also works.
|
|
got := blobContent(t, doc.AttachmentReader, ctx)
|
|
require.Equal(t, []byte(strings.Repeat("a", i+1)), got)
|
|
}
|
|
}
|
|
|
|
func TestBlobLoadOnScanCreate(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
meta := []byte(`{"version": 1, "tags": ["test"]}`)
|
|
doc := client.Document.Create().
|
|
SetName("loadonscan-doc").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SetMetadata(meta).
|
|
SaveX(ctx)
|
|
|
|
// The struct field is populated on create (from the mutation value).
|
|
require.Equal(t, meta, doc.Metadata)
|
|
|
|
// Reading via MetadataReader also works.
|
|
got := blobContent(t, doc.MetadataReader, ctx)
|
|
require.Equal(t, meta, got)
|
|
}
|
|
|
|
func TestBlobLoadOnScanQuery(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
meta := []byte(`{"loaded": true}`)
|
|
created := client.Document.Create().
|
|
SetName("loadonscan-query").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SetMetadata(meta).
|
|
SaveX(ctx)
|
|
|
|
// Query back — LoadOnScan auto-loads the metadata from blob storage.
|
|
queried := client.Document.GetX(ctx, created.ID)
|
|
require.Equal(t, meta, queried.Metadata)
|
|
}
|
|
|
|
func TestBlobLoadOnScanUpdate(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
v1 := []byte(`{"v": 1}`)
|
|
doc := client.Document.Create().
|
|
SetName("loadonscan-update").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SetMetadata(v1).
|
|
SaveX(ctx)
|
|
|
|
v2 := []byte(`{"v": 2}`)
|
|
doc = doc.Update().SetMetadata(v2).SaveX(ctx)
|
|
require.Equal(t, v2, doc.Metadata)
|
|
|
|
// Query confirms the update.
|
|
queried := client.Document.GetX(ctx, doc.ID)
|
|
require.Equal(t, v2, queried.Metadata)
|
|
}
|
|
|
|
func TestBlobLoadOnScanNil(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
// Create without metadata (optional).
|
|
doc := client.Document.Create().
|
|
SetName("loadonscan-nil").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SaveX(ctx)
|
|
|
|
// The struct field is nil when no metadata was set.
|
|
require.Nil(t, doc.Metadata)
|
|
|
|
// Query also returns nil.
|
|
queried := client.Document.GetX(ctx, doc.ID)
|
|
require.Nil(t, queried.Metadata)
|
|
}
|
|
|
|
func TestBlobLoadOnScanHook(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
// Register a hook that observes the metadata field in the mutation.
|
|
var hookSeen []byte
|
|
client.Document.Use(func(next ent.Mutator) ent.Mutator {
|
|
return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) {
|
|
if dm, ok := m.(*ent.DocumentMutation); ok {
|
|
if v, exists := dm.Metadata(); exists {
|
|
hookSeen = v
|
|
}
|
|
}
|
|
return next.Mutate(ctx, m)
|
|
})
|
|
})
|
|
|
|
meta := []byte(`{"hook": true}`)
|
|
client.Document.Create().
|
|
SetName("hook-doc").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SetMetadata(meta).
|
|
SaveX(ctx)
|
|
|
|
// The hook saw the metadata value.
|
|
require.Equal(t, meta, hookSeen)
|
|
}
|
|
|
|
func TestBlobLoadOnScanBulkCreate(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
bulk := make([]*ent.DocumentCreate, 3)
|
|
for i := range bulk {
|
|
bulk[i] = client.Document.Create().
|
|
SetName(fmt.Sprintf("bulk-los-%d", i)).
|
|
SetContent(bytes.NewReader([]byte("c"))).
|
|
SetThumbnail(bytes.NewReader([]byte("t"))).
|
|
SetAttachment([]byte("att")).
|
|
SetMetadata([]byte(fmt.Sprintf(`{"i": %d}`, i)))
|
|
}
|
|
docs, err := client.Document.CreateBulk(bulk...).Save(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, docs, 3)
|
|
|
|
for i, doc := range docs {
|
|
// Struct field is populated on bulk create.
|
|
require.Equal(t, []byte(fmt.Sprintf(`{"i": %d}`, i)), doc.Metadata)
|
|
|
|
// Blob reader also works.
|
|
got := blobContent(t, doc.MetadataReader, ctx)
|
|
require.Equal(t, []byte(fmt.Sprintf(`{"i": %d}`, i)), got)
|
|
}
|
|
}
|
|
|
|
func TestBlobLoadOnScanQueryAll(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
// Create multiple documents with metadata.
|
|
for i := range 4 {
|
|
client.Document.Create().
|
|
SetName(fmt.Sprintf("queryall-%d", i)).
|
|
SetContent(bytes.NewReader([]byte("c"))).
|
|
SetThumbnail(bytes.NewReader([]byte("t"))).
|
|
SetAttachment([]byte("att")).
|
|
SetMetadata([]byte(fmt.Sprintf(`{"n": %d}`, i))).
|
|
SaveX(ctx)
|
|
}
|
|
|
|
// Query all — each entity should have its metadata auto-loaded.
|
|
docs := client.Document.Query().
|
|
Order(ent.Asc(document.FieldName)).
|
|
AllX(ctx)
|
|
require.Len(t, docs, 4)
|
|
for i, doc := range docs {
|
|
require.Equal(t, []byte(fmt.Sprintf(`{"n": %d}`, i)), doc.Metadata)
|
|
}
|
|
}
|
|
|
|
func TestBlobLoadOnScanClear(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
meta := []byte(`{"clear": true}`)
|
|
doc := client.Document.Create().
|
|
SetName("clear-meta").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SetMetadata(meta).
|
|
SaveX(ctx)
|
|
require.Equal(t, meta, doc.Metadata)
|
|
|
|
// Clear the metadata.
|
|
doc = doc.Update().ClearMetadata().SaveX(ctx)
|
|
|
|
// After clearing, the struct field is nil (metadata_key was not set on the update).
|
|
require.Nil(t, doc.Metadata)
|
|
|
|
// Query back — also nil.
|
|
queried := client.Document.GetX(ctx, doc.ID)
|
|
require.Nil(t, queried.Metadata)
|
|
}
|
|
|
|
func TestBlobLoadOnScanUpdateHook(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
doc := client.Document.Create().
|
|
SetName("update-hook-doc").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SetMetadata([]byte(`{"v": 1}`)).
|
|
SaveX(ctx)
|
|
|
|
// Register hook that observes metadata on update.
|
|
var hookSeen []byte
|
|
client.Document.Use(func(next ent.Mutator) ent.Mutator {
|
|
return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) {
|
|
if dm, ok := m.(*ent.DocumentMutation); ok {
|
|
if v, exists := dm.Metadata(); exists {
|
|
hookSeen = v
|
|
}
|
|
}
|
|
return next.Mutate(ctx, m)
|
|
})
|
|
})
|
|
|
|
v2 := []byte(`{"v": 2}`)
|
|
doc.Update().SetMetadata(v2).SaveX(ctx)
|
|
require.Equal(t, v2, hookSeen)
|
|
}
|
|
|
|
func TestBlobLoadOnScanNillableSet(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
meta := []byte(`{"nillable": true}`)
|
|
doc := client.Document.Create().
|
|
SetName("nillable-doc").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SetNillableMetadata(&meta).
|
|
SaveX(ctx)
|
|
require.Equal(t, meta, doc.Metadata)
|
|
|
|
// SetNillableMetadata with nil does nothing.
|
|
doc = doc.Update().SetNillableMetadata(nil).SaveX(ctx)
|
|
// The metadata should remain unchanged (loaded from blob).
|
|
require.Equal(t, meta, doc.Metadata)
|
|
}
|
|
|
|
func TestBlobLoadOnScanReaderNilKey(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
// Create without metadata — key is nil.
|
|
doc := client.Document.Create().
|
|
SetName("nil-key-doc").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SaveX(ctx)
|
|
|
|
// MetadataReader should return an error when key is nil.
|
|
_, err := doc.MetadataReader(ctx)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "nil or empty")
|
|
}
|
|
|
|
func TestBlobDualWriteHook(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
// Register a hook that observes the attachment field.
|
|
var hookSeen []byte
|
|
client.Document.Use(func(next ent.Mutator) ent.Mutator {
|
|
return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) {
|
|
if dm, ok := m.(*ent.DocumentMutation); ok {
|
|
if v, exists := dm.Attachment(); exists {
|
|
hookSeen = v
|
|
}
|
|
}
|
|
return next.Mutate(ctx, m)
|
|
})
|
|
})
|
|
|
|
att := []byte("hook-attachment-data")
|
|
client.Document.Create().
|
|
SetName("dw-hook-doc").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment(att).
|
|
SaveX(ctx)
|
|
|
|
require.Equal(t, att, hookSeen)
|
|
}
|
|
|
|
func TestBlobDualWriteNillableSet(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
att := []byte("initial-attachment")
|
|
doc := client.Document.Create().
|
|
SetName("dw-nillable-doc").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment(att).
|
|
SaveX(ctx)
|
|
|
|
// SetNillableAttachment with nil does nothing.
|
|
doc = doc.Update().SetNillableAttachment(nil).SaveX(ctx)
|
|
require.Equal(t, att, doc.Attachment)
|
|
|
|
// SetNillableAttachment with a value updates.
|
|
newAtt := []byte("updated-attachment")
|
|
doc = doc.Update().SetNillableAttachment(&newAtt).SaveX(ctx)
|
|
require.Equal(t, newAtt, doc.Attachment)
|
|
|
|
// Blob storage also has the new value.
|
|
got := blobContent(t, doc.AttachmentReader, ctx)
|
|
require.Equal(t, newAtt, got)
|
|
}
|
|
|
|
func TestBlobInlineKeyCheckContent(t *testing.T) {
|
|
// Verify that ContentReader errors when the entity has no key set.
|
|
// This tests the inlined nil-and-empty key check.
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
doc := client.Document.Create().
|
|
SetName("key-check-doc").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SaveX(ctx)
|
|
|
|
// The entity has a key set, so this should work fine.
|
|
got := blobContent(t, doc.ContentReader, ctx)
|
|
require.Equal(t, []byte("content"), got)
|
|
|
|
// Construct a bare Document without keys to test the guard.
|
|
bare := &ent.Document{}
|
|
_, err := bare.ContentReader(ctx)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "nil or empty")
|
|
}
|
|
|
|
func TestBlobLoadOnScanWithPrefix(t *testing.T) {
|
|
dir := blobDir(t)
|
|
prefixedOpeners := ent.BlobOpeners{
|
|
Document: func(ctx context.Context, field string) (ent.Blob, error) {
|
|
var subdir string
|
|
switch field {
|
|
case document.FieldContent:
|
|
subdir = "documents"
|
|
case document.FieldThumbnail:
|
|
subdir = "thumbnails"
|
|
case document.FieldAttachment:
|
|
subdir = "attachments"
|
|
case document.FieldMetadata:
|
|
subdir = "metadata"
|
|
case document.FieldPayload:
|
|
subdir = "payloads"
|
|
default:
|
|
return nil, fmt.Errorf("unknown blob field: %s", field)
|
|
}
|
|
b, err := blob.OpenBucket(ctx, "file://"+filepath.Join(dir, subdir))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return b.Prefixed("tenant-x/"), nil
|
|
},
|
|
}
|
|
client, ctx, _ := setupBlob(t, ent.WithBlobOpeners(prefixedOpeners))
|
|
|
|
meta := []byte(`{"prefixed": true}`)
|
|
doc := client.Document.Create().
|
|
SetName("prefixed-los-doc").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SetMetadata(meta).
|
|
SaveX(ctx)
|
|
|
|
// Struct field populated on create.
|
|
require.Equal(t, meta, doc.Metadata)
|
|
|
|
// Query auto-loads from prefixed blob.
|
|
queried := client.Document.GetX(ctx, doc.ID)
|
|
require.Equal(t, meta, queried.Metadata)
|
|
|
|
// Update also works through prefix.
|
|
v2 := []byte(`{"prefixed": "v2"}`)
|
|
doc = doc.Update().SetMetadata(v2).SaveX(ctx)
|
|
require.Equal(t, v2, doc.Metadata)
|
|
}
|
|
|
|
func TestBlobLoadOnScanWithEncryption(t *testing.T) {
|
|
dir := blobDir(t)
|
|
masterSeed := []byte("encryption-seed-for-loadonscan")
|
|
|
|
encryptedOpeners := ent.BlobOpeners{
|
|
Document: func(ctx context.Context, field string) (ent.Blob, error) {
|
|
var subdir string
|
|
switch field {
|
|
case document.FieldContent:
|
|
subdir = "documents"
|
|
case document.FieldThumbnail:
|
|
subdir = "thumbnails"
|
|
case document.FieldAttachment:
|
|
subdir = "attachments"
|
|
case document.FieldMetadata:
|
|
subdir = "metadata"
|
|
case document.FieldPayload:
|
|
subdir = "payloads"
|
|
default:
|
|
return nil, fmt.Errorf("unknown blob field: %s", field)
|
|
}
|
|
b, err := blob.OpenBucket(ctx, "file://"+filepath.Join(dir, subdir))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return blob.NewEncrypted(b, masterSeed), nil
|
|
},
|
|
}
|
|
client, _, _ := setupBlob(t, ent.WithBlobOpeners(encryptedOpeners))
|
|
|
|
acmeCtx := blob.WithTenant(context.Background(), "acme")
|
|
meta := []byte(`{"encrypted": true, "tenant": "acme"}`)
|
|
doc := client.Document.Create().
|
|
SetName("enc-los-doc").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SetMetadata(meta).
|
|
SaveX(acmeCtx)
|
|
|
|
// Struct field populated on create.
|
|
require.Equal(t, meta, doc.Metadata)
|
|
|
|
// Query auto-loads encrypted metadata (same tenant).
|
|
queried := client.Document.GetX(acmeCtx, doc.ID)
|
|
require.Equal(t, meta, queried.Metadata)
|
|
|
|
// Update round-trips through encryption.
|
|
v2 := []byte(`{"encrypted": true, "v": 2}`)
|
|
doc = doc.Update().SetMetadata(v2).SaveX(acmeCtx)
|
|
require.Equal(t, v2, doc.Metadata)
|
|
}
|
|
|
|
func TestBlobDeleteRemovesBlobs(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
// Create a document with all blob fields populated.
|
|
doc := client.Document.Create().
|
|
SetName("delete-doc").
|
|
SetContent(bytes.NewReader([]byte("content-data"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb-data"))).
|
|
SetAttachment([]byte("att-data")).
|
|
SetMetadata([]byte("meta-data")).
|
|
SaveX(ctx)
|
|
|
|
// Verify blobs are readable before delete.
|
|
got := blobContent(t, doc.ContentReader, ctx)
|
|
require.Equal(t, []byte("content-data"), got)
|
|
got = blobContent(t, doc.ThumbnailReader, ctx)
|
|
require.Equal(t, []byte("thumb-data"), got)
|
|
got = blobContent(t, doc.AttachmentReader, ctx)
|
|
require.Equal(t, []byte("att-data"), got)
|
|
got = blobContent(t, doc.MetadataReader, ctx)
|
|
require.Equal(t, []byte("meta-data"), got)
|
|
|
|
// Delete the entity.
|
|
client.Document.DeleteOne(doc).ExecX(ctx)
|
|
|
|
// Verify entity is gone from database.
|
|
count := client.Document.Query().CountX(ctx)
|
|
require.Equal(t, 0, count)
|
|
|
|
// Verify blobs were removed: readers return nil (not found).
|
|
got = blobContent(t, doc.ContentReader, ctx)
|
|
require.Nil(t, got, "content blob should be deleted")
|
|
got = blobContent(t, doc.ThumbnailReader, ctx)
|
|
require.Nil(t, got, "thumbnail blob should be deleted")
|
|
got = blobContent(t, doc.AttachmentReader, ctx)
|
|
require.Nil(t, got, "attachment blob should be deleted")
|
|
got = blobContent(t, doc.MetadataReader, ctx)
|
|
require.Nil(t, got, "metadata blob should be deleted")
|
|
}
|
|
|
|
func TestBlobDeleteBulkRemovesBlobs(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
// Create multiple documents, save references.
|
|
var docs []*ent.Document
|
|
for i := range 3 {
|
|
doc := client.Document.Create().
|
|
SetName(fmt.Sprintf("bulk-del-%d", i)).
|
|
SetContent(bytes.NewReader([]byte(fmt.Sprintf("c%d", i)))).
|
|
SetThumbnail(bytes.NewReader([]byte(fmt.Sprintf("t%d", i)))).
|
|
SetAttachment([]byte("att")).
|
|
SaveX(ctx)
|
|
docs = append(docs, doc)
|
|
}
|
|
|
|
// Verify blobs are readable.
|
|
for i, doc := range docs {
|
|
got := blobContent(t, doc.ContentReader, ctx)
|
|
require.Equal(t, []byte(fmt.Sprintf("c%d", i)), got)
|
|
}
|
|
|
|
// Bulk delete all documents.
|
|
n := client.Document.Delete().ExecX(ctx)
|
|
require.Equal(t, 3, n)
|
|
|
|
// Verify blobs were removed: readers return nil.
|
|
for _, doc := range docs {
|
|
got := blobContent(t, doc.ContentReader, ctx)
|
|
require.Nil(t, got, "blob should be deleted after bulk delete")
|
|
}
|
|
}
|
|
|
|
func TestBlobDeleteWithPredicateOnlyDeletesMatching(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
// Create two documents.
|
|
keep := client.Document.Create().
|
|
SetName("keep-me").
|
|
SetContent(bytes.NewReader([]byte("keep-content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("keep-thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SaveX(ctx)
|
|
del := client.Document.Create().
|
|
SetName("delete-me").
|
|
SetContent(bytes.NewReader([]byte("delete-content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("delete-thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SaveX(ctx)
|
|
|
|
// Delete only the second one.
|
|
n := client.Document.Delete().Where(document.Name("delete-me")).ExecX(ctx)
|
|
require.Equal(t, 1, n)
|
|
|
|
// One document remains.
|
|
remaining := client.Document.Query().OnlyX(ctx)
|
|
require.Equal(t, "keep-me", remaining.Name)
|
|
|
|
// The kept document's blobs are still readable.
|
|
got := blobContent(t, keep.ContentReader, ctx)
|
|
require.Equal(t, []byte("keep-content"), got)
|
|
|
|
// The deleted document's blobs are gone.
|
|
got = blobContent(t, del.ContentReader, ctx)
|
|
require.Nil(t, got, "deleted doc's content blob should be removed")
|
|
got = blobContent(t, del.ThumbnailReader, ctx)
|
|
require.Nil(t, got, "deleted doc's thumbnail blob should be removed")
|
|
}
|
|
|
|
func TestBlobDeleteTxCommitRemovesBlobs(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
// Create a document with blobs.
|
|
doc := client.Document.Create().
|
|
SetName("tx-commit-doc").
|
|
SetContent(bytes.NewReader([]byte("tx-content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("tx-thumb"))).
|
|
SetAttachment([]byte("tx-att")).
|
|
SaveX(ctx)
|
|
|
|
// Verify blobs exist.
|
|
got := blobContent(t, doc.ContentReader, ctx)
|
|
require.Equal(t, []byte("tx-content"), got)
|
|
|
|
// Delete inside a transaction and commit.
|
|
tx, err := client.Tx(ctx)
|
|
require.NoError(t, err)
|
|
err = tx.Document.DeleteOne(doc).Exec(ctx)
|
|
require.NoError(t, err)
|
|
|
|
// Before commit, blobs should still exist (cleanup is deferred).
|
|
got = blobContent(t, doc.ContentReader, ctx)
|
|
require.Equal(t, []byte("tx-content"), got, "blob should still exist before commit")
|
|
got = blobContent(t, doc.ThumbnailReader, ctx)
|
|
require.Equal(t, []byte("tx-thumb"), got, "blob should still exist before commit")
|
|
|
|
// Commit the transaction.
|
|
require.NoError(t, tx.Commit())
|
|
|
|
// After commit, blobs should be deleted.
|
|
got = blobContent(t, doc.ContentReader, ctx)
|
|
require.Nil(t, got, "content blob should be deleted after tx commit")
|
|
got = blobContent(t, doc.ThumbnailReader, ctx)
|
|
require.Nil(t, got, "thumbnail blob should be deleted after tx commit")
|
|
got = blobContent(t, doc.AttachmentReader, ctx)
|
|
require.Nil(t, got, "attachment blob should be deleted after tx commit")
|
|
}
|
|
|
|
func TestBlobDeleteTxRollbackPreservesBlobs(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
// Create a document with blobs.
|
|
doc := client.Document.Create().
|
|
SetName("tx-rollback-doc").
|
|
SetContent(bytes.NewReader([]byte("keep-content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("keep-thumb"))).
|
|
SetAttachment([]byte("keep-att")).
|
|
SaveX(ctx)
|
|
|
|
// Delete inside a transaction but rollback.
|
|
tx, err := client.Tx(ctx)
|
|
require.NoError(t, err)
|
|
err = tx.Document.DeleteOne(doc).Exec(ctx)
|
|
require.NoError(t, err)
|
|
|
|
// Rollback the transaction.
|
|
require.NoError(t, tx.Rollback())
|
|
|
|
// After rollback, the entity and its blobs should still exist.
|
|
exists := client.Document.Query().Where(document.ID(doc.ID)).ExistX(ctx)
|
|
require.True(t, exists, "document should still exist after rollback")
|
|
got := blobContent(t, doc.ContentReader, ctx)
|
|
require.Equal(t, []byte("keep-content"), got, "content blob should be preserved after rollback")
|
|
got = blobContent(t, doc.ThumbnailReader, ctx)
|
|
require.Equal(t, []byte("keep-thumb"), got, "thumbnail blob should be preserved after rollback")
|
|
got = blobContent(t, doc.AttachmentReader, ctx)
|
|
require.Equal(t, []byte("keep-att"), got, "attachment blob should be preserved after rollback")
|
|
}
|
|
|
|
func TestBlobDualWritePayloadCreate(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
p := &schema.DocPayload{Title: "hello", Body: "world"}
|
|
doc := client.Document.Create().
|
|
SetName("payload-doc").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SetPayload(p).
|
|
SaveX(ctx)
|
|
|
|
// The struct field holds the custom GoType.
|
|
require.NotNil(t, doc.Payload)
|
|
require.Equal(t, "hello", doc.Payload.Title)
|
|
require.Equal(t, "world", doc.Payload.Body)
|
|
|
|
// PayloadReader reads the JSON bytes from blob storage.
|
|
got := blobContent(t, doc.PayloadReader, ctx)
|
|
require.Contains(t, string(got), `"title":"hello"`)
|
|
require.Contains(t, string(got), `"body":"world"`)
|
|
}
|
|
|
|
func TestBlobDualWritePayloadQuery(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
p := &schema.DocPayload{Title: "query-title", Body: "query-body"}
|
|
created := client.Document.Create().
|
|
SetName("payload-query-doc").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SetPayload(p).
|
|
SaveX(ctx)
|
|
|
|
// Query back from the database.
|
|
queried := client.Document.GetX(ctx, created.ID)
|
|
require.NotNil(t, queried.Payload)
|
|
require.Equal(t, "query-title", queried.Payload.Title)
|
|
require.Equal(t, "query-body", queried.Payload.Body)
|
|
|
|
// PayloadReader also works on queried entity.
|
|
got := blobContent(t, queried.PayloadReader, ctx)
|
|
require.Contains(t, string(got), `"query-title"`)
|
|
}
|
|
|
|
func TestBlobDualWritePayloadUpdate(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
v1 := &schema.DocPayload{Title: "v1", Body: "first"}
|
|
doc := client.Document.Create().
|
|
SetName("payload-update-doc").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SetPayload(v1).
|
|
SaveX(ctx)
|
|
|
|
// Update payload.
|
|
v2 := &schema.DocPayload{Title: "v2", Body: "updated"}
|
|
doc = doc.Update().SetPayload(v2).SaveX(ctx)
|
|
|
|
require.Equal(t, "v2", doc.Payload.Title)
|
|
require.Equal(t, "updated", doc.Payload.Body)
|
|
|
|
// Blob storage has the updated data.
|
|
got := blobContent(t, doc.PayloadReader, ctx)
|
|
require.Contains(t, string(got), `"v2"`)
|
|
require.Contains(t, string(got), `"updated"`)
|
|
}
|
|
|
|
func TestBlobDualWritePayloadOptionalNil(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
// Create without setting payload (optional field).
|
|
doc := client.Document.Create().
|
|
SetName("payload-nil-doc").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SaveX(ctx)
|
|
|
|
require.Nil(t, doc.Payload)
|
|
|
|
// Set payload, then clear it.
|
|
p := &schema.DocPayload{Title: "temp", Body: "data"}
|
|
doc = doc.Update().SetPayload(p).SaveX(ctx)
|
|
require.NotNil(t, doc.Payload)
|
|
|
|
doc = doc.Update().ClearPayload().SaveX(ctx)
|
|
queried := client.Document.GetX(ctx, doc.ID)
|
|
require.Nil(t, queried.Payload)
|
|
}
|
|
|
|
// TestBlobDualWritePayloadReadsFromBlob verifies that querying a DualWrite+ValueScanner
|
|
// field returns the value from blob storage (not the column). It does this by modifying
|
|
// the blob file on disk after creation and asserting the query reflects the new blob content.
|
|
func TestBlobDualWritePayloadReadsFromBlob(t *testing.T) {
|
|
client, ctx, dir := setupBlob(t)
|
|
|
|
p := &schema.DocPayload{Title: "original", Body: "from-create"}
|
|
doc := client.Document.Create().
|
|
SetName("blob-source-doc").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SetPayload(p).
|
|
SaveX(ctx)
|
|
|
|
require.Equal(t, "original", doc.Payload.Title)
|
|
|
|
// Find the blob file on disk and overwrite it with different JSON.
|
|
payloadDir := filepath.Join(dir, "payloads")
|
|
entries, err := os.ReadDir(payloadDir)
|
|
require.NoError(t, err)
|
|
var blobFile string
|
|
for _, e := range entries {
|
|
if !strings.HasSuffix(e.Name(), ".attrs") {
|
|
blobFile = e.Name()
|
|
}
|
|
}
|
|
require.NotEmpty(t, blobFile, "blob file not found")
|
|
blobPath := filepath.Join(payloadDir, blobFile)
|
|
newJSON := []byte(`{"title":"from-blob","body":"overwritten"}`)
|
|
require.NoError(t, os.WriteFile(blobPath, newJSON, 0o644))
|
|
|
|
// Query the document — should get the value from blob, not the column.
|
|
queried := client.Document.GetX(ctx, doc.ID)
|
|
require.NotNil(t, queried.Payload)
|
|
require.Equal(t, "from-blob", queried.Payload.Title)
|
|
require.Equal(t, "overwritten", queried.Payload.Body)
|
|
}
|
|
|
|
// TestBlobDualWritePayloadFallbackToColumn verifies that when a DualWrite+ValueScanner
|
|
// field has an empty blob key (legacy row from before DualWrite migration), the value
|
|
// is read from the column instead.
|
|
func TestBlobDualWritePayloadFallbackToColumn(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
// Insert a legacy row directly via SQL — has payload column data but no blob key.
|
|
db := client.Driver().(*entsql.Driver).DB()
|
|
_, err := db.ExecContext(ctx,
|
|
`INSERT INTO documents (name, content_key, thumbnail_key, attachment_key, attachment, payload) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
"legacy-doc", "", "", "", []byte("att"), `{"title":"from-column","body":"legacy-data"}`,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
// Query via ent — should decode payload from the column value.
|
|
queried := client.Document.Query().Where(document.Name("legacy-doc")).OnlyX(ctx)
|
|
require.NotNil(t, queried.Payload)
|
|
require.Equal(t, "from-column", queried.Payload.Title)
|
|
require.Equal(t, "legacy-data", queried.Payload.Body)
|
|
}
|
|
|
|
func TestBlobGoTypeString(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
// Create a document with a string-typed blob field (no ValueScanner needed).
|
|
doc := client.Document.Create().
|
|
SetName("string-blob-doc").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SetDescription("hello from string blob").
|
|
SaveX(ctx)
|
|
|
|
// The struct field is a string.
|
|
require.Equal(t, "hello from string blob", doc.Description)
|
|
|
|
// DescriptionReader returns the raw bytes stored in blob storage.
|
|
got := blobContent(t, doc.DescriptionReader, ctx)
|
|
require.Equal(t, "hello from string blob", string(got))
|
|
|
|
// Query back from the database and verify the field loads.
|
|
queried := client.Document.GetX(ctx, doc.ID)
|
|
require.Equal(t, "hello from string blob", queried.Description)
|
|
|
|
// Update the string blob field.
|
|
doc = doc.Update().SetDescription("updated string").SaveX(ctx)
|
|
require.Equal(t, "updated string", doc.Description)
|
|
|
|
got = blobContent(t, doc.DescriptionReader, ctx)
|
|
require.Equal(t, "updated string", string(got))
|
|
}
|
|
|
|
func TestBlobGoTypeStringOptionalNil(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
// Create without setting the optional string blob field.
|
|
doc := client.Document.Create().
|
|
SetName("no-desc-doc").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SaveX(ctx)
|
|
|
|
require.Empty(t, doc.Description)
|
|
|
|
// Set description, then clear it.
|
|
doc = doc.Update().SetDescription("temporary").SaveX(ctx)
|
|
require.Equal(t, "temporary", doc.Description)
|
|
|
|
doc = doc.Update().ClearDescription().SaveX(ctx)
|
|
queried := client.Document.GetX(ctx, doc.ID)
|
|
require.Empty(t, queried.Description)
|
|
}
|
|
|
|
func TestBlobCreateBulkEmpty(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
// Empty bulk create should not panic.
|
|
docs, err := client.Document.CreateBulk().Save(ctx)
|
|
require.NoError(t, err)
|
|
require.Empty(t, docs)
|
|
|
|
// Verify no documents were created.
|
|
count := client.Document.Query().CountX(ctx)
|
|
require.Zero(t, count)
|
|
}
|
|
|
|
// TestBlobUpdateDeletesOrphanedBlob verifies that updating a blob field
|
|
// deletes the old blob from storage after the update succeeds.
|
|
func TestBlobUpdateDeletesOrphanedBlob(t *testing.T) {
|
|
client, ctx, dir := setupBlob(t)
|
|
|
|
// Create a document with attachment (DualWrite, non-lazy — stored as flat UUID files).
|
|
doc := client.Document.Create().
|
|
SetName("orphan-update-doc").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att-v1")).
|
|
SaveX(ctx)
|
|
|
|
// Count blob files in attachments directory before update.
|
|
attDir := filepath.Join(dir, "attachments")
|
|
require.Equal(t, 1, countBlobFiles(t, attDir), "should have exactly 1 blob file after create")
|
|
|
|
// Update the attachment field — this should write a new blob and delete the old one.
|
|
doc = doc.Update().
|
|
SetAttachment([]byte("att-v2")).
|
|
SaveX(ctx)
|
|
|
|
// Verify the new content is readable.
|
|
got := blobContent(t, doc.AttachmentReader, ctx)
|
|
require.Equal(t, []byte("att-v2"), got)
|
|
|
|
// The old blob should have been deleted — still only 1 blob file.
|
|
require.Equal(t, 1, countBlobFiles(t, attDir), "old blob should be deleted after update, expected 1 file")
|
|
}
|
|
|
|
// TestBlobUpdateClearDeletesOrphanedBlob verifies that clearing an optional
|
|
// blob field deletes the old blob from storage.
|
|
func TestBlobUpdateClearDeletesOrphanedBlob(t *testing.T) {
|
|
client, ctx, dir := setupBlob(t)
|
|
|
|
// Create a document with the optional metadata field set.
|
|
doc := client.Document.Create().
|
|
SetName("orphan-clear-doc").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SetMetadata([]byte("some-metadata")).
|
|
SaveX(ctx)
|
|
|
|
// Verify metadata blob exists.
|
|
metaDir := filepath.Join(dir, "metadata")
|
|
require.Equal(t, 1, countBlobFiles(t, metaDir), "should have metadata blob after create")
|
|
|
|
// Verify metadata is readable before clearing.
|
|
got := blobContent(t, doc.MetadataReader, ctx)
|
|
require.Equal(t, []byte("some-metadata"), got)
|
|
|
|
// Clear metadata — should delete the old blob.
|
|
doc = doc.Update().ClearMetadata().SaveX(ctx)
|
|
|
|
// Verify the old blob file was deleted.
|
|
require.Equal(t, 0, countBlobFiles(t, metaDir), "old metadata blob should be deleted after clear")
|
|
}
|
|
|
|
// TestBlobUpdateMultipleFieldsDeletesOrphans verifies that updating multiple
|
|
// blob fields at once deletes all old blobs.
|
|
func TestBlobUpdateMultipleFieldsDeletesOrphans(t *testing.T) {
|
|
client, ctx, dir := setupBlob(t)
|
|
|
|
doc := client.Document.Create().
|
|
SetName("multi-orphan-doc").
|
|
SetContent(bytes.NewReader([]byte("content-v1"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb-v1"))).
|
|
SetAttachment([]byte("att-v1")).
|
|
SetDescription("desc-v1").
|
|
SaveX(ctx)
|
|
|
|
// Count blobs in each directory (use directories with flat keys).
|
|
thumbDir := filepath.Join(dir, "thumbnails")
|
|
attDir := filepath.Join(dir, "attachments")
|
|
descDir := filepath.Join(dir, "descriptions")
|
|
|
|
require.Equal(t, 1, countBlobFiles(t, thumbDir), "1 thumbnail blob")
|
|
require.Equal(t, 1, countBlobFiles(t, attDir), "1 attachment blob")
|
|
require.Equal(t, 1, countBlobFiles(t, descDir), "1 description blob")
|
|
|
|
// Update all three at once.
|
|
doc = doc.Update().
|
|
SetThumbnail(bytes.NewReader([]byte("thumb-v2"))).
|
|
SetAttachment([]byte("att-v2")).
|
|
SetDescription("desc-v2").
|
|
SaveX(ctx)
|
|
|
|
// All old blobs should be cleaned up — still 1 each.
|
|
require.Equal(t, 1, countBlobFiles(t, thumbDir), "old thumbnail blob should be deleted")
|
|
require.Equal(t, 1, countBlobFiles(t, attDir), "old attachment blob should be deleted")
|
|
require.Equal(t, 1, countBlobFiles(t, descDir), "old description blob should be deleted")
|
|
|
|
// Verify updated values are correct.
|
|
got := blobContent(t, doc.ThumbnailReader, ctx)
|
|
require.Equal(t, []byte("thumb-v2"), got)
|
|
got = blobContent(t, doc.AttachmentReader, ctx)
|
|
require.Equal(t, []byte("att-v2"), got)
|
|
require.Equal(t, "desc-v2", doc.Description)
|
|
}
|
|
|
|
// TestBlobUpdateCleansUpNewBlobOnSQLFailure verifies that when new blobs are
|
|
// uploaded before the SQL UPDATE but the UPDATE fails (e.g., constraint violation),
|
|
// the newly-written blobs are cleaned up and the old blobs remain intact.
|
|
func TestBlobUpdateCleansUpNewBlobOnSQLFailure(t *testing.T) {
|
|
client, ctx, dir := setupBlob(t)
|
|
db := client.Driver().(*entsql.Driver).DB()
|
|
|
|
// Add a UNIQUE constraint on name to trigger a real SQL constraint violation.
|
|
_, err := db.ExecContext(ctx, `CREATE UNIQUE INDEX idx_documents_name ON documents(name)`)
|
|
require.NoError(t, err)
|
|
|
|
// Create two documents with unique names.
|
|
doc := client.Document.Create().
|
|
SetName("doc-one").
|
|
SetContent(bytes.NewReader([]byte("content1"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb1"))).
|
|
SetAttachment([]byte("att1")).
|
|
SaveX(ctx)
|
|
client.Document.Create().
|
|
SetName("doc-two").
|
|
SetContent(bytes.NewReader([]byte("content2"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb2"))).
|
|
SetAttachment([]byte("att2")).
|
|
SaveX(ctx)
|
|
|
|
// Count blobs after the two creates.
|
|
contentDir := filepath.Join(dir, "documents")
|
|
thumbDir := filepath.Join(dir, "thumbnails")
|
|
attDir := filepath.Join(dir, "attachments")
|
|
contentAfter := countBlobFiles(t, contentDir)
|
|
thumbAfter := countBlobFiles(t, thumbDir)
|
|
attAfter := countBlobFiles(t, attDir)
|
|
|
|
// Attempt to update doc-one's name to "doc-two" (UNIQUE violation).
|
|
// The new blob should be written then cleaned up when the SQL fails.
|
|
_, err = doc.Update().
|
|
SetName("doc-two").
|
|
SetThumbnail(bytes.NewReader([]byte("new-thumb-should-be-cleaned"))).
|
|
SetAttachment([]byte("new-att-should-be-cleaned")).
|
|
Save(ctx)
|
|
require.Error(t, err, "update should fail due to UNIQUE constraint on name")
|
|
|
|
// Verify no new blobs remain — the newly uploaded ones should be cleaned up.
|
|
require.Equal(t, contentAfter, countBlobFiles(t, contentDir),
|
|
"content blobs should remain unchanged after failed update")
|
|
require.Equal(t, thumbAfter, countBlobFiles(t, thumbDir),
|
|
"new thumbnail blob should be cleaned up after failed update")
|
|
require.Equal(t, attAfter, countBlobFiles(t, attDir),
|
|
"new attachment blob should be cleaned up after failed update")
|
|
|
|
// Verify the original doc-one is still readable with its original data.
|
|
doc = client.Document.GetX(ctx, doc.ID)
|
|
got2 := blobContent(t, doc.ThumbnailReader, ctx)
|
|
require.Equal(t, []byte("thumb1"), got2, "original thumbnail should still be readable")
|
|
}
|
|
|
|
// TestBlobClearedFieldsIncludesLazyBlob verifies that ClearedFields() on the
|
|
// mutation reports lazy blob fields that were cleared.
|
|
func TestBlobClearedFieldsIncludesLazyBlob(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
// Create a document with the optional description (GoType string, lazy blob).
|
|
doc := client.Document.Create().
|
|
SetName("cleared-fields-doc").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att")).
|
|
SetDescription("initial-desc").
|
|
SaveX(ctx)
|
|
|
|
// Use a hook to capture the mutation's ClearedFields during update.
|
|
var clearedFields []string
|
|
client.Document.Use(func(next ent.Mutator) ent.Mutator {
|
|
return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) {
|
|
clearedFields = m.ClearedFields()
|
|
return next.Mutate(ctx, m)
|
|
})
|
|
})
|
|
|
|
// Clear description via update.
|
|
doc.Update().ClearDescription().SaveX(ctx)
|
|
|
|
// The hook should have observed "description" in cleared fields.
|
|
require.Contains(t, clearedFields, document.FieldDescription,
|
|
"ClearedFields() should include lazy blob field 'description'")
|
|
}
|
|
|
|
// TestBlobCreateNoOrphanOnFailedInsert verifies that when a create INSERT fails
|
|
// (e.g., due to a constraint violation), no orphaned blob is left in storage
|
|
// because blob writes are deferred until after the INSERT succeeds.
|
|
func TestBlobCreateNoOrphanOnFailedInsert(t *testing.T) {
|
|
client, ctx, dir := setupBlob(t)
|
|
|
|
// Insert a row with a known ID directly via SQL.
|
|
db := client.Driver().(*entsql.Driver).DB()
|
|
_, err := db.ExecContext(ctx,
|
|
`INSERT INTO documents (id, name, content_key, thumbnail_key, attachment_key, attachment) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
1, "existing-doc", "existing-key", "existing-thumb-key", "existing-att-key", []byte("att"),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
// Count blob files before the failed attempt.
|
|
thumbDir := filepath.Join(dir, "thumbnails")
|
|
attDir := filepath.Join(dir, "attachments")
|
|
thumbBefore := countBlobFiles(t, thumbDir)
|
|
attBefore := countBlobFiles(t, attDir)
|
|
|
|
// Attempt to create a document that will fail due to PK conflict (no OnConflict).
|
|
// We use a hook to force the ID to the existing one, causing a constraint error.
|
|
client.Document.Use(func(next ent.Mutator) ent.Mutator {
|
|
return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) {
|
|
// Force the spec to use ID=1 which already exists.
|
|
// This hook runs before sqlSave but after the mutation is set up.
|
|
return next.Mutate(ctx, m)
|
|
})
|
|
})
|
|
|
|
// Since we can't easily set the ID for auto-increment, use a UNIQUE constraint
|
|
// violation via the SQL driver directly. Instead, just verify the basic behavior:
|
|
// a failed Create (validation error) should not write blobs.
|
|
_, err = client.Document.Create().
|
|
// Missing required "name" field → fails check() before INSERT
|
|
SetContent(bytes.NewReader([]byte("should-not-persist"))).
|
|
SetThumbnail(bytes.NewReader([]byte("should-not-persist-thumb"))).
|
|
SetAttachment([]byte("att2")).
|
|
Save(ctx)
|
|
require.Error(t, err, "create should fail due to missing required field")
|
|
|
|
// Verify no new blobs were written — counts should remain the same.
|
|
require.Equal(t, thumbBefore, countBlobFiles(t, thumbDir),
|
|
"no orphaned thumbnail blob should be created on failed insert")
|
|
require.Equal(t, attBefore, countBlobFiles(t, attDir),
|
|
"no orphaned attachment blob should be created on failed insert")
|
|
}
|
|
|
|
// TestBlobCreateCleansUpOnSQLFailure verifies that when blobs are written to
|
|
// storage before the SQL INSERT but the INSERT fails (constraint violation),
|
|
// the written blobs are cleaned up by the BlobCreates cleanup function.
|
|
func TestBlobCreateCleansUpOnSQLFailure(t *testing.T) {
|
|
client, ctx, dir := setupBlob(t)
|
|
db := client.Driver().(*entsql.Driver).DB()
|
|
|
|
// Add a UNIQUE constraint on name to trigger a real SQL constraint violation.
|
|
_, err := db.ExecContext(ctx, `CREATE UNIQUE INDEX idx_documents_name ON documents(name)`)
|
|
require.NoError(t, err)
|
|
|
|
// Create the first document successfully.
|
|
client.Document.Create().
|
|
SetName("unique-name").
|
|
SetContent(bytes.NewReader([]byte("content1"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb1"))).
|
|
SetAttachment([]byte("att1")).
|
|
SaveX(ctx)
|
|
|
|
// Count blobs after first successful create.
|
|
contentDir := filepath.Join(dir, "documents")
|
|
thumbDir := filepath.Join(dir, "thumbnails")
|
|
attDir := filepath.Join(dir, "attachments")
|
|
contentAfterFirst := countBlobFiles(t, contentDir)
|
|
thumbAfterFirst := countBlobFiles(t, thumbDir)
|
|
attAfterFirst := countBlobFiles(t, attDir)
|
|
|
|
// Attempt to create a second document with the same name (UNIQUE violation).
|
|
// This passes check() but fails at the SQL INSERT level.
|
|
_, err = client.Document.Create().
|
|
SetName("unique-name").
|
|
SetContent(bytes.NewReader([]byte("content2-should-be-cleaned"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb2-should-be-cleaned"))).
|
|
SetAttachment([]byte("att2-should-be-cleaned")).
|
|
Save(ctx)
|
|
require.Error(t, err, "create should fail due to UNIQUE constraint on name")
|
|
|
|
// Verify that the blobs written before the INSERT were cleaned up.
|
|
require.Equal(t, contentAfterFirst, countBlobFiles(t, contentDir),
|
|
"content blob should be cleaned up after SQL failure")
|
|
require.Equal(t, thumbAfterFirst, countBlobFiles(t, thumbDir),
|
|
"thumbnail blob should be cleaned up after SQL failure")
|
|
require.Equal(t, attAfterFirst, countBlobFiles(t, attDir),
|
|
"attachment blob should be cleaned up after SQL failure")
|
|
}
|
|
|
|
// TestBlobBulkCreateCleansUpOnSQLFailure verifies that when a bulk create's
|
|
// SQL INSERT fails, the blobs written before the INSERT are cleaned up.
|
|
func TestBlobBulkCreateCleansUpOnSQLFailure(t *testing.T) {
|
|
client, ctx, dir := setupBlob(t)
|
|
db := client.Driver().(*entsql.Driver).DB()
|
|
|
|
// Add a UNIQUE constraint on name to trigger a real SQL constraint violation.
|
|
_, err := db.ExecContext(ctx, `CREATE UNIQUE INDEX idx_documents_name ON documents(name)`)
|
|
require.NoError(t, err)
|
|
|
|
// Create the first document successfully.
|
|
client.Document.Create().
|
|
SetName("bulk-unique").
|
|
SetContent(bytes.NewReader([]byte("content1"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb1"))).
|
|
SetAttachment([]byte("att1")).
|
|
SaveX(ctx)
|
|
|
|
// Count blobs after first successful create.
|
|
contentDir := filepath.Join(dir, "documents")
|
|
thumbDir := filepath.Join(dir, "thumbnails")
|
|
attDir := filepath.Join(dir, "attachments")
|
|
contentAfterFirst := countBlobFiles(t, contentDir)
|
|
thumbAfterFirst := countBlobFiles(t, thumbDir)
|
|
attAfterFirst := countBlobFiles(t, attDir)
|
|
|
|
// Attempt a bulk create where one entry duplicates the name (UNIQUE violation).
|
|
_, err = client.Document.CreateBulk(
|
|
client.Document.Create().
|
|
SetName("bulk-unique"). // duplicate → will fail
|
|
SetContent(bytes.NewReader([]byte("bulk-content-orphan"))).
|
|
SetThumbnail(bytes.NewReader([]byte("bulk-thumb-orphan"))).
|
|
SetAttachment([]byte("bulk-att-orphan")),
|
|
).Save(ctx)
|
|
require.Error(t, err, "bulk create should fail due to UNIQUE constraint on name")
|
|
|
|
// Verify that the blobs written before the INSERT were cleaned up.
|
|
require.Equal(t, contentAfterFirst, countBlobFiles(t, contentDir),
|
|
"content blob should be cleaned up after bulk SQL failure")
|
|
require.Equal(t, thumbAfterFirst, countBlobFiles(t, thumbDir),
|
|
"thumbnail blob should be cleaned up after bulk SQL failure")
|
|
require.Equal(t, attAfterFirst, countBlobFiles(t, attDir),
|
|
"attachment blob should be cleaned up after bulk SQL failure")
|
|
}
|
|
|
|
// TestBlobDualWriteFallbackOnMissingBlob verifies that when a DualWrite field has
|
|
// a key set but the blob object is missing (e.g., not yet migrated to blob storage),
|
|
// the query still returns the value from the SQL column rather than nil.
|
|
func TestBlobDualWriteFallbackOnMissingBlob(t *testing.T) {
|
|
client, ctx, dir := setupBlob(t)
|
|
db := client.Driver().(*entsql.Driver).DB()
|
|
|
|
// Create a document with attachment (DualWrite) and metadata (non-DualWrite).
|
|
doc := client.Document.Create().
|
|
SetName("fallback-doc").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att-from-column")).
|
|
SetMetadata([]byte(`{"meta": true}`)).
|
|
SaveX(ctx)
|
|
|
|
// Manually delete the attachment blob file from storage to simulate
|
|
// a missing blob (e.g., data written to column before blob migration).
|
|
attDir := filepath.Join(dir, "attachments")
|
|
entries, err := os.ReadDir(attDir)
|
|
require.NoError(t, err)
|
|
for _, e := range entries {
|
|
if !strings.HasSuffix(e.Name(), ".attrs") {
|
|
require.NoError(t, os.Remove(filepath.Join(attDir, e.Name())))
|
|
}
|
|
}
|
|
|
|
// Also update the column directly to simulate a value that only exists in SQL.
|
|
_, err = db.ExecContext(ctx, `UPDATE documents SET attachment = ? WHERE id = ?`, []byte("sql-only-value"), doc.ID)
|
|
require.NoError(t, err)
|
|
|
|
// Query the document — the DualWrite field should fall back to the SQL column value.
|
|
queried := client.Document.GetX(ctx, doc.ID)
|
|
require.Equal(t, []byte("sql-only-value"), queried.Attachment,
|
|
"DualWrite field should preserve SQL column value when blob is missing")
|
|
|
|
// Non-DualWrite field (metadata) — blob still exists, should be loaded normally.
|
|
require.Equal(t, []byte(`{"meta": true}`), queried.Metadata)
|
|
}
|
|
|
|
// TestBlobLoadOnScanUpdateSkipsBlobReadForMutatedFields verifies the optimization
|
|
// that after an update, mutated fields are populated from the mutation value directly
|
|
// (without reading from blob storage), while non-mutated non-DualWrite fields are
|
|
// still read from blob storage.
|
|
func TestBlobLoadOnScanUpdateSkipsBlobReadForMutatedFields(t *testing.T) {
|
|
dir := blobDir(t)
|
|
var (
|
|
mu sync.Mutex
|
|
readFields []string
|
|
)
|
|
// Wrap the opener to spy on which fields trigger NewReader calls.
|
|
spyOpeners := ent.BlobOpeners{
|
|
Document: func(ctx context.Context, field string) (ent.Blob, error) {
|
|
b, err := newBlobOpeners(dir).Document(ctx, field)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &blobReadSpy{Blob: b, field: field, mu: &mu, reads: &readFields}, nil
|
|
},
|
|
}
|
|
client, ctx, _ := setupBlob(t, ent.WithBlobOpeners(spyOpeners))
|
|
|
|
// Create a document with all LoadOnScan fields populated.
|
|
meta := []byte(`{"version": 1}`)
|
|
doc := client.Document.Create().
|
|
SetName("skip-read-doc").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("attachment-v1")).
|
|
SetMetadata(meta).
|
|
SetDescription("description-v1").
|
|
SetPayload(&schema.DocPayload{Title: "t1", Body: "b1"}).
|
|
SaveX(ctx)
|
|
|
|
// Clear read tracking from create.
|
|
mu.Lock()
|
|
readFields = nil
|
|
mu.Unlock()
|
|
|
|
// Update only metadata (non-DualWrite). Other non-DualWrite fields (description)
|
|
// should be read from blob, but DualWrite fields (attachment, payload) should NOT
|
|
// trigger blob reads (they come from SQL column via assignValues).
|
|
newMeta := []byte(`{"version": 2}`)
|
|
doc = doc.Update().SetMetadata(newMeta).SaveX(ctx)
|
|
|
|
// Verify returned values are correct.
|
|
require.Equal(t, newMeta, doc.Metadata, "mutated field should have new value")
|
|
require.Equal(t, []byte("attachment-v1"), doc.Attachment, "DualWrite field should retain value from SQL")
|
|
require.Equal(t, "description-v1", doc.Description, "non-DualWrite field should be read from blob")
|
|
require.NotNil(t, doc.Payload, "DualWrite GoType field should retain value from SQL")
|
|
require.Equal(t, "t1", doc.Payload.Title)
|
|
|
|
// Check which fields were read from blob during the update.
|
|
mu.Lock()
|
|
reads := append([]string{}, readFields...)
|
|
mu.Unlock()
|
|
|
|
// metadata was mutated → no blob read needed for it.
|
|
require.NotContains(t, reads, document.FieldMetadata, "mutated field should not trigger blob read")
|
|
// attachment and payload are DualWrite → populated from SQL, no blob read.
|
|
require.NotContains(t, reads, document.FieldAttachment, "DualWrite field should not trigger blob read")
|
|
require.NotContains(t, reads, document.FieldPayload, "DualWrite field should not trigger blob read")
|
|
// description is non-DualWrite and was NOT mutated → must be read from blob.
|
|
require.Contains(t, reads, document.FieldDescription, "non-mutated non-DualWrite field should be read from blob")
|
|
}
|
|
|
|
// TestBlobLoadOnScanUpdateAllMutated verifies that when all LoadOnScan fields
|
|
// are mutated, no blob reads occur at all during the update.
|
|
func TestBlobLoadOnScanUpdateAllMutated(t *testing.T) {
|
|
dir := blobDir(t)
|
|
var (
|
|
mu sync.Mutex
|
|
readFields []string
|
|
)
|
|
spyOpeners := ent.BlobOpeners{
|
|
Document: func(ctx context.Context, field string) (ent.Blob, error) {
|
|
b, err := newBlobOpeners(dir).Document(ctx, field)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &blobReadSpy{Blob: b, field: field, mu: &mu, reads: &readFields}, nil
|
|
},
|
|
}
|
|
client, ctx, _ := setupBlob(t, ent.WithBlobOpeners(spyOpeners))
|
|
|
|
doc := client.Document.Create().
|
|
SetName("all-mutated-doc").
|
|
SetContent(bytes.NewReader([]byte("content"))).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb"))).
|
|
SetAttachment([]byte("att-v1")).
|
|
SetMetadata([]byte(`{"v": 1}`)).
|
|
SetDescription("desc-v1").
|
|
SetPayload(&schema.DocPayload{Title: "t1", Body: "b1"}).
|
|
SaveX(ctx)
|
|
|
|
// Clear tracking.
|
|
mu.Lock()
|
|
readFields = nil
|
|
mu.Unlock()
|
|
|
|
// Update ALL LoadOnScan fields at once.
|
|
doc = doc.Update().
|
|
SetAttachment([]byte("att-v2")).
|
|
SetMetadata([]byte(`{"v": 2}`)).
|
|
SetDescription("desc-v2").
|
|
SetPayload(&schema.DocPayload{Title: "t2", Body: "b2"}).
|
|
SaveX(ctx)
|
|
|
|
// Verify all values are updated.
|
|
require.Equal(t, []byte("att-v2"), doc.Attachment)
|
|
require.Equal(t, []byte(`{"v": 2}`), doc.Metadata)
|
|
require.Equal(t, "desc-v2", doc.Description)
|
|
require.Equal(t, "t2", doc.Payload.Title)
|
|
|
|
// No blob reads should have occurred for LoadOnScan fields during the update
|
|
// (writes happen, but reads should be skipped).
|
|
mu.Lock()
|
|
reads := append([]string{}, readFields...)
|
|
mu.Unlock()
|
|
|
|
// Filter to only LoadOnScan fields (exclude content/thumbnail which are lazy).
|
|
loadOnScanReads := filterReads(reads, document.FieldAttachment, document.FieldMetadata, document.FieldPayload, document.FieldDescription)
|
|
require.Empty(t, loadOnScanReads, "all fields were mutated, no blob reads expected for LoadOnScan fields")
|
|
}
|
|
|
|
// blobReadSpy wraps a Blob to record which fields trigger NewReader calls.
|
|
type blobReadSpy struct {
|
|
ent.Blob
|
|
field string
|
|
mu *sync.Mutex
|
|
reads *[]string
|
|
}
|
|
|
|
func (s *blobReadSpy) NewReader(ctx context.Context, key string) (io.ReadCloser, error) {
|
|
s.mu.Lock()
|
|
*s.reads = append(*s.reads, s.field)
|
|
s.mu.Unlock()
|
|
return s.Blob.NewReader(ctx, key)
|
|
}
|
|
|
|
// filterReads returns only the reads that match one of the target fields.
|
|
func filterReads(reads []string, targets ...string) []string {
|
|
set := make(map[string]bool, len(targets))
|
|
for _, t := range targets {
|
|
set[t] = true
|
|
}
|
|
var filtered []string
|
|
for _, r := range reads {
|
|
if set[r] {
|
|
filtered = append(filtered, r)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// countBlobFiles recursively counts files in a directory tree that are not
|
|
// .attrs metadata files (used by fileblob).
|
|
func countBlobFiles(t *testing.T, dir string) int {
|
|
t.Helper()
|
|
count := 0
|
|
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !d.IsDir() && !strings.HasSuffix(d.Name(), ".attrs") {
|
|
count++
|
|
}
|
|
return nil
|
|
})
|
|
require.NoError(t, err)
|
|
return count
|
|
}
|
|
|
|
func TestBlobUpdateSkipsWriteWhenUnchanged(t *testing.T) {
|
|
dir := blobDir(t)
|
|
var mu sync.Mutex
|
|
var writeFields []string
|
|
|
|
// Opener that spies on NewWriter calls.
|
|
openers := ent.BlobOpeners{
|
|
Document: func(ctx context.Context, field string) (ent.Blob, error) {
|
|
var subdir string
|
|
switch field {
|
|
case document.FieldContent:
|
|
subdir = "documents"
|
|
case document.FieldThumbnail:
|
|
subdir = "thumbnails"
|
|
case document.FieldAttachment:
|
|
subdir = "attachments"
|
|
case document.FieldMetadata:
|
|
subdir = "metadata"
|
|
case document.FieldPayload:
|
|
subdir = "payloads"
|
|
case document.FieldDescription:
|
|
subdir = "descriptions"
|
|
default:
|
|
return nil, fmt.Errorf("unknown blob field: %s", field)
|
|
}
|
|
b, err := blob.OpenBucket(ctx, "file://"+filepath.Join(dir, subdir))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &blobWriteSpy{Blob: b, field: field, mu: &mu, writes: &writeFields}, nil
|
|
},
|
|
}
|
|
client, ctx, _ := setupBlob(t, ent.WithBlobOpeners(openers))
|
|
|
|
// Create a document.
|
|
doc := client.Document.Create().
|
|
SetName("skip-write-doc").
|
|
SetContent(strings.NewReader("content-data")).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb-data"))).
|
|
SetAttachment([]byte("att-data")).
|
|
SaveX(ctx)
|
|
|
|
// Clear tracking.
|
|
mu.Lock()
|
|
writeFields = nil
|
|
mu.Unlock()
|
|
|
|
// Update with the SAME content — key will be the same hash,
|
|
// so the write should be skipped.
|
|
doc = doc.Update().
|
|
SetContent(strings.NewReader("content-data")).
|
|
SetThumbnail(bytes.NewReader([]byte("thumb-data"))).
|
|
SetAttachment([]byte("att-data")).
|
|
SaveX(ctx)
|
|
|
|
mu.Lock()
|
|
writes := append([]string{}, writeFields...)
|
|
mu.Unlock()
|
|
|
|
// No writes should have occurred — all keys unchanged.
|
|
require.Empty(t, writes, "no blob writes expected when content is unchanged")
|
|
|
|
// Verify data is still readable.
|
|
got := blobContent(t, doc.ContentReader, ctx)
|
|
require.Equal(t, []byte("content-data"), got)
|
|
|
|
// Now update with DIFFERENT content — writes should occur.
|
|
mu.Lock()
|
|
writeFields = nil
|
|
mu.Unlock()
|
|
|
|
// Count content blobs before the update.
|
|
contentDir := filepath.Join(dir, "documents")
|
|
contentBefore := countBlobFiles(t, contentDir)
|
|
|
|
doc = doc.Update().
|
|
SetContent(strings.NewReader("content-v2")).
|
|
SetAttachment([]byte("att-data")). // same
|
|
SaveX(ctx)
|
|
|
|
mu.Lock()
|
|
writes = append([]string{}, writeFields...)
|
|
mu.Unlock()
|
|
|
|
// Only content should be written (changed), not attachment (unchanged).
|
|
require.Equal(t, []string{document.FieldContent}, writes)
|
|
|
|
got = blobContent(t, doc.ContentReader, ctx)
|
|
require.Equal(t, []byte("content-v2"), got)
|
|
|
|
// The old content blob should be deleted (orphan cleanup).
|
|
require.Equal(t, contentBefore, countBlobFiles(t, contentDir),
|
|
"old content blob should be deleted after update, count should remain the same")
|
|
}
|
|
|
|
// blobWriteSpy wraps a Blob to record which fields trigger NewWriter calls.
|
|
type blobWriteSpy struct {
|
|
ent.Blob
|
|
field string
|
|
mu *sync.Mutex
|
|
writes *[]string
|
|
}
|
|
|
|
func (s *blobWriteSpy) NewWriter(ctx context.Context, key string) (io.WriteCloser, error) {
|
|
s.mu.Lock()
|
|
*s.writes = append(*s.writes, s.field)
|
|
s.mu.Unlock()
|
|
return s.Blob.NewWriter(ctx, key)
|
|
}
|
|
|
|
func TestBlobLazyDualWrite(t *testing.T) {
|
|
client, ctx, _ := setupBlob(t)
|
|
|
|
// Create a document with the Lazy+DualWrite archive field.
|
|
data := []byte("archived content")
|
|
doc := client.Document.Create().
|
|
SetName("lazy-dualwrite").
|
|
SetContent(bytes.NewReader([]byte("c"))).
|
|
SetThumbnail(bytes.NewReader([]byte("t"))).
|
|
SetAttachment([]byte("a")).
|
|
SetArchive(bytes.NewReader(data)).
|
|
SaveX(ctx)
|
|
|
|
// Query back and read the lazy field via its Reader method.
|
|
got := client.Document.GetX(ctx, doc.ID)
|
|
result := blobContent(t, got.ArchiveReader, ctx)
|
|
require.Equal(t, data, result)
|
|
|
|
// Update the archive field.
|
|
updated := []byte("updated archive")
|
|
client.Document.UpdateOne(got).
|
|
SetArchive(bytes.NewReader(updated)).
|
|
ExecX(ctx)
|
|
|
|
got2 := client.Document.GetX(ctx, got.ID)
|
|
result2 := blobContent(t, got2.ArchiveReader, ctx)
|
|
require.Equal(t, updated, result2)
|
|
|
|
// Clear the archive field (optional).
|
|
client.Document.UpdateOne(got2).
|
|
ClearArchive().
|
|
ExecX(ctx)
|
|
|
|
got3 := client.Document.GetX(ctx, got.ID)
|
|
_, err := got3.ArchiveReader(ctx)
|
|
require.Error(t, err, "expected error reading cleared archive")
|
|
}
|