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 ); + } +}