diff --git a/engine/Sandbox.Engine/Sandbox.Engine.csproj b/engine/Sandbox.Engine/Sandbox.Engine.csproj
index 9cf1acc08..63de938a3 100644
--- a/engine/Sandbox.Engine/Sandbox.Engine.csproj
+++ b/engine/Sandbox.Engine/Sandbox.Engine.csproj
@@ -41,6 +41,7 @@
+
diff --git a/engine/Sandbox.Engine/Utility/Sql/Sql.cs b/engine/Sandbox.Engine/Utility/Sql/Sql.cs
new file mode 100644
index 000000000..f31137d65
--- /dev/null
+++ b/engine/Sandbox.Engine/Utility/Sql/Sql.cs
@@ -0,0 +1,446 @@
+using System.Threading;
+using Microsoft.Data.Sqlite;
+
+namespace Sandbox.Utility;
+
+///
+/// Provides SQLite database functionality.
+/// This is the primary interface for interacting with a per-game SQLite database.
+///
+public static class Sql
+{
+ private static SqlDatabase s_serverDatabase;
+ private static SqlDatabase s_clientDatabase;
+ private static readonly Lock Lock = new();
+
+ ///
+ /// The last error that occurred during a query, or null if no error.
+ ///
+ public static string LastError { get; private set; }
+
+ ///
+ /// Gets the default SQLite database for the current context.
+ /// Uses cl.db when connected as client, sv.db when hosting.
+ ///
+ internal static SqlDatabase Database
+ {
+ get
+ {
+ if ( Networking.IsClient )
+ {
+ lock ( Lock )
+ return s_clientDatabase ??= OpenDatabase( "cl.db" );
+ }
+ else
+ {
+ lock ( Lock )
+ return s_serverDatabase ??= OpenDatabase( "sv.db" );
+ }
+ }
+ }
+
+ ///
+ /// Gets the server-side database (sv.db). Use this when you explicitly need
+ /// server storage regardless of the current network context.
+ ///
+ public static SqlDatabase Server
+ {
+ get
+ {
+ lock ( Lock )
+ return s_serverDatabase ??= OpenDatabase( "sv.db" );
+ }
+ }
+
+ ///
+ /// Gets the client-side database (cl.db). Use this when you explicitly need
+ /// client storage regardless of the current network context.
+ ///
+ public static SqlDatabase Client
+ {
+ get
+ {
+ lock ( Lock )
+ return s_clientDatabase ??= OpenDatabase( "cl.db" );
+ }
+ }
+
+ private static SqlDatabase OpenDatabase( string filename )
+ {
+ var dataPath = GetDatabasePath( filename );
+ var db = new SqlDatabase( dataPath );
+ return db;
+ }
+
+ private static string GetDatabasePath( string filename )
+ {
+ // Use the game's data directory for storing the SQLite database
+ var dataFolder = EngineFileSystem.Root.GetFullPath( "data" );
+ System.IO.Directory.CreateDirectory( dataFolder );
+ return System.IO.Path.Combine( dataFolder, filename );
+ }
+
+ ///
+ /// Executes a SQL query and returns the results as a list of dictionaries.
+ /// Each dictionary represents a row where keys are column names and values are the cell values.
+ ///
+ /// The SQL query to execute.
+ /// Optional parameters for parameterized queries.
+ ///
+ /// A list of dictionaries for SELECT queries, an empty list for non-SELECT queries that succeed,
+ /// or null if an error occurred (check for details).
+ ///
+ ///
+ ///
+ /// // Simple query
+ /// var results = Sql.Query( "SELECT * FROM users WHERE id = @id", new { id = 1 } );
+ ///
+ /// // Iterate results
+ /// foreach ( var row in results )
+ /// {
+ /// Log.Info( $"Name: {row["name"]}" );
+ /// }
+ ///
+ ///
+ public static List> Query( string query, object parameters = null )
+ {
+ LastError = null;
+
+ try
+ {
+ return Database.Query( query, parameters );
+ }
+ catch ( SqliteException ex )
+ {
+ LastError = ex.Message;
+ Log.Warning( $"SQL Error: {ex.Message}" );
+ return null;
+ }
+ }
+
+ ///
+ /// Executes a non-query SQL command (INSERT, UPDATE, DELETE, CREATE, etc.).
+ ///
+ /// The SQL command to execute.
+ /// Optional parameters for parameterized queries.
+ /// The number of rows affected, or -1 if an error occurred.
+ ///
+ ///
+ /// Sql.Execute( "CREATE TABLE users ( id INTEGER PRIMARY KEY, name TEXT )" );
+ /// Sql.Execute( "INSERT INTO users (name) VALUES (@name)", new { name = "John" } );
+ /// var affected = Sql.Execute( "DELETE FROM users WHERE id > @id", new { id = 10 } );
+ ///
+ ///
+ public static int Execute( string query, object parameters = null )
+ {
+ LastError = null;
+
+ try
+ {
+ return Database.Execute( query, parameters );
+ }
+ catch ( SqliteException ex )
+ {
+ LastError = ex.Message;
+ Log.Warning( $"SQL Error: {ex.Message}" );
+ return -1;
+ }
+ }
+
+ ///
+ /// Executes a SQL query asynchronously and returns the results as a list of dictionaries.
+ ///
+ /// The SQL query to execute.
+ /// Optional parameters for parameterized queries.
+ ///
+ /// A task containing a list of dictionaries for SELECT queries, an empty list for non-SELECT queries that succeed,
+ /// or null if an error occurred (check for details).
+ ///
+ public static async Task>> QueryAsync( string query, object parameters = null )
+ {
+ LastError = null;
+
+ try
+ {
+ return await Database.QueryAsync( query, parameters );
+ }
+ catch ( SqliteException ex )
+ {
+ LastError = ex.Message;
+ Log.Warning( $"SQL Error: {ex.Message}" );
+ return null;
+ }
+ }
+
+ ///
+ /// Executes a SQL query and returns a single row as a dictionary.
+ ///
+ /// The SQL query to execute.
+ /// The row index to return (0-based). Defaults to 0 (first row).
+ /// Optional parameters for parameterized queries.
+ ///
+ /// A dictionary representing the requested row, or null if no rows exist or an error occurred.
+ ///
+ ///
+ ///
+ /// var user = Sql.QueryRow( "SELECT * FROM users WHERE id = @id", parameters: new { id = 1 } );
+ /// if ( user != null )
+ /// {
+ /// Log.Info( $"User name: {user["name"]}" );
+ /// }
+ ///
+ ///
+ public static Dictionary QueryRow( string query, int row = 0, object parameters = null )
+ {
+ var results = Query( query, parameters );
+ return results is null || results.Count <= row ? null : results[row];
+ }
+
+ ///
+ /// Executes a SQL query and returns a single value from the first column of the first row.
+ ///
+ /// The SQL query to execute.
+ /// Optional parameters for parameterized queries.
+ ///
+ /// The value of the first column of the first row, or null if no rows exist or an error occurred.
+ ///
+ ///
+ ///
+ /// var count = Sql.QueryValue( "SELECT COUNT(*) FROM users" );
+ /// Log.Info( $"Total users: {count}" );
+ ///
+ ///
+ public static object QueryValue( string query, object parameters = null )
+ {
+ LastError = null;
+
+ try
+ {
+ return Database.QueryValue( query, parameters );
+ }
+ catch ( SqliteException ex )
+ {
+ LastError = ex.Message;
+ Log.Warning( $"SQL Error: {ex.Message}" );
+ return null;
+ }
+ }
+
+ ///
+ /// Executes a SQL query and returns a single value cast to the specified type.
+ ///
+ /// The type to cast the result to.
+ /// The SQL query to execute.
+ /// Optional parameters for parameterized queries.
+ ///
+ /// The value cast to type T, or default(T) if no rows exist, an error occurred, or the cast failed.
+ ///
+ ///
+ ///
+ /// var count = Sql.QueryValue<int>( "SELECT COUNT(*) FROM users" );
+ /// var name = Sql.QueryValue<string>( "SELECT name FROM users WHERE id = @id", new { id = 1 } );
+ ///
+ ///
+ public static T QueryValue( string query, object parameters = null )
+ {
+ var value = QueryValue( query, parameters );
+
+ if ( value is null )
+ return default;
+
+ try
+ {
+ return (T)Convert.ChangeType( value, typeof( T ) );
+ }
+ catch ( Exception ex )
+ {
+ Log.Warning( $"SQL: Failed to convert '{value}' to {typeof( T ).Name}: {ex.Message}" );
+ return default;
+ }
+ }
+
+ ///
+ /// Checks if a table with the specified name exists in the database.
+ ///
+ /// The name of the table to check.
+ /// True if the table exists, false otherwise.
+ ///
+ ///
+ /// if ( !Sql.TableExists( "users" ) )
+ /// {
+ /// Sql.Query( "CREATE TABLE users ( id INTEGER PRIMARY KEY, name TEXT )" );
+ /// }
+ ///
+ ///
+ public static bool TableExists( string tableName )
+ {
+ var result = Query( "SELECT name FROM sqlite_master WHERE name = @name AND type = 'table'",
+ new { name = tableName } );
+
+ return result is not null && result.Count > 0;
+ }
+
+ ///
+ /// Checks if an index with the specified name exists in the database.
+ ///
+ /// The name of the index to check.
+ /// True if the index exists, false otherwise.
+ public static bool IndexExists( string indexName )
+ {
+ var result = Query( "SELECT name FROM sqlite_master WHERE name = @name AND type = 'index'",
+ new { name = indexName } );
+
+ return result is not null && result.Count > 0;
+ }
+
+ ///
+ /// Escapes a string for safe use in SQL queries by escaping single quotes.
+ /// For new code, prefer using parameterized queries instead.
+ ///
+ /// The string to escape.
+ /// If true, wraps the result in single quotes. Defaults to true.
+ /// The escaped string, optionally wrapped in single quotes.
+ ///
+ ///
+ /// // Legacy style (not recommended)
+ /// var safeName = Sql.Escape( userInput );
+ /// Sql.Query( $"SELECT * FROM users WHERE name = {safeName}" );
+ ///
+ /// // Preferred: Use parameterized queries
+ /// Sql.Query( "SELECT * FROM users WHERE name = @name", new { name = userInput } );
+ ///
+ ///
+ public static string Escape( string input, bool includeQuotes = true )
+ {
+ if ( input is null )
+ return includeQuotes ? "''" : "";
+
+ var escaped = input.Replace( "'", "''" );
+
+ // Truncate at null character for safety
+ var nullIndex = escaped.IndexOf( '\0' );
+ if ( nullIndex >= 0 )
+ {
+ escaped = escaped[..nullIndex];
+ }
+
+ return includeQuotes ? $"'{escaped}'" : escaped;
+ }
+
+ ///
+ /// Begins a transaction. Use this before performing many insert operations
+ /// for significantly improved performance.
+ ///
+ ///
+ /// Always call after completing your operations, or to cancel them.
+ ///
+ ///
+ ///
+ /// Sql.Begin();
+ /// try
+ /// {
+ /// for ( int i = 0; i < 1000; i++ )
+ /// {
+ /// Sql.Query( "INSERT INTO data (value) VALUES (@v)", new { v = i } );
+ /// }
+ /// Sql.Commit();
+ /// }
+ /// catch
+ /// {
+ /// Sql.Rollback();
+ /// throw;
+ /// }
+ ///
+ ///
+ public static void Begin()
+ {
+ Database.Execute( "BEGIN TRANSACTION" );
+ }
+
+ ///
+ /// Commits a transaction, writing all changes to disk.
+ ///
+ public static void Commit()
+ {
+ Database.Execute( "COMMIT" );
+ }
+
+ ///
+ /// Rolls back a transaction, discarding all changes since the last call.
+ ///
+ public static void Rollback()
+ {
+ Database.Execute( "ROLLBACK" );
+ }
+
+ ///
+ /// Gets the row ID of the last inserted row.
+ ///
+ /// The row ID of the last inserted row, or 0 if no insert has been performed.
+ public static long LastInsertRowId()
+ {
+ return QueryValue( "SELECT last_insert_rowid()" );
+ }
+
+ ///
+ /// Gets the number of rows affected by the last INSERT, UPDATE, or DELETE statement.
+ ///
+ /// The number of rows affected.
+ public static int RowsAffected()
+ {
+ return QueryValue( "SELECT changes()" );
+ }
+
+ ///
+ /// Gets all table names in the database.
+ ///
+ /// A list of table names.
+ public static List GetTables()
+ {
+ var results = Query( "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name" );
+ var tables = new List();
+
+ if ( results is not null )
+ {
+ foreach ( var row in results )
+ {
+ if ( row.TryGetValue( "name", out var name ) && name is string tableName )
+ {
+ tables.Add( tableName );
+ }
+ }
+ }
+
+ return tables;
+ }
+
+ ///
+ /// Gets column information for a specified table.
+ ///
+ /// The name of the table.
+ /// A list of column information dictionaries.
+ ///
+ /// Warning: This method uses string interpolation for the table name because SQLite PRAGMA
+ /// statements don't support parameterized queries. Do not pass untrusted user input as the
+ /// table name. Table names should come from your code, not from user input.
+ ///
+ public static List> GetTableColumns( string tableName )
+ {
+ return Query( $"PRAGMA table_info({tableName})" );
+ }
+
+ ///
+ /// Shuts down the default database connection. Called automatically when the engine shuts down.
+ ///
+ internal static void Shutdown()
+ {
+ lock ( Lock )
+ {
+ s_serverDatabase?.Dispose();
+ s_serverDatabase = null;
+
+ s_clientDatabase?.Dispose();
+ s_clientDatabase = null;
+ }
+ }
+}
diff --git a/engine/Sandbox.Engine/Utility/Sql/SqlBuilder.cs b/engine/Sandbox.Engine/Utility/Sql/SqlBuilder.cs
new file mode 100644
index 000000000..51dc24612
--- /dev/null
+++ b/engine/Sandbox.Engine/Utility/Sql/SqlBuilder.cs
@@ -0,0 +1,393 @@
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace Sandbox.Utility;
+
+///
+/// A fluent SQL query builder for constructing safe, parameterized SQL queries.
+///
+///
+///
+/// // SELECT query
+/// var query = new SqlBuilder()
+/// .Select( "id", "name", "score" )
+/// .From( "users" )
+/// .Where( "score > @minScore", new { minScore = 100 } )
+/// .OrderBy( "score DESC" )
+/// .Limit( 10 );
+///
+/// var results = Sql.Query( query.Build(), query.Parameters );
+///
+/// // INSERT query
+/// var insert = new SqlBuilder()
+/// .InsertInto( "users", "name", "score" )
+/// .Values( new { name = "John", score = 500 } );
+///
+/// Sql.Query( insert.Build(), insert.Parameters );
+///
+///
+public sealed class SqlBuilder
+{
+ private readonly StringBuilder _query = new();
+ private int _parameterIndex;
+
+ ///
+ /// Gets the parameters dictionary for use with .
+ ///
+ public Dictionary Parameters { get; } = new( StringComparer.OrdinalIgnoreCase );
+
+ ///
+ /// Creates a new SQL query builder.
+ ///
+ public SqlBuilder()
+ {
+ }
+
+ ///
+ /// Adds a SELECT clause to the query.
+ ///
+ /// The columns to select. Use "*" for all columns.
+ /// This builder for method chaining.
+ public SqlBuilder Select( params string[] columns )
+ {
+ _query.Append( "SELECT " );
+ _query.Append( columns.Length == 0 ? "*" : string.Join( ", ", columns ) );
+ return this;
+ }
+
+ ///
+ /// Adds a SELECT DISTINCT clause to the query.
+ ///
+ /// The columns to select.
+ /// This builder for method chaining.
+ public SqlBuilder SelectDistinct( params string[] columns )
+ {
+ _query.Append( "SELECT DISTINCT " );
+ _query.Append( columns.Length == 0 ? "*" : string.Join( ", ", columns ) );
+ return this;
+ }
+
+ ///
+ /// Adds a FROM clause to the query.
+ ///
+ /// The table name.
+ /// This builder for method chaining.
+ public SqlBuilder From( string table )
+ {
+ _query.Append( " FROM " );
+ _query.Append( table );
+ return this;
+ }
+
+ ///
+ /// Adds a JOIN clause to the query.
+ ///
+ /// The table to join.
+ /// The join condition.
+ /// Optional parameters for the condition.
+ /// This builder for method chaining.
+ public SqlBuilder Join( string table, string condition, object parameters = null )
+ {
+ _query.Append( " JOIN " );
+ _query.Append( table );
+ _query.Append( " ON " );
+ _query.Append( ProcessCondition( condition, parameters ) );
+ return this;
+ }
+
+ ///
+ /// Adds a LEFT JOIN clause to the query.
+ ///
+ /// The table to join.
+ /// The join condition.
+ /// Optional parameters for the condition.
+ /// This builder for method chaining.
+ public SqlBuilder LeftJoin( string table, string condition, object parameters = null )
+ {
+ _query.Append( " LEFT JOIN " );
+ _query.Append( table );
+ _query.Append( " ON " );
+ _query.Append( ProcessCondition( condition, parameters ) );
+ return this;
+ }
+
+ ///
+ /// Adds a WHERE clause to the query.
+ ///
+ /// The WHERE condition.
+ /// Optional parameters for the condition.
+ /// This builder for method chaining.
+ public SqlBuilder Where( string condition, object parameters = null )
+ {
+ _query.Append( " WHERE " );
+ _query.Append( ProcessCondition( condition, parameters ) );
+ return this;
+ }
+
+ ///
+ /// Adds an AND clause to an existing WHERE condition.
+ ///
+ /// The AND condition.
+ /// Optional parameters for the condition.
+ /// This builder for method chaining.
+ public SqlBuilder And( string condition, object parameters = null )
+ {
+ _query.Append( " AND " );
+ _query.Append( ProcessCondition( condition, parameters ) );
+ return this;
+ }
+
+ ///
+ /// Adds an OR clause to an existing WHERE condition.
+ ///
+ /// The OR condition.
+ /// Optional parameters for the condition.
+ /// This builder for method chaining.
+ public SqlBuilder Or( string condition, object parameters = null )
+ {
+ _query.Append( " OR " );
+ _query.Append( ProcessCondition( condition, parameters ) );
+ return this;
+ }
+
+ ///
+ /// Adds an ORDER BY clause to the query.
+ ///
+ /// The ORDER BY expression (e.g., "name ASC", "score DESC").
+ /// This builder for method chaining.
+ public SqlBuilder OrderBy( string orderBy )
+ {
+ _query.Append( " ORDER BY " );
+ _query.Append( orderBy );
+ return this;
+ }
+
+ ///
+ /// Adds a GROUP BY clause to the query.
+ ///
+ /// The columns to group by.
+ /// This builder for method chaining.
+ public SqlBuilder GroupBy( params string[] columns )
+ {
+ _query.Append( " GROUP BY " );
+ _query.Append( string.Join( ", ", columns ) );
+ return this;
+ }
+
+ ///
+ /// Adds a HAVING clause to the query (used with GROUP BY).
+ ///
+ /// The HAVING condition.
+ /// Optional parameters for the condition.
+ /// This builder for method chaining.
+ public SqlBuilder Having( string condition, object parameters = null )
+ {
+ _query.Append( " HAVING " );
+ _query.Append( ProcessCondition( condition, parameters ) );
+ return this;
+ }
+
+ ///
+ /// Adds a LIMIT clause to the query.
+ ///
+ /// The maximum number of rows to return.
+ /// This builder for method chaining.
+ public SqlBuilder Limit( int count )
+ {
+ _query.Append( " LIMIT " );
+ _query.Append( count );
+ return this;
+ }
+
+ ///
+ /// Adds an OFFSET clause to the query.
+ ///
+ /// The number of rows to skip.
+ /// This builder for method chaining.
+ public SqlBuilder Offset( int offset )
+ {
+ _query.Append( " OFFSET " );
+ _query.Append( offset );
+ return this;
+ }
+
+ ///
+ /// Adds an INSERT INTO clause to the query.
+ ///
+ /// The table to insert into.
+ /// The columns to insert values into.
+ /// This builder for method chaining.
+ public SqlBuilder InsertInto( string table, params string[] columns )
+ {
+ _query.Append( "INSERT INTO " );
+ _query.Append( table );
+
+ if ( columns.Length > 0 )
+ {
+ _query.Append( " (" );
+ _query.Append( string.Join( ", ", columns ) );
+ _query.Append( ')' );
+ }
+
+ return this;
+ }
+
+ ///
+ /// Adds an INSERT OR REPLACE clause to the query (upsert).
+ ///
+ /// The table to insert into.
+ /// The columns to insert values into.
+ /// This builder for method chaining.
+ public SqlBuilder InsertOrReplace( string table, params string[] columns )
+ {
+ _query.Append( "INSERT OR REPLACE INTO " );
+ _query.Append( table );
+
+ if ( columns.Length > 0 )
+ {
+ _query.Append( " (" );
+ _query.Append( string.Join( ", ", columns ) );
+ _query.Append( ')' );
+ }
+
+ return this;
+ }
+
+ ///
+ /// Adds a VALUES clause with parameters from an object.
+ ///
+ /// An anonymous object containing the values.
+ /// This builder for method chaining.
+ public SqlBuilder Values( object values )
+ {
+ var type = values.GetType();
+ var valueList = new List();
+
+ foreach ( var property in type.GetProperties() )
+ {
+ var paramName = AddParameter( property.GetValue( values ) );
+ valueList.Add( paramName );
+ }
+
+ foreach ( var field in type.GetFields() )
+ {
+ var paramName = AddParameter( field.GetValue( values ) );
+ valueList.Add( paramName );
+ }
+
+ _query.Append( " VALUES (" );
+ _query.Append( string.Join( ", ", valueList ) );
+ _query.Append( ')' );
+
+ return this;
+ }
+
+ ///
+ /// Adds an UPDATE clause to the query.
+ ///
+ /// The table to update.
+ /// This builder for method chaining.
+ public SqlBuilder Update( string table )
+ {
+ _query.Append( "UPDATE " );
+ _query.Append( table );
+ return this;
+ }
+
+ ///
+ /// Adds a SET clause with values from an object.
+ ///
+ /// An anonymous object containing the column-value pairs.
+ /// This builder for method chaining.
+ public SqlBuilder Set( object values )
+ {
+ var type = values.GetType();
+ var assignments = new List();
+
+ foreach ( var property in type.GetProperties() )
+ {
+ var paramName = AddParameter( property.GetValue( values ) );
+ assignments.Add( $"{property.Name} = {paramName}" );
+ }
+
+ foreach ( var field in type.GetFields() )
+ {
+ var paramName = AddParameter( field.GetValue( values ) );
+ assignments.Add( $"{field.Name} = {paramName}" );
+ }
+
+ _query.Append( " SET " );
+ _query.Append( string.Join( ", ", assignments ) );
+
+ return this;
+ }
+
+ ///
+ /// Adds a DELETE FROM clause to the query.
+ ///
+ /// The table to delete from.
+ /// This builder for method chaining.
+ public SqlBuilder DeleteFrom( string table )
+ {
+ _query.Append( "DELETE FROM " );
+ _query.Append( table );
+ return this;
+ }
+
+ ///
+ /// Adds raw SQL to the query. Use with caution - prefer parameterized methods.
+ ///
+ /// The raw SQL to append.
+ /// This builder for method chaining.
+ public SqlBuilder Raw( string sql )
+ {
+ _query.Append( sql );
+ return this;
+ }
+
+ ///
+ /// Builds and returns the final SQL query string.
+ ///
+ /// The constructed SQL query.
+ public string Build()
+ {
+ return _query.ToString();
+ }
+
+ ///
+ /// Returns the constructed SQL query string.
+ ///
+ public override string ToString() => Build();
+
+ private string AddParameter( object value )
+ {
+ var name = $"@p{_parameterIndex++}";
+ Parameters[name.TrimStart( '@' )] = value;
+ return name;
+ }
+
+ private string ProcessCondition( string condition, object parameters )
+ {
+ if ( parameters is null )
+ return condition;
+
+ var type = parameters.GetType();
+ var result = condition;
+
+ foreach ( var property in type.GetProperties() )
+ {
+ var newName = AddParameter( property.GetValue( parameters ) );
+ var pattern = $@"@{property.Name}(?![a-zA-Z0-9_])";
+ result = Regex.Replace( result, pattern, newName, RegexOptions.IgnoreCase );
+ }
+
+ foreach ( var field in type.GetFields() )
+ {
+ var newName = AddParameter( field.GetValue( parameters ) );
+ var pattern = $@"@{field.Name}(?![a-zA-Z0-9_])";
+ result = Regex.Replace( result, pattern, newName, RegexOptions.IgnoreCase );
+ }
+
+ return result;
+ }
+}
diff --git a/engine/Sandbox.Engine/Utility/Sql/SqlDatabase.cs b/engine/Sandbox.Engine/Utility/Sql/SqlDatabase.cs
new file mode 100644
index 000000000..86b573019
--- /dev/null
+++ b/engine/Sandbox.Engine/Utility/Sql/SqlDatabase.cs
@@ -0,0 +1,613 @@
+using Microsoft.Data.Sqlite;
+using System.IO;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Threading;
+
+namespace Sandbox.Utility;
+
+///
+/// Handles native SQLite library initialization for the engine.
+///
+internal static class SqliteNative
+{
+ private static bool s_initialized;
+ private static readonly Lock Lock = new();
+ private static IntPtr s_nativeHandle;
+
+ ///
+ /// Path to the native SQLite library relative to the managed assembly directory.
+ ///
+ private static string GetNativeLibraryPath()
+ {
+ var managedDir = Path.GetDirectoryName( typeof( SqliteNative ).Assembly.Location );
+ var runtimesPath = Path.Combine( managedDir, "runtimes" );
+
+ if ( RuntimeInformation.IsOSPlatform( OSPlatform.Windows ) )
+ {
+ var rid = RuntimeInformation.ProcessArchitecture switch
+ {
+ Architecture.X64 => "win-x64",
+ Architecture.X86 => "win-x86",
+ Architecture.Arm64 => "win-arm64",
+ _ => "win-x64"
+ };
+ return Path.Combine( runtimesPath, rid, "native", "e_sqlite3.dll" );
+ }
+
+ if ( RuntimeInformation.IsOSPlatform( OSPlatform.Linux ) )
+ {
+ var rid = RuntimeInformation.ProcessArchitecture switch
+ {
+ Architecture.X64 => "linux-x64",
+ Architecture.Arm64 => "linux-arm64",
+ _ => "linux-x64"
+ };
+ return Path.Combine( runtimesPath, rid, "native", "libe_sqlite3.so" );
+ }
+
+ if ( RuntimeInformation.IsOSPlatform( OSPlatform.OSX ) )
+ {
+ var rid = RuntimeInformation.ProcessArchitecture switch
+ {
+ Architecture.Arm64 => "osx-arm64",
+ Architecture.X64 => "osx-x64",
+ _ => "osx-x64"
+ };
+ return Path.Combine( runtimesPath, rid, "native", "libe_sqlite3.dylib" );
+ }
+
+ return null;
+ }
+
+ ///
+ /// Ensures the native SQLite library is loaded and SQLitePCL is initialized.
+ /// Must be called before any SQLite operations.
+ ///
+ internal static void Initialize()
+ {
+ if ( s_initialized )
+ return;
+
+ lock ( Lock )
+ {
+ if ( s_initialized )
+ return;
+
+ var nativeLibPath = GetNativeLibraryPath();
+ if ( string.IsNullOrEmpty( nativeLibPath ) || !File.Exists( nativeLibPath ) )
+ {
+ throw new DllNotFoundException( $"Could not find native SQLite library at: {nativeLibPath}" );
+ }
+
+ // Pre-load the native library so it's available when SQLitePCL needs it
+ if ( !NativeLibrary.TryLoad( nativeLibPath, out s_nativeHandle ) )
+ {
+ throw new DllNotFoundException( $"Failed to load native SQLite library from: {nativeLibPath}" );
+ }
+
+ AppDomain.CurrentDomain.AssemblyLoad += OnAssemblyLoad;
+
+ foreach ( var assembly in AppDomain.CurrentDomain.GetAssemblies() )
+ {
+ TrySetResolver( assembly );
+ }
+
+ InitializeSqlitePcl();
+
+ s_initialized = true;
+ }
+ }
+
+ private static void OnAssemblyLoad( object sender, AssemblyLoadEventArgs args )
+ {
+ TrySetResolver( args.LoadedAssembly );
+ }
+
+ private static void TrySetResolver( Assembly assembly )
+ {
+ var name = assembly.GetName().Name;
+ if ( name == null || !name.StartsWith( "SQLitePCL", StringComparison.OrdinalIgnoreCase ) )
+ return;
+
+ try
+ {
+ NativeLibrary.SetDllImportResolver( assembly, ResolveSqliteNative );
+ }
+ catch ( InvalidOperationException )
+ {
+ // Resolver already set
+ }
+ }
+
+ private static IntPtr ResolveSqliteNative( string libraryName, Assembly assembly, DllImportSearchPath? searchPath )
+ {
+ return libraryName == "e_sqlite3" && s_nativeHandle != IntPtr.Zero ? s_nativeHandle : IntPtr.Zero;
+ }
+
+ private static void InitializeSqlitePcl()
+ {
+ var providerType = Type.GetType( "SQLitePCL.SQLite3Provider_e_sqlite3, SQLitePCLRaw.provider.e_sqlite3" );
+ var rawType = Type.GetType( "SQLitePCL.raw, SQLitePCLRaw.core" );
+
+ if ( providerType == null || rawType == null )
+ {
+ throw new InvalidOperationException( "SQLitePCL assemblies not found. Did you check if Microsoft.Data.Sqlite is referenced?" );
+ }
+
+ var provider = Activator.CreateInstance( providerType );
+ var setProviderMethod = rawType.GetMethod( "SetProvider", BindingFlags.Public | BindingFlags.Static );
+ setProviderMethod?.Invoke( null, [provider] );
+ }
+
+ ///
+ /// Frees the native SQLite library. Called on shutdown.
+ ///
+ internal static void Shutdown()
+ {
+ AppDomain.CurrentDomain.AssemblyLoad -= OnAssemblyLoad;
+
+ if ( s_nativeHandle != IntPtr.Zero )
+ {
+ NativeLibrary.Free( s_nativeHandle );
+ s_nativeHandle = IntPtr.Zero;
+ }
+ s_initialized = false;
+ }
+}
+
+///
+/// Represents an SQLite database connection that can be used for custom database operations.
+/// For most use cases, prefer the static class which provides access to the default game database.
+///
+///
+///
+/// // Create a custom database
+/// using var db = new SqlDatabase( "path/to/my/database.db" );
+///
+/// // Create a table
+/// db.Query( "CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT )" );
+///
+/// // Insert data
+/// db.Query( "INSERT OR REPLACE INTO settings (key, value) VALUES (@k, @v)", new { k = "volume", v = "0.8" } );
+///
+/// // Query data
+/// var volume = db.QueryValue<string>( "SELECT value FROM settings WHERE key = @k", new { k = "volume" } );
+///
+///
+public sealed class SqlDatabase : IDisposable
+{
+ private SqliteConnection _connection;
+ private readonly string _connectionString;
+ private readonly Lock _lock = new();
+ private bool _disposed;
+
+ ///
+ /// Gets the file path to the database, or ":memory:" for an in-memory database.
+ ///
+ public string DatabasePath { get; }
+
+ ///
+ /// Creates a new SQLite database connection.
+ ///
+ ///
+ /// The file path to the database file. The file will be created if it doesn't exist.
+ /// Use ":memory:" for an in-memory database.
+ ///
+ /// Thrown if path is null.
+ public SqlDatabase( string path )
+ {
+ ArgumentNullException.ThrowIfNull( path );
+
+ SqliteNative.Initialize();
+
+ DatabasePath = path;
+
+ // Ensure directory exists for file-based databases
+ if ( path != ":memory:" )
+ {
+ var directory = Path.GetDirectoryName( path );
+ if ( !string.IsNullOrEmpty( directory ) )
+ {
+ Directory.CreateDirectory( directory );
+ }
+ }
+
+ _connectionString = new SqliteConnectionStringBuilder
+ {
+ DataSource = path,
+ Mode = path == ":memory:" ? SqliteOpenMode.Memory : SqliteOpenMode.ReadWriteCreate,
+ Cache = path == ":memory:" ? SqliteCacheMode.Private : SqliteCacheMode.Shared
+ }.ToString();
+
+ _connection = new SqliteConnection( _connectionString );
+ _connection.Open();
+
+ // Enable foreign keys
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "PRAGMA foreign_keys = ON;";
+ cmd.ExecuteNonQuery();
+ }
+
+ ///
+ /// Creates an in-memory SQLite database.
+ ///
+ /// A new in-memory SqlDatabase instance.
+ public static SqlDatabase CreateInMemory()
+ {
+ return new SqlDatabase( ":memory:" );
+ }
+
+ ///
+ /// Executes a SQL query and returns all results.
+ ///
+ /// The SQL query to execute.
+ /// Optional anonymous object containing parameter values.
+ /// A list of dictionaries where each dictionary represents a row.
+ public List> Query( string query, object parameters = null )
+ {
+ ThrowIfDisposed();
+
+ lock ( _lock )
+ {
+ using var cmd = CreateCommand( query, parameters );
+ using var reader = cmd.ExecuteReader();
+
+ var results = new List>();
+
+ while ( reader.Read() )
+ {
+ var row = new Dictionary( StringComparer.OrdinalIgnoreCase );
+
+ for ( int i = 0; i < reader.FieldCount; i++ )
+ {
+ var name = reader.GetName( i );
+ var value = reader.IsDBNull( i ) ? null : reader.GetValue( i );
+ row[name] = value;
+ }
+
+ results.Add( row );
+ }
+
+ return results;
+ }
+ }
+
+ ///
+ /// Executes a SQL query asynchronously and returns all results.
+ ///
+ /// The SQL query to execute.
+ /// Optional anonymous object containing parameter values.
+ /// A task containing a list of dictionaries where each dictionary represents a row.
+ public async Task>> QueryAsync( string query, object parameters = null )
+ {
+ ThrowIfDisposed();
+
+ // Note: We acquire the lock synchronously to avoid deadlocks.
+ // The actual database operation is async.
+ lock ( _lock )
+ {
+ // Create command inside lock to ensure thread safety
+ var cmd = CreateCommand( query, parameters );
+ return QueryAsyncInternal( cmd ).GetAwaiter().GetResult();
+ }
+ }
+
+ private async Task>> QueryAsyncInternal( SqliteCommand cmd )
+ {
+ using ( cmd )
+ using ( var reader = await cmd.ExecuteReaderAsync() )
+ {
+ var results = new List>();
+
+ while ( await reader.ReadAsync() )
+ {
+ var row = new Dictionary( StringComparer.OrdinalIgnoreCase );
+
+ for ( int i = 0; i < reader.FieldCount; i++ )
+ {
+ var name = reader.GetName( i );
+ var value = reader.IsDBNull( i ) ? null : reader.GetValue( i );
+ row[name] = value;
+ }
+
+ results.Add( row );
+ }
+
+ return results;
+ }
+ }
+
+ ///
+ /// Executes a SQL query and returns a single row.
+ ///
+ /// The SQL query to execute.
+ /// The row index to return (0-based).
+ /// Optional anonymous object containing parameter values.
+ /// A dictionary representing the row, or null if not found.
+ public Dictionary QueryRow( string query, int row = 0, object parameters = null )
+ {
+ var results = Query( query, parameters );
+ return results.Count <= row ? null : results[row];
+ }
+
+ ///
+ /// Executes a SQL query and returns a single value.
+ ///
+ /// The SQL query to execute.
+ /// Optional anonymous object containing parameter values.
+ /// The value of the first column of the first row, or null if no rows or the value is NULL.
+ public object QueryValue( string query, object parameters = null )
+ {
+ ThrowIfDisposed();
+
+ lock ( _lock )
+ {
+ using var cmd = CreateCommand( query, parameters );
+ var result = cmd.ExecuteScalar();
+ return result is DBNull ? null : result;
+ }
+ }
+
+ ///
+ /// Executes a SQL query and returns a single value cast to the specified type.
+ ///
+ /// The type to cast the result to.
+ /// The SQL query to execute.
+ /// Optional anonymous object containing parameter values.
+ /// The value cast to type T, or default(T).
+ public T QueryValue( string query, object parameters = null )
+ {
+ var value = QueryValue( query, parameters );
+
+ if ( value is null || value is DBNull )
+ return default;
+
+ try
+ {
+ return (T)Convert.ChangeType( value, typeof( T ) );
+ }
+ catch ( Exception ex )
+ {
+ Log.Warning( $"SQL: Failed to convert '{value}' to {typeof( T ).Name}: {ex.Message}" );
+ return default;
+ }
+ }
+
+ ///
+ /// Executes a non-query SQL command (INSERT, UPDATE, DELETE, etc.).
+ ///
+ /// The SQL command to execute.
+ /// Optional anonymous object containing parameter values.
+ /// The number of rows affected.
+ public int Execute( string query, object parameters = null )
+ {
+ ThrowIfDisposed();
+
+ lock ( _lock )
+ {
+ using var cmd = CreateCommand( query, parameters );
+ return cmd.ExecuteNonQuery();
+ }
+ }
+
+ ///
+ /// Checks if a table exists in the database.
+ ///
+ /// The name of the table.
+ /// True if the table exists.
+ public bool TableExists( string tableName )
+ {
+ var result = QueryValue(
+ "SELECT COUNT(*) FROM sqlite_master WHERE name = @name AND type = 'table'",
+ new { name = tableName } );
+
+ return result > 0;
+ }
+
+ ///
+ /// Checks if an index exists in the database.
+ ///
+ /// The name of the index.
+ /// True if the index exists.
+ public bool IndexExists( string indexName )
+ {
+ var result = QueryValue(
+ "SELECT COUNT(*) FROM sqlite_master WHERE name = @name AND type = 'index'",
+ new { name = indexName } );
+
+ return result > 0;
+ }
+
+ ///
+ /// Gets the row ID of the last inserted row.
+ ///
+ /// The last insert row ID.
+ public long LastInsertRowId()
+ {
+ return QueryValue( "SELECT last_insert_rowid()" );
+ }
+
+ ///
+ /// Gets the number of rows affected by the last data modification.
+ ///
+ /// The number of rows changed.
+ public int RowsAffected()
+ {
+ return QueryValue( "SELECT changes()" );
+ }
+
+ ///
+ /// Begins a transaction.
+ ///
+ /// A transaction object that should be used with using statement.
+ public SqlTransaction BeginTransaction()
+ {
+ ThrowIfDisposed();
+ return new SqlTransaction( this );
+ }
+
+ ///
+ /// Executes an action within a transaction. The transaction is automatically
+ /// committed if the action succeeds, or rolled back if an exception is thrown.
+ ///
+ /// The action to execute within the transaction.
+ public void InTransaction( Action action )
+ {
+ using var transaction = BeginTransaction();
+ action();
+ transaction.Commit();
+ }
+
+ ///
+ /// Executes a function within a transaction and returns its result.
+ ///
+ /// The return type.
+ /// The function to execute within the transaction.
+ /// The result of the function.
+ public T InTransaction( Func func )
+ {
+ using var transaction = BeginTransaction();
+ var result = func();
+ transaction.Commit();
+ return result;
+ }
+
+ private SqliteCommand CreateCommand( string query, object parameters )
+ {
+ var cmd = _connection.CreateCommand();
+ cmd.CommandText = query;
+
+ if ( parameters is not null )
+ {
+ AddParameters( cmd, parameters );
+ }
+
+ return cmd;
+ }
+
+ private static void AddParameters( SqliteCommand cmd, object parameters )
+ {
+ if ( parameters is IDictionary dict )
+ {
+ foreach ( var kvp in dict )
+ {
+ var paramName = kvp.Key.StartsWith( '@' ) ? kvp.Key : $"@{kvp.Key}";
+ cmd.Parameters.AddWithValue( paramName, kvp.Value ?? DBNull.Value );
+ }
+ return;
+ }
+
+ var type = parameters.GetType();
+
+ foreach ( var property in type.GetProperties() )
+ {
+ var value = property.GetValue( parameters );
+ var paramName = $"@{property.Name}";
+ cmd.Parameters.AddWithValue( paramName, value ?? DBNull.Value );
+ }
+
+ foreach ( var field in type.GetFields() )
+ {
+ var value = field.GetValue( parameters );
+ var paramName = $"@{field.Name}";
+ cmd.Parameters.AddWithValue( paramName, value ?? DBNull.Value );
+ }
+ }
+
+ private void ThrowIfDisposed()
+ {
+ ObjectDisposedException.ThrowIf( _disposed, this );
+ }
+
+ ///
+ /// Closes the database connection and releases all resources.
+ ///
+ public void Dispose()
+ {
+ if ( _disposed )
+ return;
+
+ _disposed = true;
+
+ lock ( _lock )
+ {
+ _connection?.Close();
+ _connection?.Dispose();
+ _connection = null;
+ }
+ }
+
+ internal void ExecuteRaw( string query )
+ {
+ lock ( _lock )
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = query;
+ cmd.ExecuteNonQuery();
+ }
+ }
+}
+
+///
+/// Represents a database transaction that can be committed or rolled back.
+///
+public sealed class SqlTransaction : IDisposable
+{
+ private readonly SqlDatabase _database;
+ private bool _completed;
+ private bool _disposed;
+
+ internal SqlTransaction( SqlDatabase database )
+ {
+ _database = database;
+ _database.ExecuteRaw( "BEGIN TRANSACTION" );
+ }
+
+ ///
+ /// Commits all changes made during the transaction.
+ ///
+ public void Commit()
+ {
+ if ( _completed )
+ return;
+
+ _database.ExecuteRaw( "COMMIT" );
+ _completed = true;
+ }
+
+ ///
+ /// Discards all changes made during the transaction.
+ ///
+ public void Rollback()
+ {
+ if ( _completed )
+ return;
+
+ _database.ExecuteRaw( "ROLLBACK" );
+ _completed = true;
+ }
+
+ ///
+ /// Disposes the transaction, rolling back if not already committed.
+ ///
+ public void Dispose()
+ {
+ if ( _disposed )
+ return;
+
+ _disposed = true;
+
+ if ( !_completed )
+ {
+ try
+ {
+ Rollback();
+ }
+ catch ( Exception ex )
+ {
+ Log.Warning( $"SQL: Transaction rollback failed during dispose: {ex.Message}" );
+ }
+ }
+ }
+}
diff --git a/engine/Sandbox.Test.Unit/Sql/SqlTest.cs b/engine/Sandbox.Test.Unit/Sql/SqlTest.cs
new file mode 100644
index 000000000..6822d126b
--- /dev/null
+++ b/engine/Sandbox.Test.Unit/Sql/SqlTest.cs
@@ -0,0 +1,499 @@
+using Sandbox.Utility;
+
+namespace Sql;
+
+[TestClass]
+public class SqlTest
+{
+ [TestMethod]
+ public void CreateInMemoryDatabase()
+ {
+ using var db = SqlDatabase.CreateInMemory();
+ Assert.IsNotNull( db );
+ Assert.AreEqual( ":memory:", db.DatabasePath );
+ }
+
+ [TestMethod]
+ public void CreateTable()
+ {
+ using var db = SqlDatabase.CreateInMemory();
+
+ db.Execute( "CREATE TABLE test ( id INTEGER PRIMARY KEY, name TEXT )" );
+
+ Assert.IsTrue( db.TableExists( "test" ) );
+ Assert.IsFalse( db.TableExists( "nonexistent" ) );
+ }
+
+ [TestMethod]
+ public void InsertAndQuery()
+ {
+ using var db = SqlDatabase.CreateInMemory();
+
+ db.Execute( "CREATE TABLE users ( id INTEGER PRIMARY KEY, name TEXT, score INTEGER )" );
+ db.Execute( "INSERT INTO users (name, score) VALUES (@name, @score)", new { name = "Alice", score = 100 } );
+ db.Execute( "INSERT INTO users (name, score) VALUES (@name, @score)", new { name = "Bob", score = 200 } );
+
+ var results = db.Query( "SELECT * FROM users ORDER BY id" );
+
+ Assert.AreEqual( 2, results.Count );
+ Assert.AreEqual( "Alice", results[0]["name"] );
+ Assert.AreEqual( 100L, results[0]["score"] );
+ Assert.AreEqual( "Bob", results[1]["name"] );
+ Assert.AreEqual( 200L, results[1]["score"] );
+ }
+
+ [TestMethod]
+ public void QueryRow()
+ {
+ using var db = SqlDatabase.CreateInMemory();
+
+ db.Execute( "CREATE TABLE users ( id INTEGER PRIMARY KEY, name TEXT )" );
+ db.Execute( "INSERT INTO users (name) VALUES ('Alice')" );
+ db.Execute( "INSERT INTO users (name) VALUES ('Bob')" );
+
+ var firstRow = db.QueryRow( "SELECT * FROM users ORDER BY id" );
+ Assert.AreEqual( "Alice", firstRow["name"] );
+
+ var secondRow = db.QueryRow( "SELECT * FROM users ORDER BY id", row: 1 );
+ Assert.AreEqual( "Bob", secondRow["name"] );
+
+ var noRow = db.QueryRow( "SELECT * FROM users WHERE id = 999" );
+ Assert.IsNull( noRow );
+ }
+
+ [TestMethod]
+ public void QueryValue()
+ {
+ using var db = SqlDatabase.CreateInMemory();
+
+ db.Execute( "CREATE TABLE users ( id INTEGER PRIMARY KEY, name TEXT )" );
+ db.Execute( "INSERT INTO users (name) VALUES ('Alice')" );
+ db.Execute( "INSERT INTO users (name) VALUES ('Bob')" );
+
+ var count = db.QueryValue( "SELECT COUNT(*) FROM users" );
+ Assert.AreEqual( 2L, count );
+
+ var name = db.QueryValue( "SELECT name FROM users WHERE id = 1" );
+ Assert.AreEqual( "Alice", name );
+ }
+
+ [TestMethod]
+ public void ParameterizedQuery()
+ {
+ using var db = SqlDatabase.CreateInMemory();
+
+ db.Execute( "CREATE TABLE users ( id INTEGER PRIMARY KEY, name TEXT )" );
+ db.Execute( "INSERT INTO users (name) VALUES (@name)", new { name = "Test User" } );
+
+ var results = db.Query( "SELECT * FROM users WHERE name = @name", new { name = "Test User" } );
+
+ Assert.AreEqual( 1, results.Count );
+ Assert.AreEqual( "Test User", results[0]["name"] );
+ }
+
+ [TestMethod]
+ public void LastInsertRowId()
+ {
+ using var db = SqlDatabase.CreateInMemory();
+
+ db.Execute( "CREATE TABLE users ( id INTEGER PRIMARY KEY, name TEXT )" );
+ db.Execute( "INSERT INTO users (name) VALUES ('Alice')" );
+
+ var rowId = db.LastInsertRowId();
+ Assert.AreEqual( 1L, rowId );
+
+ db.Execute( "INSERT INTO users (name) VALUES ('Bob')" );
+ rowId = db.LastInsertRowId();
+ Assert.AreEqual( 2L, rowId );
+ }
+
+ [TestMethod]
+ public void RowsAffected()
+ {
+ using var db = SqlDatabase.CreateInMemory();
+
+ db.Execute( "CREATE TABLE users ( id INTEGER PRIMARY KEY, name TEXT )" );
+ db.Execute( "INSERT INTO users (name) VALUES ('Alice')" );
+ db.Execute( "INSERT INTO users (name) VALUES ('Bob')" );
+ db.Execute( "INSERT INTO users (name) VALUES ('Charlie')" );
+
+ db.Execute( "DELETE FROM users WHERE id > 1" );
+ var affected = db.RowsAffected();
+
+ Assert.AreEqual( 2, affected );
+ }
+
+ [TestMethod]
+ public void Transaction()
+ {
+ using var db = SqlDatabase.CreateInMemory();
+
+ db.Execute( "CREATE TABLE users ( id INTEGER PRIMARY KEY, name TEXT )" );
+
+ using ( var transaction = db.BeginTransaction() )
+ {
+ db.Execute( "INSERT INTO users (name) VALUES ('Alice')" );
+ db.Execute( "INSERT INTO users (name) VALUES ('Bob')" );
+ transaction.Commit();
+ }
+
+ var count = db.QueryValue( "SELECT COUNT(*) FROM users" );
+ Assert.AreEqual( 2L, count );
+ }
+
+ [TestMethod]
+ public void TransactionRollback()
+ {
+ using var db = SqlDatabase.CreateInMemory();
+
+ db.Execute( "CREATE TABLE users ( id INTEGER PRIMARY KEY, name TEXT )" );
+
+ using ( var transaction = db.BeginTransaction() )
+ {
+ db.Execute( "INSERT INTO users (name) VALUES ('Alice')" );
+ db.Execute( "INSERT INTO users (name) VALUES ('Bob')" );
+ transaction.Rollback();
+ }
+
+ var count = db.QueryValue( "SELECT COUNT(*) FROM users" );
+ Assert.AreEqual( 0L, count );
+ }
+
+ [TestMethod]
+ public void TransactionAutoRollbackOnDispose()
+ {
+ using var db = SqlDatabase.CreateInMemory();
+
+ db.Execute( "CREATE TABLE users ( id INTEGER PRIMARY KEY, name TEXT )" );
+
+ using ( db.BeginTransaction() )
+ {
+ db.Execute( "INSERT INTO users (name) VALUES ('Alice')" );
+ // No commit, let it dispose
+ }
+
+ var count = db.QueryValue( "SELECT COUNT(*) FROM users" );
+ Assert.AreEqual( 0L, count );
+ }
+
+ [TestMethod]
+ public void InTransactionHelper()
+ {
+ using var db = SqlDatabase.CreateInMemory();
+
+ db.Execute( "CREATE TABLE users ( id INTEGER PRIMARY KEY, name TEXT )" );
+
+ db.InTransaction( () =>
+ {
+ db.Execute( "INSERT INTO users (name) VALUES ('Alice')" );
+ db.Execute( "INSERT INTO users (name) VALUES ('Bob')" );
+ } );
+
+ var count = db.QueryValue( "SELECT COUNT(*) FROM users" );
+ Assert.AreEqual( 2L, count );
+ }
+
+ [TestMethod]
+ public void IndexExists()
+ {
+ using var db = SqlDatabase.CreateInMemory();
+
+ db.Execute( "CREATE TABLE users ( id INTEGER PRIMARY KEY, name TEXT )" );
+ db.Execute( "CREATE INDEX idx_users_name ON users (name)" );
+
+ Assert.IsTrue( db.IndexExists( "idx_users_name" ) );
+ Assert.IsFalse( db.IndexExists( "nonexistent_index" ) );
+ }
+
+ [TestMethod]
+ public void NullValues()
+ {
+ using var db = SqlDatabase.CreateInMemory();
+
+ db.Execute( "CREATE TABLE users ( id INTEGER PRIMARY KEY, name TEXT, bio TEXT )" );
+ db.Execute( "INSERT INTO users (name, bio) VALUES (@name, @bio)", new { name = "Alice", bio = (string)null } );
+
+ var row = db.QueryRow( "SELECT * FROM users WHERE id = 1" );
+
+ Assert.AreEqual( "Alice", row["name"] );
+ Assert.IsNull( row["bio"] );
+ }
+
+ [TestMethod]
+ public void CaseInsensitiveColumnNames()
+ {
+ using var db = SqlDatabase.CreateInMemory();
+
+ db.Execute( "CREATE TABLE users ( ID INTEGER PRIMARY KEY, Name TEXT )" );
+ db.Execute( "INSERT INTO users (Name) VALUES ('Alice')" );
+
+ var row = db.QueryRow( "SELECT * FROM users" );
+
+ // Should work regardless of case
+ Assert.AreEqual( "Alice", row["name"] );
+ Assert.AreEqual( "Alice", row["NAME"] );
+ Assert.AreEqual( "Alice", row["Name"] );
+ }
+
+ [TestMethod]
+ public async Task QueryAsync()
+ {
+ using var db = SqlDatabase.CreateInMemory();
+
+ db.Execute( "CREATE TABLE users ( id INTEGER PRIMARY KEY, name TEXT )" );
+ db.Execute( "INSERT INTO users (name) VALUES ('Alice')" );
+ db.Execute( "INSERT INTO users (name) VALUES ('Bob')" );
+
+ var results = await db.QueryAsync( "SELECT * FROM users ORDER BY id" );
+
+ Assert.AreEqual( 2, results.Count );
+ Assert.AreEqual( "Alice", results[0]["name"] );
+ Assert.AreEqual( "Bob", results[1]["name"] );
+ }
+
+ [TestMethod]
+ public void ErrorHandling_InvalidSql()
+ {
+ using var db = SqlDatabase.CreateInMemory();
+
+ Assert.ThrowsException( () =>
+ {
+ db.Query( "INVALID SQL SYNTAX" );
+ } );
+ }
+
+ [TestMethod]
+ public void ErrorHandling_NonExistentTable()
+ {
+ using var db = SqlDatabase.CreateInMemory();
+
+ Assert.ThrowsException( () =>
+ {
+ db.Query( "SELECT * FROM nonexistent_table" );
+ } );
+ }
+
+ [TestMethod]
+ public void ErrorHandling_ConstraintViolation()
+ {
+ using var db = SqlDatabase.CreateInMemory();
+
+ db.Execute( "CREATE TABLE users ( id INTEGER PRIMARY KEY, name TEXT UNIQUE )" );
+ db.Execute( "INSERT INTO users (name) VALUES ('Alice')" );
+
+ Assert.ThrowsException( () =>
+ {
+ db.Execute( "INSERT INTO users (name) VALUES ('Alice')" ); // Duplicate
+ } );
+ }
+}
+
+[TestClass]
+public class SqlBuilderTest
+{
+ [TestMethod]
+ public void SelectAll()
+ {
+ var query = new SqlBuilder()
+ .Select()
+ .From( "users" )
+ .Build();
+
+ Assert.AreEqual( "SELECT * FROM users", query );
+ }
+
+ [TestMethod]
+ public void SelectColumns()
+ {
+ var query = new SqlBuilder()
+ .Select( "id", "name", "email" )
+ .From( "users" )
+ .Build();
+
+ Assert.AreEqual( "SELECT id, name, email FROM users", query );
+ }
+
+ [TestMethod]
+ public void SelectWithWhere()
+ {
+ var builder = new SqlBuilder()
+ .Select()
+ .From( "users" )
+ .Where( "id = @id", new { id = 5 } );
+
+ var query = builder.Build();
+
+ Assert.AreEqual( "SELECT * FROM users WHERE id = @p0", query );
+ Assert.AreEqual( 5, builder.Parameters["p0"] );
+ }
+
+ [TestMethod]
+ public void SelectWithMultipleConditions()
+ {
+ var builder = new SqlBuilder()
+ .Select()
+ .From( "users" )
+ .Where( "score > @min", new { min = 100 } )
+ .And( "score < @max", new { max = 500 } );
+
+ var query = builder.Build();
+
+ Assert.AreEqual( "SELECT * FROM users WHERE score > @p0 AND score < @p1", query );
+ Assert.AreEqual( 100, builder.Parameters["p0"] );
+ Assert.AreEqual( 500, builder.Parameters["p1"] );
+ }
+
+ [TestMethod]
+ public void SelectWithOrderAndLimit()
+ {
+ var query = new SqlBuilder()
+ .Select()
+ .From( "users" )
+ .OrderBy( "score DESC" )
+ .Limit( 10 )
+ .Offset( 5 )
+ .Build();
+
+ Assert.AreEqual( "SELECT * FROM users ORDER BY score DESC LIMIT 10 OFFSET 5", query );
+ }
+
+ [TestMethod]
+ public void InsertWithValues()
+ {
+ var builder = new SqlBuilder()
+ .InsertInto( "users", "name", "score" )
+ .Values( new { name = "Alice", score = 100 } );
+
+ var query = builder.Build();
+
+ Assert.AreEqual( "INSERT INTO users (name, score) VALUES (@p0, @p1)", query );
+ Assert.AreEqual( "Alice", builder.Parameters["p0"] );
+ Assert.AreEqual( 100, builder.Parameters["p1"] );
+ }
+
+ [TestMethod]
+ public void Update()
+ {
+ var builder = new SqlBuilder()
+ .Update( "users" )
+ .Set( new { name = "Bob", score = 200 } )
+ .Where( "id = @id", new { id = 1 } );
+
+ var query = builder.Build();
+
+ Assert.AreEqual( "UPDATE users SET name = @p0, score = @p1 WHERE id = @p2", query );
+ }
+
+ [TestMethod]
+ public void Delete()
+ {
+ var builder = new SqlBuilder()
+ .DeleteFrom( "users" )
+ .Where( "id = @id", new { id = 1 } );
+
+ var query = builder.Build();
+
+ Assert.AreEqual( "DELETE FROM users WHERE id = @p0", query );
+ }
+
+ [TestMethod]
+ public void Join()
+ {
+ var query = new SqlBuilder()
+ .Select( "u.name", "p.title" )
+ .From( "users u" )
+ .Join( "posts p", "p.user_id = u.id" )
+ .Build();
+
+ Assert.AreEqual( "SELECT u.name, p.title FROM users u JOIN posts p ON p.user_id = u.id", query );
+ }
+
+ [TestMethod]
+ public void GroupByHaving()
+ {
+ var builder = new SqlBuilder()
+ .Select( "user_id", "COUNT(*) as post_count" )
+ .From( "posts" )
+ .GroupBy( "user_id" )
+ .Having( "COUNT(*) > @min", new { min = 5 } );
+
+ var query = builder.Build();
+
+ Assert.AreEqual( "SELECT user_id, COUNT(*) as post_count FROM posts GROUP BY user_id HAVING COUNT(*) > @p0", query );
+ }
+
+ [TestMethod]
+ public void ExecuteWithDatabase()
+ {
+ using var db = SqlDatabase.CreateInMemory();
+
+ db.Execute( "CREATE TABLE users ( id INTEGER PRIMARY KEY, name TEXT, score INTEGER )" );
+
+ // Insert with builder
+ var insertBuilder = new SqlBuilder()
+ .InsertInto( "users", "name", "score" )
+ .Values( new { name = "Alice", score = 100 } );
+
+ db.Execute( insertBuilder.Build(), insertBuilder.Parameters );
+
+ // Query with builder
+ var selectBuilder = new SqlBuilder()
+ .Select()
+ .From( "users" )
+ .Where( "score >= @min", new { min = 50 } );
+
+ var results = db.Query( selectBuilder.Build(), selectBuilder.Parameters );
+
+ Assert.AreEqual( 1, results.Count );
+ Assert.AreEqual( "Alice", results[0]["name"] );
+ }
+}
+
+[TestClass]
+public class SqlEscapeTest
+{
+ [TestMethod]
+ public void EscapeSimpleString()
+ {
+ var result = Sandbox.Utility.Sql.Escape( "hello" );
+ Assert.AreEqual( "'hello'", result );
+ }
+
+ [TestMethod]
+ public void EscapeStringWithQuotes()
+ {
+ var result = Sandbox.Utility.Sql.Escape( "it's a test" );
+ Assert.AreEqual( "'it''s a test'", result );
+ }
+
+ [TestMethod]
+ public void EscapeStringWithMultipleQuotes()
+ {
+ var result = Sandbox.Utility.Sql.Escape( "it's John's test" );
+ Assert.AreEqual( "'it''s John''s test'", result );
+ }
+
+ [TestMethod]
+ public void EscapeStringWithoutQuotes()
+ {
+ var result = Sandbox.Utility.Sql.Escape( "hello", includeQuotes: false );
+ Assert.AreEqual( "hello", result );
+ }
+
+ [TestMethod]
+ public void EscapeNullString()
+ {
+ var result = Sandbox.Utility.Sql.Escape( null );
+ Assert.AreEqual( "''", result );
+
+ var resultNoQuotes = Sandbox.Utility.Sql.Escape( null, includeQuotes: false );
+ Assert.AreEqual( "", resultNoQuotes );
+ }
+
+ [TestMethod]
+ public void EscapeStringWithNullCharacter()
+ {
+ var result = Sandbox.Utility.Sql.Escape( "hello\0world" );
+ Assert.AreEqual( "'hello'", result );
+ }
+}