Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
193 changes: 162 additions & 31 deletions contract/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,58 +2,189 @@ package contract

import (
"context"
"encoding/json"
"errors"
"time"
)

var (
// ErrCacheKeyNotFound is returned when a key does not exist in the cache.
// Callers should also supply additional context, such as the cache key.
ErrCacheKeyNotFound = errors.New("cache key not found")

// ErrCacheUnsupportedOperation is returned when a method such as Forever
// or atomic operations is not supported by the cache backend.
// ErrCacheUnsupportedOperation is returned when a method such as
// atomic increment/decrement is not supported by the cache driver.
ErrCacheUnsupportedOperation = errors.New("cache unsupported operation")
)

// Cache defines a generic cache contract inspired by Laravel's Cache Repository.
// It supports basic CRUD, atomic operations, and lazy-loading via Remember patterns.
type Cache interface {
// Get retrieves the value for the given key.
// Returns nil and an error if the key is missing or retrieval fails.
Get(ctx context.Context, key string) (any, error)
// CacheDriver defines the minimal contract that cache backends must
// implement. Drivers operate on raw bytes, leaving serialization to
// the [Cache] wrapper. A zero TTL means the entry should never expire.
type CacheDriver interface {
// Get retrieves the raw bytes for the given key.
// Returns [ErrCacheKeyNotFound] when the key is missing or expired.
Get(ctx context.Context, key string) ([]byte, error)

// Put stores a value for the given key with a TTL.
// Overwrites any existing value.
Put(ctx context.Context, key string, value any, ttl time.Duration) error
// Put stores raw bytes for the given key with a TTL.
// A zero TTL stores the entry without expiration.
Put(ctx context.Context, key string, value []byte, ttl time.Duration) error

// Delete removes the cached value for the given key.
// Delete removes the entry for the given key.
// Does nothing if the key does not exist.
Delete(ctx context.Context, key string) error

// Has returns true if the key exists in the cache and is not expired.
// Has returns true if the key exists and is not expired.
Has(ctx context.Context, key string) (bool, error)
}

// CacheCounter is an optional interface that cache drivers may
// implement to support atomic increment and decrement operations.
// When the driver does not implement this interface, the [Cache]
// wrapper returns [ErrCacheUnsupportedOperation].
type CacheCounter interface {
// Increment atomically increases the integer value at key by
// the given delta. Returns the new value.
Increment(ctx context.Context, key string, delta int64) (int64, error)

// Decrement atomically decreases the integer value at key by
// the given delta. Returns the new value.
Decrement(ctx context.Context, key string, delta int64) (int64, error)
}

// Cache provides a type-safe caching layer over a [CacheDriver].
// It handles JSON serialization and offers convenience methods such
// as Pull, Forever, Remember, and atomic counters. When generic
// methods become available in Go, the Get and Remember methods will
// be updated to return typed values directly.
type Cache struct {
driver CacheDriver
}

// NewCache creates a new [Cache] that delegates storage to the given driver.
func NewCache(driver CacheDriver) *Cache {
return &Cache{driver: driver}
}

// Driver returns the underlying [CacheDriver].
func (cache *Cache) Driver() CacheDriver {
return cache.driver
}

// Get retrieves the value for the given key and JSON-decodes it into
// the provided destination. Returns [ErrCacheKeyNotFound] when the
// key is missing.
func (cache *Cache) Get(ctx context.Context, key string, dest any) error {
raw, err := cache.driver.Get(ctx, key)

if err != nil {
return err
}

return json.Unmarshal(raw, dest)
}

// Put JSON-encodes the value and stores it with the given TTL.
func (cache *Cache) Put(ctx context.Context, key string, value any, ttl time.Duration) error {
raw, err := json.Marshal(value)

if err != nil {
return err
}

return cache.driver.Put(ctx, key, raw, ttl)
}

// Delete removes the cached value for the given key.
func (cache *Cache) Delete(ctx context.Context, key string) error {
return cache.driver.Delete(ctx, key)
}

// Has returns true if the key exists in the cache and is not expired.
func (cache *Cache) Has(ctx context.Context, key string) (bool, error) {
return cache.driver.Has(ctx, key)
}

// Pull retrieves and removes the value for the given key, decoding
// it into dest. Returns [ErrCacheKeyNotFound] when the key is missing.
func (cache *Cache) Pull(ctx context.Context, key string, dest any) error {
raw, err := cache.driver.Get(ctx, key)

if err != nil {
return err
}

// Pull retrieves and removes the value for the given key.
// Returns nil and an error if the key is missing.
Pull(ctx context.Context, key string) (any, error)
if err := cache.driver.Delete(ctx, key); err != nil {
return err
}

// Forever stores a value permanently (no TTL).
// Only supported if the backend allows non-expiring keys.
Forever(ctx context.Context, key string, value any) error
return json.Unmarshal(raw, dest)
}

// Forever stores a value permanently (zero TTL).
func (cache *Cache) Forever(ctx context.Context, key string, value any) error {
return cache.Put(ctx, key, value, 0)
}

// Increment atomically increases the integer value stored at key by the given
// amount. Returns the new value or an error if the operation fails.
Increment(ctx context.Context, key string, by int64) (int64, error)
// Increment atomically increases the integer value at key by the
// given delta. Returns [ErrCacheUnsupportedOperation] if the driver
// does not implement [CacheCounter].
func (cache *Cache) Increment(ctx context.Context, key string, delta int64) (int64, error) {
counter, ok := cache.driver.(CacheCounter)

// Decrement atomically decreases the integer value stored at key by
// the given amount. Returns the new value or an error if the operation fails.
Decrement(ctx context.Context, key string, by int64) (int64, error)
if !ok {
return 0, ErrCacheUnsupportedOperation
}

// Remember attempts to get the value for the given key.
// If the key is missing, it calls the compute function, stores the result with the given TTL, and returns it.
Remember(ctx context.Context, key string, ttl time.Duration, compute func() (any, error)) (any, error)
return counter.Increment(ctx, key, delta)
}

// Decrement atomically decreases the integer value at key by the
// given delta. Returns [ErrCacheUnsupportedOperation] if the driver
// does not implement [CacheCounter].
func (cache *Cache) Decrement(ctx context.Context, key string, delta int64) (int64, error) {
counter, ok := cache.driver.(CacheCounter)

if !ok {
return 0, ErrCacheUnsupportedOperation
}

return counter.Decrement(ctx, key, delta)
}

// Remember retrieves the cached value for the given key, decoding it
// into dest. If the key is not found, it calls compute, stores the
// result with the given TTL, and decodes it into dest.
func (cache *Cache) Remember(ctx context.Context, key string, ttl time.Duration, dest any, compute func() (any, error)) error {
raw, err := cache.driver.Get(ctx, key)

if err == nil {
return json.Unmarshal(raw, dest)
}

if !errors.Is(err, ErrCacheKeyNotFound) {
return err
}

value, err := compute()

if err != nil {
return err
}

encoded, err := json.Marshal(value)

if err != nil {
return err
}

if err := cache.driver.Put(ctx, key, encoded, ttl); err != nil {
return err
}

return json.Unmarshal(encoded, dest)
}

// RememberForever is like Remember but stores the computed result without TTL (permanently).
RememberForever(ctx context.Context, key string, compute func() (any, error)) (any, error)
// RememberForever is like [Cache.Remember] but stores the computed
// result without expiration.
func (cache *Cache) RememberForever(ctx context.Context, key string, dest any, compute func() (any, error)) error {
return cache.Remember(ctx, key, 0, dest, compute)
}
114 changes: 83 additions & 31 deletions contract/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,56 +15,108 @@ var (
ErrDatabaseNestedTransaction = errors.New("nested transactions are not supported")
)

// Database defines a generic interface for interacting with a SQL-based datastore.
// It provides methods for executing queries, retrieving multiple or single records,
// and performing operations within a transaction context.
type Database interface {
// Close closes the database connection and releases any associated resources.
// It should be called when the database is no longer needed to prevent
// resource leaks. After calling Close, the Database instance should not
// be used for further operations.
// DatabaseDriver defines the interface for interacting with a SQL-based
// datastore. Implementations handle query execution, scanning, and
// transaction management. The [Database] wrapper provides type-safe
// convenience methods on top of this driver.
type DatabaseDriver interface {
// Close closes the database connection and releases associated resources.
Close() error

// Ping verifies that the database connection is still alive and accessible.
// It sends a simple query to the database to test connectivity and returns
// an error if the connection is unavailable or the database is unreachable.
// This method is typically used for health checks and connection validation.
// Ping will also connect to the database if the connection was not established.
// Ping verifies that the database connection is still alive.
Ping(ctx context.Context) error

// Exec executes a SQL query that modifies data (e.g., INSERT, UPDATE, DELETE).
// It returns the number of rows affected.
// Returns the number of rows affected.
Exec(ctx context.Context, query string, args ...any) (int64, error)

// ExecNamed executes a SQL query that modifies data using named parameters.
// ExecNamed executes a SQL query using named parameters.
// The arg parameter should be a struct or map containing the named parameters.
// Returns the number of rows affected.
ExecNamed(ctx context.Context, query string, arg any) (int64, error)

// Select executes a query and scans the results into dest.
// Dest should be a pointer to a slice of structs (e.g., *[]User).
// Returns an error if the query fails or if scanning is unsuccessful.
Select(ctx context.Context, query string, dest any, args ...any) error

// SelectNamed executes a query using named parameters and scans the results into dest.
// Dest should be a pointer to a slice of structs, and arg should contain the named parameters.
// Returns an error if the query fails or if scanning is unsuccessful.
// SelectNamed executes a query using named parameters and scans results into dest.
// Dest should be a pointer to a slice of structs.
SelectNamed(ctx context.Context, query string, dest any, arg any) error

// Find executes a query expected to return a single row,
// and scans the result into dest. Dest should be a pointer to a struct.
// Returns an error if no row is found, multiple rows are returned, or scanning fails.
// Find executes a query expected to return a single row and scans
// the result into dest. Dest should be a pointer to a struct.
Find(ctx context.Context, query string, dest any, args ...any) error

// FindNamed executes a query using named parameters expected to return a single row,
// and scans the result into dest. Dest should be a pointer to a struct,
// and arg should contain the named parameters.
// Returns an error if no row is found, multiple rows are returned, or scanning fails.
// FindNamed executes a query using named parameters expected to return
// a single row and scans the result into dest.
FindNamed(ctx context.Context, query string, dest any, arg any) error

// WithTransaction executes the provided function fn within a database transaction.
// If fn returns an error, the transaction is rolled back. Otherwise, it is committed.
// The tx passed to fn implements the same Database interface and can be used
// for operations within the transaction scope.
WithTransaction(ctx context.Context, fn func(tx Database) error) error
// WithTransaction executes fn within a database transaction. If fn returns
// an error, the transaction is rolled back. Otherwise, it is committed.
WithTransaction(ctx context.Context, fn func(tx DatabaseDriver) error) error
}

// Database provides a wrapper over a [DatabaseDriver] with convenience
// methods. When generic methods become available in Go, Find and Select
// will be updated to return typed values directly.
type Database struct {
driver DatabaseDriver
}

// NewDatabase creates a new [Database] that delegates operations to the given driver.
func NewDatabase(driver DatabaseDriver) *Database {
return &Database{driver: driver}
}

// Driver returns the underlying [DatabaseDriver].
func (database *Database) Driver() DatabaseDriver {
return database.driver
}

// Close closes the database connection and releases associated resources.
func (database *Database) Close() error {
return database.driver.Close()
}

// Ping verifies that the database connection is still alive.
func (database *Database) Ping(ctx context.Context) error {
return database.driver.Ping(ctx)
}

// Exec executes a SQL query that modifies data. Returns the number of rows affected.
func (database *Database) Exec(ctx context.Context, query string, args ...any) (int64, error) {
return database.driver.Exec(ctx, query, args...)
}

// ExecNamed executes a SQL query using named parameters. Returns the number of rows affected.
func (database *Database) ExecNamed(ctx context.Context, query string, arg any) (int64, error) {
return database.driver.ExecNamed(ctx, query, arg)
}

// Select executes a query and scans the results into dest.
// Dest should be a pointer to a slice of structs.
func (database *Database) Select(ctx context.Context, query string, dest any, args ...any) error {
return database.driver.Select(ctx, query, dest, args...)
}

// SelectNamed executes a query using named parameters and scans results into dest.
func (database *Database) SelectNamed(ctx context.Context, query string, dest any, arg any) error {
return database.driver.SelectNamed(ctx, query, dest, arg)
}

// Find executes a query expected to return a single row and scans the result into dest.
func (database *Database) Find(ctx context.Context, query string, dest any, args ...any) error {
return database.driver.Find(ctx, query, dest, args...)
}

// FindNamed executes a query using named parameters expected to return a single row.
func (database *Database) FindNamed(ctx context.Context, query string, dest any, arg any) error {
return database.driver.FindNamed(ctx, query, dest, arg)
}

// WithTransaction executes fn within a database transaction.
func (database *Database) WithTransaction(ctx context.Context, fn func(tx *Database) error) error {
return database.driver.WithTransaction(ctx, func(tx DatabaseDriver) error {
return fn(NewDatabase(tx))
})
}
Loading