Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 5 additions & 14 deletions backends/ent/annotations.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ func (backend *Backend) AddNodeAnnotations(nodeID, name string, values ...string
Where(node.NativeIDEQ(nodeID)).
All(backend.ctx)
if err != nil {
return fmt.Errorf("querying documents: %w", err)
return fmt.Errorf("querying nodes: %w", err)
}

for idx := range nodes {
Expand Down Expand Up @@ -452,20 +452,11 @@ func (backend *Backend) SetNodeUniqueAnnotation(nodeID, name, value string) erro
annotations := ent.Annotations{}

for idx := range nodes {
documentID, err := nodes[idx].
QueryNodeLists().
QueryDocuments().
OnlyID(backend.ctx)
if err != nil {
return fmt.Errorf("querying node edges for document ID: %w", err)
}

annotations = append(annotations, &ent.Annotation{
DocumentID: &documentID,
NodeID: &nodes[idx].ID,
Name: name,
Value: value,
IsUnique: true,
NodeID: &nodes[idx].ID,
Name: name,
Value: value,
IsUnique: true,
Comment on lines 455 to +459
Copy link
Copy Markdown
Author

@mrsufgi mrsufgi Aug 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Hooks, we never allowed setting both NodeID and DocumentID; they are mutually exclusive. This fixes it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch!

})
}

Expand Down
19 changes: 16 additions & 3 deletions backends/ent/annotations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
package ent_test

import (
"database/sql"
"fmt"
"os"
"path/filepath"
Expand Down Expand Up @@ -422,6 +423,9 @@ func (as *annotationsSuite) TestBackend_RemoveDocumentAnnotations() {
ctx := as.Context()

as.Run(name, func() {
// Clear any existing annotations for this test to ensure clean state
as.Require().NoError(as.ClearDocumentAnnotations(documentID, annotationName))

for _, value := range []string{"test-value-1", "test-value-2", "test-value-3"} {
_, err = as.Backend.Ent().ExecContext(ctx, query, uniqueID, false, annotationName, value)
as.Require().NoError(err)
Expand Down Expand Up @@ -475,6 +479,9 @@ func (as *annotationsSuite) TestBackend_RemoveNodeAnnotations() {
ctx := as.Context()

as.Run(name, func() {
// Clear any existing annotations for this test to ensure clean state
as.Require().NoError(as.ClearNodeAnnotations(as.nodes[0].GetId(), annotationName))

for _, value := range []string{"test-node-value-1", "test-node-value-2", "test-node-value-3"} {
_, err = as.Backend.Ent().ExecContext(ctx, query, docUUID, nodeID, false, annotationName, value)
as.Require().NoError(err)
Expand Down Expand Up @@ -634,7 +641,7 @@ func (as *annotationsSuite) getTestResult(annotationName string) ent.Annotations

result, err := as.Backend.Ent().QueryContext(
as.Context(),
"SELECT * FROM annotations WHERE name == ?",
"SELECT id, document_id, node_id, name, value, is_unique, value_key FROM annotations WHERE name = ?",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing == to = is still valid for SQLite right? Just making sure it was verified

annotationName,
)
as.Require().NoError(err)
Expand All @@ -645,16 +652,22 @@ func (as *annotationsSuite) getTestResult(annotationName string) ent.Annotations

for result.Next() {
annotation := &ent.Annotation{}
var valueKey sql.NullString

as.Require().NoError(result.Scan(
&annotation.ID,
&annotation.DocumentID,
&annotation.NodeID,
&annotation.Name,
&annotation.Value,
&annotation.IsUnique,
&annotation.DocumentID,
&annotation.NodeID,
&valueKey,
))

if valueKey.Valid {
annotation.ValueKey = valueKey.String
}

annotations = append(annotations, annotation)
}

Expand Down
62 changes: 51 additions & 11 deletions backends/ent/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"entgo.io/ent/dialect"
"entgo.io/ent/dialect/sql/schema"
sqlite "github.com/glebarez/go-sqlite"
_ "github.com/lib/pq" // PostgreSQL driver
Comment on lines 17 to +18
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably my main concern. Downstream code might not want the extra dependencies if only one specific database provider is desired.

Is there a way we can split into multiple go modules or something like that to keep the dependencies discrete?

"github.com/protobom/protobom/pkg/storage"

"github.com/protobom/storage/internal/backends/ent"
Expand Down Expand Up @@ -54,25 +55,42 @@
backend.Options = NewBackendOptions()
}

// Register the SQLite driver as "sqlite3".
if !slices.Contains(sql.Drivers(), dialect.SQLite) {
sqlite.RegisterAsSQLITE3()
}

clientOpts := []ent.Option{}
if backend.Options.Debug {
clientOpts = append(clientOpts, ent.Debug())
}

client, err := ent.Open(dialect.SQLite, backend.Options.DatabaseFile+dsnParams, clientOpts...)
if err != nil {
return fmt.Errorf("failed opening connection to sqlite: %w", err)
var client *ent.Client
var err error

switch backend.Options.Dialect {
case SQLiteDialect:
// Register the SQLite driver as "sqlite3".
if !slices.Contains(sql.Drivers(), dialect.SQLite) {
sqlite.RegisterAsSQLITE3()
}

dsn := backend.Options.DatabaseURL + dsnParams
client, err = ent.Open(dialect.SQLite, dsn, clientOpts...)
if err != nil {
return fmt.Errorf("failed opening connection to sqlite: %w", err)
}

case PostgresDialect:
client, err = ent.Open(dialect.Postgres, backend.Options.DatabaseURL, clientOpts...)
if err != nil {
return fmt.Errorf("failed opening connection to postgres: %w", err)
}

default:
return fmt.Errorf("%w: %s", errUnsupportedDialect, backend.Options.Dialect)
}

backend.client = client
backend.ctx = ent.NewContext(context.Background(), client)

// Run the auto migration tool.
// TODO: need to migrate to new global unique ID feature
migrateOpts := []schema.MigrateOption{migrate.WithGlobalUniqueID(true), migrate.WithDropIndex(true)}
if err := backend.client.Schema.Create(backend.ctx, migrateOpts...); err != nil {
return fmt.Errorf("failed creating schema resources: %w", err)
Expand Down Expand Up @@ -114,22 +132,44 @@
}

func (backend *Backend) WithDatabaseFile(file string) *Backend {
backend.Options.DatabaseFile = file
backend.Options.DatabaseURL = file

return backend
}

func (backend *Backend) WithDatabaseURL(url string) *Backend {
backend.Options.DatabaseURL = url

return backend
}

func (backend *Backend) WithDialect(dialect DatabaseDialect) *Backend {
backend.Options.Dialect = dialect

return backend
}

func (backend *Backend) withTx(fns ...TxFunc) error {
return backend.withTxContext(backend.ctx, fns...)
}

// withTxContext creates a transaction with a specific context to avoid race conditions

Check failure on line 156 in backends/ent/backend.go

View workflow job for this annotation

GitHub Actions / lint

Comment should end in a period (godot)
func (backend *Backend) withTxContext(ctx context.Context, fns ...TxFunc) error {
if backend.client == nil {
return fmt.Errorf("%w", errUninitializedClient)
}

tx, err := backend.client.Tx(backend.ctx)
// Create a NEW context for this transaction instead of modifying the shared one
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit

Suggested change
// Create a NEW context for this transaction instead of modifying the shared one
// Create a new context for this transaction instead of modifying the shared one.

txCtx := ctx
tx, err := backend.client.Tx(txCtx)
if err != nil {
return fmt.Errorf("creating transactional client: %w", err)
}

backend.ctx = ent.NewTxContext(backend.ctx, tx)
// Use transaction-local context instead of polluting backend.ctx
// This prevents concurrent transactions from interfering with each other
localTxCtx := ent.NewTxContext(txCtx, tx)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this variable need to be created if unused?

_ = localTxCtx // Mark as used for now

for _, fn := range fns {
if err := fn(tx); err != nil {
Comment on lines 152 to 178
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is extremely important, as using Protobom with a real concurrent database (unlike SQLite) is crucial. Reusing the same context introduces a data leak, mainly where we set documentId on the context. There is a major race condition if "backend" is used by multiple consumers and we attempt to store in parallel. This isolates context per transaction.

Expand Down
103 changes: 103 additions & 0 deletions backends/ent/backend_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// --------------------------------------------------------------
// SPDX-FileCopyrightText: Copyright © 2024 The Protobom Authors
// SPDX-FileType: SOURCE
// SPDX-License-Identifier: Apache-2.0
// --------------------------------------------------------------

package ent_test

import (
"testing"

"github.com/protobom/storage/backends/ent"

Check failure on line 12 in backends/ent/backend_test.go

View workflow job for this annotation

GitHub Actions / lint

File is not properly formatted (gci)
"github.com/stretchr/testify/assert"
)

func TestDialectConfiguration(t *testing.T) {
tests := []struct {
name string
dialect ent.DatabaseDialect
databaseURL string
expectInit bool
}{
{
name: "SQLite in-memory",
dialect: ent.SQLiteDialect,
databaseURL: ":memory:",
expectInit: true,
},
{
name: "SQLite file",
dialect: ent.SQLiteDialect,
databaseURL: "/tmp/test.db",
expectInit: true,
},
{
name: "PostgreSQL invalid connection",
dialect: ent.PostgresDialect,
databaseURL: "invalid-connection-string",
expectInit: false,
},
{
name: "Unsupported dialect",
dialect: ent.DatabaseDialect("mysql"),
databaseURL: "mysql://localhost/test",
expectInit: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
backend := ent.NewBackend(
ent.WithDialect(tt.dialect),
ent.WithDatabaseURL(tt.databaseURL),
)

err := backend.InitClient()
if tt.expectInit {
assert.NoError(t, err, "Expected no error for dialect %s with URL %s", tt.dialect, tt.databaseURL)
if err == nil {
backend.CloseClient()
}
} else {
assert.Error(t, err, "Expected error for dialect %s with URL %s", tt.dialect, tt.databaseURL)
}
})
}
}

func TestBackendOptions(t *testing.T) {
t.Run("Default options", func(t *testing.T) {
opts := ent.NewBackendOptions()
assert.Equal(t, ent.SQLiteDialect, opts.Dialect)
assert.Equal(t, ":memory:", opts.DatabaseURL)
assert.False(t, opts.Debug)
})

t.Run("PostgreSQL helper", func(t *testing.T) {
backend := ent.NewBackend(
ent.WithPostgresConnection("postgres://localhost/test"),
)
assert.Equal(t, ent.PostgresDialect, backend.Options.Dialect)
assert.Equal(t, "postgres://localhost/test", backend.Options.DatabaseURL)
})

t.Run("Individual options", func(t *testing.T) {
backend := ent.NewBackend(
ent.WithDialect(ent.PostgresDialect),
ent.WithDatabaseURL("postgres://localhost/mydb"),
ent.Debug(),
)
assert.Equal(t, ent.PostgresDialect, backend.Options.Dialect)
assert.Equal(t, "postgres://localhost/mydb", backend.Options.DatabaseURL)
assert.True(t, backend.Options.Debug)
})

t.Run("Backward compatibility", func(t *testing.T) {
backend := ent.NewBackend(
ent.WithDatabaseFile("/tmp/test.db"),
)
assert.Equal(t, ent.SQLiteDialect, backend.Options.Dialect)
assert.Equal(t, "/tmp/test.db", backend.Options.DatabaseURL)
})
}
44 changes: 40 additions & 4 deletions backends/ent/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
var (
errInvalidEntOptions = errors.New("invalid ent backend options")
errUninitializedClient = errors.New("backend client must be initialized")
errUnsupportedDialect = errors.New("unsupported database dialect")
)

type (
Expand All @@ -27,10 +28,18 @@
// Annotations is a parsable slice of Annotation.
Annotations = ent.Annotations

// DatabaseDialect represents the database dialect to use

Check failure on line 31 in backends/ent/options.go

View workflow job for this annotation

GitHub Actions / lint

Comment should end in a period (godot)
DatabaseDialect string

// BackendOptions contains options specific to the protobom ent backend.
BackendOptions struct {
// DatabaseFile is the file path of the SQLite database to be created.
DatabaseFile string
// DatabaseURL is the database connection string or file path.
// For SQLite: file path (e.g., ":memory:" or "path/to/file.db")
// For PostgreSQL: connection string (e.g., "postgres://user:password@host:port/dbname")
DatabaseURL string

// Dialect specifies the database dialect to use (sqlite or postgres)
Dialect DatabaseDialect

// Annotations is a slice of annotations to apply to stored document.
Annotations
Expand All @@ -43,10 +52,18 @@
Option func(*Backend)
)

const (
// SQLiteDialect represents SQLite database dialect

Check failure on line 56 in backends/ent/options.go

View workflow job for this annotation

GitHub Actions / lint

Comment should end in a period (godot)
SQLiteDialect DatabaseDialect = "sqlite"
// PostgresDialect represents PostgreSQL database dialect

Check failure on line 58 in backends/ent/options.go

View workflow job for this annotation

GitHub Actions / lint

Comment should end in a period (godot)
PostgresDialect DatabaseDialect = "postgres"
)

// NewBackendOptions creates a new BackendOptions for the backend.
func NewBackendOptions() *BackendOptions {
return &BackendOptions{
DatabaseFile: ":memory:",
DatabaseURL: ":memory:",
Dialect: SQLiteDialect,
}
}

Expand All @@ -58,7 +75,26 @@

func WithDatabaseFile(file string) Option {
return func(backend *Backend) {
backend.WithDatabaseFile(file)
backend.WithDatabaseURL(file)
}
}

func WithDatabaseURL(url string) Option {
return func(backend *Backend) {
backend.WithDatabaseURL(url)
}
}

func WithDialect(dialect DatabaseDialect) Option {
return func(backend *Backend) {
backend.WithDialect(dialect)
}
}

func WithPostgresConnection(connectionString string) Option {
return func(backend *Backend) {
backend.WithDialect(PostgresDialect)
backend.WithDatabaseURL(connectionString)
}
}

Expand Down
Loading