mirror of
https://github.com/juanfont/headscale.git
synced 2025-08-11 18:17:40 +00:00
db: add sqlite "source of truth" schema
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
This commit is contained in:

committed by
Kristoffer Dalby

parent
855c48aec2
commit
c6736dd6d6
345
hscontrol/db/sqliteconfig/config.go
Normal file
345
hscontrol/db/sqliteconfig/config.go
Normal file
@@ -0,0 +1,345 @@
|
||||
// Package sqliteconfig provides type-safe configuration for SQLite databases
|
||||
// with proper enum validation and URL generation for modernc.org/sqlite driver.
|
||||
package sqliteconfig
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Errors returned by config validation.
|
||||
var (
|
||||
ErrPathEmpty = errors.New("path cannot be empty")
|
||||
ErrBusyTimeoutNegative = errors.New("busy_timeout must be >= 0")
|
||||
ErrInvalidJournalMode = errors.New("invalid journal_mode")
|
||||
ErrInvalidAutoVacuum = errors.New("invalid auto_vacuum")
|
||||
ErrWALAutocheckpoint = errors.New("wal_autocheckpoint must be >= -1")
|
||||
ErrInvalidSynchronous = errors.New("invalid synchronous")
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultBusyTimeout is the default busy timeout in milliseconds.
|
||||
DefaultBusyTimeout = 10000
|
||||
)
|
||||
|
||||
// JournalMode represents SQLite journal_mode pragma values.
|
||||
// Journal modes control how SQLite handles write transactions and crash recovery.
|
||||
//
|
||||
// Performance vs Durability Tradeoffs:
|
||||
//
|
||||
// WAL (Write-Ahead Logging) - Recommended for production:
|
||||
// - Best performance for concurrent reads/writes
|
||||
// - Readers don't block writers, writers don't block readers
|
||||
// - Excellent crash recovery with minimal data loss risk
|
||||
// - Uses additional .wal and .shm files
|
||||
// - Default choice for Headscale production deployments
|
||||
//
|
||||
// DELETE - Traditional rollback journal:
|
||||
// - Good performance for single-threaded access
|
||||
// - Readers block writers and vice versa
|
||||
// - Reliable crash recovery but with exclusive locking
|
||||
// - Creates temporary journal files during transactions
|
||||
// - Suitable for low-concurrency scenarios
|
||||
//
|
||||
// TRUNCATE - Similar to DELETE but faster cleanup:
|
||||
// - Slightly better performance than DELETE
|
||||
// - Same concurrency limitations as DELETE
|
||||
// - Faster transaction commit by truncating instead of deleting journal
|
||||
//
|
||||
// PERSIST - Journal file remains between transactions:
|
||||
// - Avoids file creation/deletion overhead
|
||||
// - Same concurrency limitations as DELETE
|
||||
// - Good for frequent small transactions
|
||||
//
|
||||
// MEMORY - Journal kept in memory:
|
||||
// - Fastest performance but NO crash recovery
|
||||
// - Data loss risk on power failure or crash
|
||||
// - Only suitable for temporary or non-critical data
|
||||
//
|
||||
// OFF - No journaling:
|
||||
// - Maximum performance but NO transaction safety
|
||||
// - High risk of database corruption on crash
|
||||
// - Should only be used for read-only or disposable databases
|
||||
type JournalMode string
|
||||
|
||||
const (
|
||||
// JournalModeWAL enables Write-Ahead Logging (RECOMMENDED for production).
|
||||
// Best concurrent performance + crash recovery. Uses additional .wal/.shm files.
|
||||
JournalModeWAL JournalMode = "WAL"
|
||||
|
||||
// JournalModeDelete uses traditional rollback journaling.
|
||||
// Good single-threaded performance, readers block writers. Creates temp journal files.
|
||||
JournalModeDelete JournalMode = "DELETE"
|
||||
|
||||
// JournalModeTruncate is like DELETE but with faster cleanup.
|
||||
// Slightly better performance than DELETE, same safety with exclusive locking.
|
||||
JournalModeTruncate JournalMode = "TRUNCATE"
|
||||
|
||||
// JournalModePersist keeps journal file between transactions.
|
||||
// Good for frequent transactions, avoids file creation/deletion overhead.
|
||||
JournalModePersist JournalMode = "PERSIST"
|
||||
|
||||
// JournalModeMemory keeps journal in memory (DANGEROUS).
|
||||
// Fastest performance but NO crash recovery - data loss on power failure.
|
||||
JournalModeMemory JournalMode = "MEMORY"
|
||||
|
||||
// JournalModeOff disables journaling entirely (EXTREMELY DANGEROUS).
|
||||
// Maximum performance but high corruption risk. Only for disposable databases.
|
||||
JournalModeOff JournalMode = "OFF"
|
||||
)
|
||||
|
||||
// IsValid returns true if the JournalMode is valid.
|
||||
func (j JournalMode) IsValid() bool {
|
||||
switch j {
|
||||
case JournalModeWAL, JournalModeDelete, JournalModeTruncate,
|
||||
JournalModePersist, JournalModeMemory, JournalModeOff:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the string representation.
|
||||
func (j JournalMode) String() string {
|
||||
return string(j)
|
||||
}
|
||||
|
||||
// AutoVacuum represents SQLite auto_vacuum pragma values.
|
||||
// Auto-vacuum controls how SQLite reclaims space from deleted data.
|
||||
//
|
||||
// Performance vs Storage Tradeoffs:
|
||||
//
|
||||
// INCREMENTAL - Recommended for production:
|
||||
// - Reclaims space gradually during normal operations
|
||||
// - Minimal performance impact on writes
|
||||
// - Database size shrinks automatically over time
|
||||
// - Can manually trigger with PRAGMA incremental_vacuum
|
||||
// - Good balance of space efficiency and performance
|
||||
//
|
||||
// FULL - Automatic space reclamation:
|
||||
// - Immediately reclaims space on every DELETE/DROP
|
||||
// - Higher write overhead due to page reorganization
|
||||
// - Keeps database file size minimal
|
||||
// - Can cause significant slowdowns on large deletions
|
||||
// - Best for applications with frequent deletes and limited storage
|
||||
//
|
||||
// NONE - No automatic space reclamation:
|
||||
// - Fastest write performance (no vacuum overhead)
|
||||
// - Database file only grows, never shrinks
|
||||
// - Deleted space is reused but file size remains large
|
||||
// - Requires manual VACUUM to reclaim space
|
||||
// - Best for write-heavy workloads where storage isn't constrained
|
||||
type AutoVacuum string
|
||||
|
||||
const (
|
||||
// AutoVacuumNone disables automatic space reclamation.
|
||||
// Fastest writes, file only grows. Requires manual VACUUM to reclaim space.
|
||||
AutoVacuumNone AutoVacuum = "NONE"
|
||||
|
||||
// AutoVacuumFull immediately reclaims space on every DELETE/DROP.
|
||||
// Minimal file size but slower writes. Can impact performance on large deletions.
|
||||
AutoVacuumFull AutoVacuum = "FULL"
|
||||
|
||||
// AutoVacuumIncremental reclaims space gradually (RECOMMENDED for production).
|
||||
// Good balance: minimal write impact, automatic space management over time.
|
||||
AutoVacuumIncremental AutoVacuum = "INCREMENTAL"
|
||||
)
|
||||
|
||||
// IsValid returns true if the AutoVacuum is valid.
|
||||
func (a AutoVacuum) IsValid() bool {
|
||||
switch a {
|
||||
case AutoVacuumNone, AutoVacuumFull, AutoVacuumIncremental:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the string representation.
|
||||
func (a AutoVacuum) String() string {
|
||||
return string(a)
|
||||
}
|
||||
|
||||
// Synchronous represents SQLite synchronous pragma values.
|
||||
// Synchronous mode controls how aggressively SQLite flushes data to disk.
|
||||
//
|
||||
// Performance vs Durability Tradeoffs:
|
||||
//
|
||||
// NORMAL - Recommended for production:
|
||||
// - Good balance of performance and safety
|
||||
// - Syncs at critical moments (transaction commits in WAL mode)
|
||||
// - Very low risk of corruption, minimal performance impact
|
||||
// - Safe with WAL mode even with power loss
|
||||
// - Default choice for most production applications
|
||||
//
|
||||
// FULL - Maximum durability:
|
||||
// - Syncs to disk after every write operation
|
||||
// - Highest data safety, virtually no corruption risk
|
||||
// - Significant performance penalty (up to 50% slower)
|
||||
// - Recommended for critical data where corruption is unacceptable
|
||||
//
|
||||
// EXTRA - Paranoid mode:
|
||||
// - Even more aggressive syncing than FULL
|
||||
// - Maximum possible data safety
|
||||
// - Severe performance impact
|
||||
// - Only for extremely critical scenarios
|
||||
//
|
||||
// OFF - Maximum performance, minimum safety:
|
||||
// - No syncing, relies on OS to flush data
|
||||
// - Fastest possible performance
|
||||
// - High risk of corruption on power failure or crash
|
||||
// - Only suitable for non-critical or easily recreatable data
|
||||
type Synchronous string
|
||||
|
||||
const (
|
||||
// SynchronousOff disables syncing (DANGEROUS).
|
||||
// Fastest performance but high corruption risk on power failure. Avoid in production.
|
||||
SynchronousOff Synchronous = "OFF"
|
||||
|
||||
// SynchronousNormal provides balanced performance and safety (RECOMMENDED).
|
||||
// Good performance with low corruption risk. Safe with WAL mode on power loss.
|
||||
SynchronousNormal Synchronous = "NORMAL"
|
||||
|
||||
// SynchronousFull provides maximum durability with performance cost.
|
||||
// Syncs after every write. Up to 50% slower but virtually no corruption risk.
|
||||
SynchronousFull Synchronous = "FULL"
|
||||
|
||||
// SynchronousExtra provides paranoid-level data safety (EXTREME).
|
||||
// Maximum safety with severe performance impact. Rarely needed in practice.
|
||||
SynchronousExtra Synchronous = "EXTRA"
|
||||
)
|
||||
|
||||
// IsValid returns true if the Synchronous is valid.
|
||||
func (s Synchronous) IsValid() bool {
|
||||
switch s {
|
||||
case SynchronousOff, SynchronousNormal, SynchronousFull, SynchronousExtra:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the string representation.
|
||||
func (s Synchronous) String() string {
|
||||
return string(s)
|
||||
}
|
||||
|
||||
// Config holds SQLite database configuration with type-safe enums.
|
||||
// This configuration balances performance, durability, and operational requirements
|
||||
// for Headscale's SQLite database usage patterns.
|
||||
type Config struct {
|
||||
Path string // file path or ":memory:"
|
||||
BusyTimeout int // milliseconds (0 = default/disabled)
|
||||
JournalMode JournalMode // journal mode (affects concurrency and crash recovery)
|
||||
AutoVacuum AutoVacuum // auto vacuum mode (affects storage efficiency)
|
||||
WALAutocheckpoint int // pages (-1 = default/not set, 0 = disabled, >0 = enabled)
|
||||
Synchronous Synchronous // synchronous mode (affects durability vs performance)
|
||||
ForeignKeys bool // enable foreign key constraints (data integrity)
|
||||
}
|
||||
|
||||
// Default returns the production configuration optimized for Headscale's usage patterns.
|
||||
// This configuration prioritizes:
|
||||
// - Concurrent access (WAL mode for multiple readers/writers)
|
||||
// - Data durability with good performance (NORMAL synchronous)
|
||||
// - Automatic space management (INCREMENTAL auto-vacuum)
|
||||
// - Data integrity (foreign key constraints enabled)
|
||||
// - Reasonable timeout for busy database scenarios (10s)
|
||||
func Default(path string) *Config {
|
||||
return &Config{
|
||||
Path: path,
|
||||
BusyTimeout: DefaultBusyTimeout,
|
||||
JournalMode: JournalModeWAL,
|
||||
AutoVacuum: AutoVacuumIncremental,
|
||||
WALAutocheckpoint: 1000,
|
||||
Synchronous: SynchronousNormal,
|
||||
ForeignKeys: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Memory returns a configuration for in-memory databases.
|
||||
func Memory() *Config {
|
||||
return &Config{
|
||||
Path: ":memory:",
|
||||
WALAutocheckpoint: -1, // not set, use driver default
|
||||
ForeignKeys: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks if all configuration values are valid.
|
||||
func (c *Config) Validate() error {
|
||||
if c.Path == "" {
|
||||
return ErrPathEmpty
|
||||
}
|
||||
|
||||
if c.BusyTimeout < 0 {
|
||||
return fmt.Errorf("%w, got %d", ErrBusyTimeoutNegative, c.BusyTimeout)
|
||||
}
|
||||
|
||||
if c.JournalMode != "" && !c.JournalMode.IsValid() {
|
||||
return fmt.Errorf("%w: %s", ErrInvalidJournalMode, c.JournalMode)
|
||||
}
|
||||
|
||||
if c.AutoVacuum != "" && !c.AutoVacuum.IsValid() {
|
||||
return fmt.Errorf("%w: %s", ErrInvalidAutoVacuum, c.AutoVacuum)
|
||||
}
|
||||
|
||||
if c.WALAutocheckpoint < -1 {
|
||||
return fmt.Errorf("%w, got %d", ErrWALAutocheckpoint, c.WALAutocheckpoint)
|
||||
}
|
||||
|
||||
if c.Synchronous != "" && !c.Synchronous.IsValid() {
|
||||
return fmt.Errorf("%w: %s", ErrInvalidSynchronous, c.Synchronous)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToURL builds a properly encoded SQLite connection string using _pragma parameters
|
||||
// compatible with modernc.org/sqlite driver.
|
||||
func (c *Config) ToURL() (string, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return "", fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
var pragmas []string
|
||||
|
||||
// Add pragma parameters only if they're set (non-zero/non-empty)
|
||||
if c.BusyTimeout > 0 {
|
||||
pragmas = append(pragmas, fmt.Sprintf("busy_timeout=%d", c.BusyTimeout))
|
||||
}
|
||||
if c.JournalMode != "" {
|
||||
pragmas = append(pragmas, fmt.Sprintf("journal_mode=%s", c.JournalMode))
|
||||
}
|
||||
if c.AutoVacuum != "" {
|
||||
pragmas = append(pragmas, fmt.Sprintf("auto_vacuum=%s", c.AutoVacuum))
|
||||
}
|
||||
if c.WALAutocheckpoint >= 0 {
|
||||
pragmas = append(pragmas, fmt.Sprintf("wal_autocheckpoint=%d", c.WALAutocheckpoint))
|
||||
}
|
||||
if c.Synchronous != "" {
|
||||
pragmas = append(pragmas, fmt.Sprintf("synchronous=%s", c.Synchronous))
|
||||
}
|
||||
if c.ForeignKeys {
|
||||
pragmas = append(pragmas, "foreign_keys=ON")
|
||||
}
|
||||
|
||||
// Handle different database types
|
||||
var baseURL string
|
||||
if c.Path == ":memory:" {
|
||||
baseURL = ":memory:"
|
||||
} else {
|
||||
baseURL = "file:" + c.Path
|
||||
}
|
||||
|
||||
// Add parameters without encoding = signs
|
||||
if len(pragmas) > 0 {
|
||||
var queryParts []string
|
||||
for _, pragma := range pragmas {
|
||||
queryParts = append(queryParts, "_pragma="+pragma)
|
||||
}
|
||||
baseURL += "?" + strings.Join(queryParts, "&")
|
||||
}
|
||||
|
||||
return baseURL, nil
|
||||
}
|
211
hscontrol/db/sqliteconfig/config_test.go
Normal file
211
hscontrol/db/sqliteconfig/config_test.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package sqliteconfig
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJournalMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
mode JournalMode
|
||||
valid bool
|
||||
}{
|
||||
{JournalModeWAL, true},
|
||||
{JournalModeDelete, true},
|
||||
{JournalModeTruncate, true},
|
||||
{JournalModePersist, true},
|
||||
{JournalModeMemory, true},
|
||||
{JournalModeOff, true},
|
||||
{JournalMode("INVALID"), false},
|
||||
{JournalMode(""), false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.mode), func(t *testing.T) {
|
||||
if got := tt.mode.IsValid(); got != tt.valid {
|
||||
t.Errorf("JournalMode(%q).IsValid() = %v, want %v", tt.mode, got, tt.valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoVacuum(t *testing.T) {
|
||||
tests := []struct {
|
||||
mode AutoVacuum
|
||||
valid bool
|
||||
}{
|
||||
{AutoVacuumNone, true},
|
||||
{AutoVacuumFull, true},
|
||||
{AutoVacuumIncremental, true},
|
||||
{AutoVacuum("INVALID"), false},
|
||||
{AutoVacuum(""), false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.mode), func(t *testing.T) {
|
||||
if got := tt.mode.IsValid(); got != tt.valid {
|
||||
t.Errorf("AutoVacuum(%q).IsValid() = %v, want %v", tt.mode, got, tt.valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynchronous(t *testing.T) {
|
||||
tests := []struct {
|
||||
mode Synchronous
|
||||
valid bool
|
||||
}{
|
||||
{SynchronousOff, true},
|
||||
{SynchronousNormal, true},
|
||||
{SynchronousFull, true},
|
||||
{SynchronousExtra, true},
|
||||
{Synchronous("INVALID"), false},
|
||||
{Synchronous(""), false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.mode), func(t *testing.T) {
|
||||
if got := tt.mode.IsValid(); got != tt.valid {
|
||||
t.Errorf("Synchronous(%q).IsValid() = %v, want %v", tt.mode, got, tt.valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid default config",
|
||||
config: Default("/path/to/db.sqlite"),
|
||||
},
|
||||
{
|
||||
name: "empty path",
|
||||
config: &Config{
|
||||
Path: "",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "negative busy timeout",
|
||||
config: &Config{
|
||||
Path: "/path/to/db.sqlite",
|
||||
BusyTimeout: -1,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid journal mode",
|
||||
config: &Config{
|
||||
Path: "/path/to/db.sqlite",
|
||||
JournalMode: JournalMode("INVALID"),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.config.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Config.Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigToURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "default config",
|
||||
config: Default("/path/to/db.sqlite"),
|
||||
want: "file:/path/to/db.sqlite?_pragma=busy_timeout=10000&_pragma=journal_mode=WAL&_pragma=auto_vacuum=INCREMENTAL&_pragma=wal_autocheckpoint=1000&_pragma=synchronous=NORMAL&_pragma=foreign_keys=ON",
|
||||
},
|
||||
{
|
||||
name: "memory config",
|
||||
config: Memory(),
|
||||
want: ":memory:?_pragma=foreign_keys=ON",
|
||||
},
|
||||
{
|
||||
name: "minimal config",
|
||||
config: &Config{
|
||||
Path: "/simple/db.sqlite",
|
||||
WALAutocheckpoint: -1, // not set
|
||||
},
|
||||
want: "file:/simple/db.sqlite",
|
||||
},
|
||||
{
|
||||
name: "custom config",
|
||||
config: &Config{
|
||||
Path: "/custom/db.sqlite",
|
||||
BusyTimeout: 5000,
|
||||
JournalMode: JournalModeDelete,
|
||||
WALAutocheckpoint: -1, // not set
|
||||
Synchronous: SynchronousFull,
|
||||
ForeignKeys: true,
|
||||
},
|
||||
want: "file:/custom/db.sqlite?_pragma=busy_timeout=5000&_pragma=journal_mode=DELETE&_pragma=synchronous=FULL&_pragma=foreign_keys=ON",
|
||||
},
|
||||
{
|
||||
name: "memory with custom timeout",
|
||||
config: &Config{
|
||||
Path: ":memory:",
|
||||
BusyTimeout: 2000,
|
||||
WALAutocheckpoint: -1, // not set
|
||||
ForeignKeys: true,
|
||||
},
|
||||
want: ":memory:?_pragma=busy_timeout=2000&_pragma=foreign_keys=ON",
|
||||
},
|
||||
{
|
||||
name: "wal autocheckpoint zero",
|
||||
config: &Config{
|
||||
Path: "/test.db",
|
||||
WALAutocheckpoint: 0,
|
||||
},
|
||||
want: "file:/test.db?_pragma=wal_autocheckpoint=0",
|
||||
},
|
||||
{
|
||||
name: "all options",
|
||||
config: &Config{
|
||||
Path: "/full.db",
|
||||
BusyTimeout: 15000,
|
||||
JournalMode: JournalModeWAL,
|
||||
AutoVacuum: AutoVacuumFull,
|
||||
WALAutocheckpoint: 1000,
|
||||
Synchronous: SynchronousExtra,
|
||||
ForeignKeys: true,
|
||||
},
|
||||
want: "file:/full.db?_pragma=busy_timeout=15000&_pragma=journal_mode=WAL&_pragma=auto_vacuum=FULL&_pragma=wal_autocheckpoint=1000&_pragma=synchronous=EXTRA&_pragma=foreign_keys=ON",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := tt.config.ToURL()
|
||||
if err != nil {
|
||||
t.Errorf("Config.ToURL() error = %v", err)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("Config.ToURL() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigToURLInvalid(t *testing.T) {
|
||||
config := &Config{
|
||||
Path: "",
|
||||
BusyTimeout: -1,
|
||||
}
|
||||
_, err := config.ToURL()
|
||||
if err == nil {
|
||||
t.Error("Config.ToURL() with invalid config should return error")
|
||||
}
|
||||
}
|
269
hscontrol/db/sqliteconfig/integration_test.go
Normal file
269
hscontrol/db/sqliteconfig/integration_test.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package sqliteconfig
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const memoryDBPath = ":memory:"
|
||||
|
||||
// TestSQLiteDriverPragmaIntegration verifies that the modernc.org/sqlite driver
|
||||
// correctly applies all pragma settings from URL parameters, ensuring they work
|
||||
// the same as the old SQL PRAGMA statements approach.
|
||||
func TestSQLiteDriverPragmaIntegration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
expected map[string]any
|
||||
}{
|
||||
{
|
||||
name: "default configuration",
|
||||
config: Default("/tmp/test.db"),
|
||||
expected: map[string]any{
|
||||
"busy_timeout": 10000,
|
||||
"journal_mode": "wal",
|
||||
"auto_vacuum": 2, // INCREMENTAL = 2
|
||||
"wal_autocheckpoint": 1000,
|
||||
"synchronous": 1, // NORMAL = 1
|
||||
"foreign_keys": 1, // ON = 1
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "memory database with foreign keys",
|
||||
config: Memory(),
|
||||
expected: map[string]any{
|
||||
"foreign_keys": 1, // ON = 1
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom configuration",
|
||||
config: &Config{
|
||||
Path: "/tmp/custom.db",
|
||||
BusyTimeout: 5000,
|
||||
JournalMode: JournalModeDelete,
|
||||
AutoVacuum: AutoVacuumFull,
|
||||
WALAutocheckpoint: 1000,
|
||||
Synchronous: SynchronousFull,
|
||||
ForeignKeys: true,
|
||||
},
|
||||
expected: map[string]any{
|
||||
"busy_timeout": 5000,
|
||||
"journal_mode": "delete",
|
||||
"auto_vacuum": 1, // FULL = 1
|
||||
"wal_autocheckpoint": 1000,
|
||||
"synchronous": 2, // FULL = 2
|
||||
"foreign_keys": 1, // ON = 1
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "foreign keys disabled",
|
||||
config: &Config{
|
||||
Path: "/tmp/no_fk.db",
|
||||
ForeignKeys: false,
|
||||
},
|
||||
expected: map[string]any{
|
||||
// foreign_keys should not be set (defaults to 0/OFF)
|
||||
"foreign_keys": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create temporary database file if not memory
|
||||
if tt.config.Path == memoryDBPath {
|
||||
// For memory databases, no changes needed
|
||||
} else {
|
||||
tempDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "test.db")
|
||||
// Update config with actual temp path
|
||||
configCopy := *tt.config
|
||||
configCopy.Path = dbPath
|
||||
tt.config = &configCopy
|
||||
}
|
||||
|
||||
// Generate URL and open database
|
||||
url, err := tt.config.ToURL()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate URL: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Opening database with URL: %s", url)
|
||||
|
||||
db, err := sql.Open("sqlite", url)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Test connection
|
||||
if err := db.Ping(); err != nil {
|
||||
t.Fatalf("Failed to ping database: %v", err)
|
||||
}
|
||||
|
||||
// Verify each expected pragma setting
|
||||
for pragma, expectedValue := range tt.expected {
|
||||
t.Run("pragma_"+pragma, func(t *testing.T) {
|
||||
var actualValue any
|
||||
query := "PRAGMA " + pragma
|
||||
err := db.QueryRow(query).Scan(&actualValue)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query %s: %v", query, err)
|
||||
}
|
||||
|
||||
t.Logf("%s: expected=%v, actual=%v", pragma, expectedValue, actualValue)
|
||||
|
||||
// Handle type conversion for comparison
|
||||
switch expected := expectedValue.(type) {
|
||||
case int:
|
||||
if actual, ok := actualValue.(int64); ok {
|
||||
if int64(expected) != actual {
|
||||
t.Errorf("%s: expected %d, got %d", pragma, expected, actual)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("%s: expected int %d, got %T %v", pragma, expected, actualValue, actualValue)
|
||||
}
|
||||
case string:
|
||||
if actual, ok := actualValue.(string); ok {
|
||||
if expected != actual {
|
||||
t.Errorf("%s: expected %q, got %q", pragma, expected, actual)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("%s: expected string %q, got %T %v", pragma, expected, actualValue, actualValue)
|
||||
}
|
||||
default:
|
||||
t.Errorf("Unsupported expected type for %s: %T", pragma, expectedValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestForeignKeyConstraintEnforcement verifies that foreign key constraints
|
||||
// are actually enforced when enabled via URL parameters.
|
||||
func TestForeignKeyConstraintEnforcement(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dbPath := filepath.Join(tempDir, "fk_test.db")
|
||||
config := Default(dbPath)
|
||||
|
||||
url, err := config.ToURL()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate URL: %v", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", url)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Create test tables with foreign key relationship
|
||||
schema := `
|
||||
CREATE TABLE parent (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE child (
|
||||
id INTEGER PRIMARY KEY,
|
||||
parent_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
FOREIGN KEY (parent_id) REFERENCES parent(id)
|
||||
);
|
||||
`
|
||||
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
t.Fatalf("Failed to create schema: %v", err)
|
||||
}
|
||||
|
||||
// Insert parent record
|
||||
if _, err := db.Exec("INSERT INTO parent (id, name) VALUES (1, 'Parent 1')"); err != nil {
|
||||
t.Fatalf("Failed to insert parent: %v", err)
|
||||
}
|
||||
|
||||
// Test 1: Valid foreign key should work
|
||||
_, err = db.Exec("INSERT INTO child (id, parent_id, name) VALUES (1, 1, 'Child 1')")
|
||||
if err != nil {
|
||||
t.Fatalf("Valid foreign key insert failed: %v", err)
|
||||
}
|
||||
|
||||
// Test 2: Invalid foreign key should fail
|
||||
_, err = db.Exec("INSERT INTO child (id, parent_id, name) VALUES (2, 999, 'Child 2')")
|
||||
if err == nil {
|
||||
t.Error("Expected foreign key constraint violation, but insert succeeded")
|
||||
} else if !contains(err.Error(), "FOREIGN KEY constraint failed") {
|
||||
t.Errorf("Expected foreign key constraint error, got: %v", err)
|
||||
} else {
|
||||
t.Logf("✓ Foreign key constraint correctly enforced: %v", err)
|
||||
}
|
||||
|
||||
// Test 3: Deleting referenced parent should fail
|
||||
_, err = db.Exec("DELETE FROM parent WHERE id = 1")
|
||||
if err == nil {
|
||||
t.Error("Expected foreign key constraint violation when deleting referenced parent")
|
||||
} else if !contains(err.Error(), "FOREIGN KEY constraint failed") {
|
||||
t.Errorf("Expected foreign key constraint error on delete, got: %v", err)
|
||||
} else {
|
||||
t.Logf("✓ Foreign key constraint correctly prevented parent deletion: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJournalModeValidation verifies that the journal_mode setting is applied correctly.
|
||||
func TestJournalModeValidation(t *testing.T) {
|
||||
modes := []struct {
|
||||
mode JournalMode
|
||||
expected string
|
||||
}{
|
||||
{JournalModeWAL, "wal"},
|
||||
{JournalModeDelete, "delete"},
|
||||
{JournalModeTruncate, "truncate"},
|
||||
{JournalModeMemory, "memory"},
|
||||
}
|
||||
|
||||
for _, tt := range modes {
|
||||
t.Run(string(tt.mode), func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dbPath := filepath.Join(tempDir, "journal_test.db")
|
||||
config := &Config{
|
||||
Path: dbPath,
|
||||
JournalMode: tt.mode,
|
||||
ForeignKeys: true,
|
||||
}
|
||||
|
||||
url, err := config.ToURL()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate URL: %v", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", url)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
var actualMode string
|
||||
err = db.QueryRow("PRAGMA journal_mode").Scan(&actualMode)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query journal_mode: %v", err)
|
||||
}
|
||||
|
||||
if actualMode != tt.expected {
|
||||
t.Errorf("journal_mode: expected %q, got %q", tt.expected, actualMode)
|
||||
} else {
|
||||
t.Logf("✓ journal_mode correctly set to: %s", actualMode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// contains checks if a string contains a substring (helper function).
|
||||
func contains(str, substr string) bool {
|
||||
return strings.Contains(str, substr)
|
||||
}
|
Reference in New Issue
Block a user