Files
ent/entc/integration/blob_test.go
2026-05-21 14:57:26 +00:00

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")
}