185 lines
5.7 KiB
Go
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)`
|