Files
browser/plugins/examples/scum-server-plugin/companion/sqlite_source.go
T

185 lines
5.7 KiB
Go

package companion
import (
"context"
"database/sql"
"fmt"
"os"
"strings"
"time"
_ "modernc.org/sqlite"
)
var openSQLite = sql.Open
type SCUMSQLiteSource struct{ db *sql.DB }
func NewSCUMSQLiteSource(db *sql.DB) (*SCUMSQLiteSource, error) {
if db == nil {
return nil, fmt.Errorf("SCUM database source is required")
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
return &SCUMSQLiteSource{db: db}, nil
}
func OpenSCUMSQLiteSourceFromEnv(envName string) (*SCUMSQLiteSource, error) {
name := strings.TrimSpace(envName)
if name == "" {
name = SCUMDatabaseFileEnvironment
}
databaseFile := strings.TrimSpace(os.Getenv(name))
if databaseFile == "" {
return nil, fmt.Errorf("%s is required for SCUM database collection", name)
}
info, err := os.Stat(databaseFile)
if err != nil || info.IsDir() {
return nil, fmt.Errorf("%s must reference a readable SCUM database file", name)
}
db, err := openSQLite("sqlite", databaseFile)
if err != nil {
return nil, fmt.Errorf("open SCUM database source: %w", err)
}
if _, err := db.Exec("PRAGMA query_only = ON"); err != nil {
_ = db.Close()
return nil, fmt.Errorf("prepare SCUM database source for read-only collection: %w", err)
}
if _, err := db.Exec("PRAGMA busy_timeout = 5000"); err != nil {
_ = db.Close()
return nil, fmt.Errorf("prepare SCUM database source timeout: %w", err)
}
return NewSCUMSQLiteSource(db)
}
func (source *SCUMSQLiteSource) Close() error {
if source == nil || source.db == nil {
return nil
}
return source.db.Close()
}
func (source *SCUMSQLiteSource) ReadPositionRows(ctx context.Context, limit int) ([]map[string]any, error) {
return source.readRows(ctx, scumPositionRowsSQL, limit,
sql.Named("subjectType", nil),
sql.Named("subjectId", nil),
sql.Named("limit", boundedTrajectoryLimit(limit)),
)
}
func (source *SCUMSQLiteSource) ReadVehicleRows(ctx context.Context, limit int) ([]map[string]any, error) {
return source.readRows(ctx, scumVehicleRowsSQL, limit,
sql.Named("vehicleId", nil),
sql.Named("search", nil),
sql.Named("limit", boundedTrajectoryLimit(limit)),
)
}
func (source *SCUMSQLiteSource) readRows(ctx context.Context, query string, limit int, args ...any) ([]map[string]any, error) {
if source == nil || source.db == nil {
return nil, fmt.Errorf("SCUM database source is not configured")
}
rows, err := source.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("read SCUM database rows: %w", err)
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
return nil, fmt.Errorf("read SCUM database columns: %w", err)
}
maxRows := boundedTrajectoryLimit(limit)
result := make([]map[string]any, 0, maxRows)
values := make([]any, len(columns))
scanTargets := make([]any, len(columns))
for index := range values {
scanTargets[index] = &values[index]
}
for rows.Next() {
if len(result) >= maxRows {
break
}
if err := rows.Scan(scanTargets...); err != nil {
return nil, fmt.Errorf("scan SCUM database rows: %w", err)
}
row := make(map[string]any, len(columns))
for index, column := range columns {
row[column] = normalizeSQLiteValue(values[index])
}
result = append(result, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("read SCUM database rows: %w", err)
}
return result, nil
}
func boundedTrajectoryLimit(limit int) int {
if limit <= 0 {
return DefaultTrajectoryCollectionMaxRows
}
if limit > 5000 {
return 5000
}
return limit
}
func normalizeSQLiteValue(value any) any {
switch typed := value.(type) {
case []byte:
return string(typed)
case time.Time:
return typed.UTC().Format(time.RFC3339Nano)
default:
return typed
}
}
const scumPositionRowsSQL = `SELECT
'player' AS subjectType,
account.id AS subjectId,
CAST(profile.id AS TEXT) AS userProfileId,
CAST(prisoner.id AS TEXT) AS gamePlayerId,
NULL AS vehicleId,
CAST(entity.id AS TEXT) AS entityId,
NULL AS baseId,
entity.location_x AS x,
entity.location_y AS y,
entity.location_z AS z,
strftime('%Y-%m-%dT%H:%M:%SZ', prisoner.last_save_time, 'unixepoch') AS observedAt
FROM user_profile profile
JOIN user account ON account.id = profile.user_id
JOIN prisoner ON prisoner.id = profile.prisoner_id
JOIN prisoner_entity ON prisoner_entity.prisoner_id = prisoner.id
JOIN entity ON entity.id = prisoner_entity.entity_id
WHERE (:subjectType IS NULL OR :subjectType = 'player')
AND (:subjectId IS NULL OR account.id = :subjectId)
UNION ALL
SELECT
'vehicle', CAST(spawner.vehicle_entity_id AS TEXT), NULL, NULL,
CAST(spawner.vehicle_entity_id AS TEXT), CAST(entity.id AS TEXT), NULL,
entity.location_x, entity.location_y, entity.location_z,
strftime('%Y-%m-%dT%H:%M:%SZ', spawner.vehicle_last_access_time, 'unixepoch')
FROM vehicle_spawner spawner
JOIN entity ON entity.id = spawner.vehicle_entity_id
WHERE (:subjectType IS NULL OR :subjectType = 'vehicle')
AND (:subjectId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :subjectId)
LIMIT COALESCE(:limit, 500)`
const scumVehicleRowsSQL = `SELECT
CAST(spawner.vehicle_entity_id AS TEXT) AS vehicleId,
CAST(spawner.vehicle_entity_id AS TEXT) AS entityId,
entity.class AS className,
spawner.vehicle_alias AS label,
entity.location_x AS x,
entity.location_y AS y,
entity.location_z AS z,
strftime('%Y-%m-%dT%H:%M:%SZ', spawner.vehicle_last_access_time, 'unixepoch') AS lastAccessTime,
spawner.is_vehicle_functional AS isFunctional
FROM vehicle_spawner spawner
JOIN entity ON entity.id = spawner.vehicle_entity_id
WHERE (:vehicleId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :vehicleId)
AND (:search IS NULL OR spawner.vehicle_alias LIKE '%' || :search || '%' OR entity.class LIKE '%' || :search || '%')
ORDER BY spawner.vehicle_last_access_time DESC
LIMIT COALESCE(:limit, 500)`