diff --git a/Dapper.PartialUpdate.csproj b/Dapper.PartialUpdate.csproj index 721b3e7..3423cef 100644 --- a/Dapper.PartialUpdate.csproj +++ b/Dapper.PartialUpdate.csproj @@ -24,4 +24,9 @@ + + + + + diff --git a/DapperPartialExtensions.cs b/DapperPartialExtensions.cs index 0072b16..ece3725 100644 --- a/DapperPartialExtensions.cs +++ b/DapperPartialExtensions.cs @@ -7,6 +7,35 @@ namespace Dapper.PartialUpdate; +/// +/// Specifies the database type for identifier quoting. +/// +public enum DatabaseType +{ + /// + /// SQL Server uses square brackets: [identifier] + /// + SqlServer, + + /// + /// SQLite, PostgreSQL, and MySQL use double quotes: "identifier" + /// + Standard, + + /// + /// MySQL also supports backticks: `identifier` + /// + MySql +} + +/// +/// Provides extension methods for partial update and insert operations using Dapper. +/// +/// +/// These extensions enable patch-style database operations where only explicitly set fields +/// (wrapped in ) are included in the generated SQL. This is useful +/// for scenarios where you need to update only specific fields without affecting others. +/// public static class DapperPartialExtensions { private sealed record PartialProp( @@ -20,14 +49,35 @@ private sealed record EntityPlan( string QualifiedTableName, PropertyInfo KeyProp, string KeyColumnName, - IReadOnlyList PartialProps + IReadOnlyList PartialProps, + DatabaseType DatabaseType ); private static readonly ConcurrentDictionary PlanCache = new(); + /// + /// Updates only the fields of an entity that have been explicitly set using . + /// + /// The type of the entity to update. Must have a key property. + /// The database connection to use. + /// The entity with partially set fields. Cannot be null. + /// Optional database transaction. Defaults to null. + /// Optional command timeout in seconds. Defaults to null. + /// The number of rows affected. + /// Thrown when or is null. + /// Thrown when no key property is found or key value is null. + /// Thrown when no fields are set on the entity. + /// + /// + /// var user = new User { Id = 1 }; + /// user.Name = "John"; // Only Name will be updated + /// connection.UpdatePartials(user); + /// + /// public static int UpdatePartials( this IDbConnection connection, T entity, + DatabaseType databaseType = DatabaseType.SqlServer, IDbTransaction? transaction = null, int? commandTimeout = null) where T : class @@ -35,9 +85,9 @@ public static int UpdatePartials( if (connection is null) throw new ArgumentNullException(nameof(connection)); if (entity is null) throw new ArgumentNullException(nameof(entity)); - var plan = PlanCache.GetOrAdd(typeof(T), BuildPlan); + var plan = PlanCache.GetOrAdd(typeof(T), type => BuildPlan(type, databaseType)); var keyValue = plan.KeyProp.GetValue(entity) - ?? throw new InvalidOperationException("Key value cannot be null."); + ?? throw new InvalidOperationException($"Key value for '{plan.KeyProp.Name}' cannot be null on entity of type '{typeof(T).Name}'."); var setClauses = new List(); var parameters = new DynamicParameters(); @@ -50,20 +100,32 @@ public static int UpdatePartials( var paramName = $"p_{partial.Name}"; var columnName = GetColumnName(partial.Prop) ?? partial.Name; - setClauses.Add($"{QuoteIdentifier(columnName)} = @{paramName}"); + setClauses.Add($"{QuoteIdentifier(columnName, plan.DatabaseType)} = @{paramName}"); parameters.Add(paramName, value); } if (setClauses.Count == 0) return 0; - var sql = $"UPDATE {plan.QualifiedTableName} SET {string.Join(", ", setClauses)} WHERE {QuoteIdentifier(plan.KeyColumnName)} = @__key;"; + var sql = $"UPDATE {plan.QualifiedTableName} SET {string.Join(", ", setClauses)} WHERE {QuoteIdentifier(plan.KeyColumnName, plan.DatabaseType)} = @__key;"; return connection.Execute(sql, parameters, transaction, commandTimeout); } + /// + /// Asynchronously updates only the fields of an entity that have been explicitly set using . + /// + /// The type of the entity to update. Must have a key property. + /// The database connection to use. + /// The entity with partially set fields. Cannot be null. + /// Optional database transaction. Defaults to null. + /// Optional command timeout in seconds. Defaults to null. + /// A task representing the asynchronous operation. The result is the number of rows affected. + /// Thrown when or is null. + /// Thrown when no key property is found or key value is null. public static Task UpdatePartialsAsync( this IDbConnection connection, T entity, + DatabaseType databaseType = DatabaseType.SqlServer, IDbTransaction? transaction = null, int? commandTimeout = null) where T : class @@ -71,9 +133,9 @@ public static Task UpdatePartialsAsync( if (connection is null) throw new ArgumentNullException(nameof(connection)); if (entity is null) throw new ArgumentNullException(nameof(entity)); - var plan = PlanCache.GetOrAdd(typeof(T), BuildPlan); + var plan = PlanCache.GetOrAdd(typeof(T), type => BuildPlan(type, databaseType)); var keyValue = plan.KeyProp.GetValue(entity) - ?? throw new InvalidOperationException("Key value cannot be null."); + ?? throw new InvalidOperationException($"Key value for '{plan.KeyProp.Name}' cannot be null on entity of type '{typeof(T).Name}'."); var setClauses = new List(); var parameters = new DynamicParameters(); @@ -86,20 +148,33 @@ public static Task UpdatePartialsAsync( var paramName = $"p_{partial.Name}"; var columnName = GetColumnName(partial.Prop) ?? partial.Name; - setClauses.Add($"{QuoteIdentifier(columnName)} = @{paramName}"); + setClauses.Add($"{QuoteIdentifier(columnName, plan.DatabaseType)} = @{paramName}"); parameters.Add(paramName, value); } if (setClauses.Count == 0) return Task.FromResult(0); - var sql = $"UPDATE {plan.QualifiedTableName} SET {string.Join(", ", setClauses)} WHERE {QuoteIdentifier(plan.KeyColumnName)} = @__key;"; + var sql = $"UPDATE {plan.QualifiedTableName} SET {string.Join(", ", setClauses)} WHERE {QuoteIdentifier(plan.KeyColumnName, plan.DatabaseType)} = @__key;"; return connection.ExecuteAsync(sql, parameters, transaction, commandTimeout); } + /// + /// Inserts only the fields of an entity that have been explicitly set using . + /// If no fields are set, executes an INSERT with DEFAULT VALUES. + /// + /// The type of the entity to insert. + /// The database connection to use. + /// The entity with partially set fields. Cannot be null. + /// Optional database transaction. Defaults to null. + /// Optional command timeout in seconds. Defaults to null. + /// The number of rows affected. + /// Thrown when or is null. + /// Thrown when no key property is found. public static int InsertPartials( this IDbConnection connection, T entity, + DatabaseType databaseType = DatabaseType.SqlServer, IDbTransaction? transaction = null, int? commandTimeout = null) where T : class @@ -107,7 +182,7 @@ public static int InsertPartials( if (connection is null) throw new ArgumentNullException(nameof(connection)); if (entity is null) throw new ArgumentNullException(nameof(entity)); - var plan = PlanCache.GetOrAdd(typeof(T), BuildPlan); + var plan = PlanCache.GetOrAdd(typeof(T), type => BuildPlan(type, databaseType)); var parameters = new DynamicParameters(); var columns = new List(); var valueParams = new List(); @@ -119,7 +194,7 @@ public static int InsertPartials( var paramName = $"p_{partial.Name}"; var columnName = GetColumnName(partial.Prop) ?? partial.Name; - columns.Add(QuoteIdentifier(columnName)); + columns.Add(QuoteIdentifier(columnName, plan.DatabaseType)); valueParams.Add($"@{paramName}"); parameters.Add(paramName, value); } @@ -134,12 +209,74 @@ public static int InsertPartials( sql = $"INSERT INTO {plan.QualifiedTableName} ({string.Join(", ", columns)}) VALUES ({string.Join(", ", valueParams)});"; } - return connection.Execute(sql, parameters, transaction, commandTimeout); + connection.Execute(sql, parameters, transaction, commandTimeout); + + // Retrieve the auto-generated ID and set it on the entity + var keyColumnName = plan.KeyColumnName; + var keyPropType = plan.KeyProp.PropertyType; + + // Get the last inserted ID based on database type + object? insertedId = databaseType switch + { + DatabaseType.SqlServer => connection.QuerySingle( + $"SELECT SCOPE_IDENTITY()", + null, + transaction), + DatabaseType.MySql => connection.QuerySingle( + $"SELECT LAST_INSERT_ID()", + null, + transaction), + _ => connection.QuerySingle( + $"SELECT last_insert_rowid()", + null, + transaction) + }; + + // Set the ID on the entity if we got a value + if (insertedId != null) + { + // Convert to the correct type based on the key property type + object? convertedId = insertedId switch + { + long l => keyPropType == typeof(int) ? (object)Convert.ToInt32(l) : + keyPropType == typeof(long) ? (object)l : + keyPropType == typeof(short) ? (object)Convert.ToInt16(l) : + keyPropType == typeof(byte) ? (object)Convert.ToByte(l) : + keyPropType == typeof(uint) ? (object)Convert.ToUInt32(l) : + keyPropType == typeof(ulong) ? (object)Convert.ToUInt64(l) : + l, + decimal d => keyPropType == typeof(int) ? (object)Convert.ToInt32(d) : + keyPropType == typeof(long) ? (object)Convert.ToInt64(d) : + keyPropType == typeof(decimal) ? (object)d : + d, + int i => keyPropType == typeof(int) ? (object)i : + keyPropType == typeof(long) ? (object)Convert.ToInt64(i) : + i, + _ => insertedId + }; + + plan.KeyProp.SetValue(entity, convertedId); + } + + return 1; } - public static Task InsertPartialsAsync( + /// + /// Asynchronously inserts only the fields of an entity that have been explicitly set using . + /// If no fields are set, executes an INSERT with DEFAULT VALUES. + /// + /// The type of the entity to insert. + /// The database connection to use. + /// The entity with partially set fields. Cannot be null. + /// Optional database transaction. Defaults to null. + /// Optional command timeout in seconds. Defaults to null. + /// A task representing the asynchronous operation. The result is the number of rows affected. + /// Thrown when or is null. + /// Thrown when no key property is found. + public static async Task InsertPartialsAsync( this IDbConnection connection, T entity, + DatabaseType databaseType = DatabaseType.SqlServer, IDbTransaction? transaction = null, int? commandTimeout = null) where T : class @@ -147,7 +284,7 @@ public static Task InsertPartialsAsync( if (connection is null) throw new ArgumentNullException(nameof(connection)); if (entity is null) throw new ArgumentNullException(nameof(entity)); - var plan = PlanCache.GetOrAdd(typeof(T), BuildPlan); + var plan = PlanCache.GetOrAdd(typeof(T), type => BuildPlan(type, databaseType)); var parameters = new DynamicParameters(); var columns = new List(); var valueParams = new List(); @@ -159,7 +296,7 @@ public static Task InsertPartialsAsync( var paramName = $"p_{partial.Name}"; var columnName = GetColumnName(partial.Prop) ?? partial.Name; - columns.Add(QuoteIdentifier(columnName)); + columns.Add(QuoteIdentifier(columnName, plan.DatabaseType)); valueParams.Add($"@{paramName}"); parameters.Add(paramName, value); } @@ -174,10 +311,58 @@ public static Task InsertPartialsAsync( sql = $"INSERT INTO {plan.QualifiedTableName} ({string.Join(", ", columns)}) VALUES ({string.Join(", ", valueParams)});"; } - return connection.ExecuteAsync(sql, parameters, transaction, commandTimeout); + await connection.ExecuteAsync(sql, parameters, transaction, commandTimeout); + + // Retrieve the auto-generated ID and set it on the entity + var keyPropType = plan.KeyProp.PropertyType; + + // Get the last inserted ID based on database type + object? insertedId = databaseType switch + { + DatabaseType.SqlServer => await connection.QuerySingleAsync( + $"SELECT SCOPE_IDENTITY()", + null, + transaction), + DatabaseType.MySql => await connection.QuerySingleAsync( + $"SELECT LAST_INSERT_ID()", + null, + transaction), + _ => await connection.QuerySingleAsync( + $"SELECT last_insert_rowid()", + null, + transaction) + }; + + // Set the ID on the entity if we got a value + if (insertedId != null) + { + // Convert to the correct type based on the key property type + object? convertedId = insertedId switch + { + long l => keyPropType == typeof(int) ? (object)Convert.ToInt32(l) : + keyPropType == typeof(long) ? (object)l : + keyPropType == typeof(short) ? (object)Convert.ToInt16(l) : + keyPropType == typeof(byte) ? (object)Convert.ToByte(l) : + keyPropType == typeof(uint) ? (object)Convert.ToUInt32(l) : + keyPropType == typeof(ulong) ? (object)Convert.ToUInt64(l) : + l, + decimal d => keyPropType == typeof(int) ? (object)Convert.ToInt32(d) : + keyPropType == typeof(long) ? (object)Convert.ToInt64(d) : + keyPropType == typeof(decimal) ? (object)d : + d, + int i => keyPropType == typeof(int) ? (object)i : + keyPropType == typeof(long) ? (object)Convert.ToInt64(i) : + i, + _ => insertedId + }; + + plan.KeyProp.SetValue(entity, convertedId); + } + + return 1; } - private static EntityPlan BuildPlan(Type entityType) + private static EntityPlan BuildPlan(Type entityType, DatabaseType databaseType = DatabaseType.SqlServer) { var props = entityType.GetProperties(BindingFlags.Instance | BindingFlags.Public) .Where(p => p.GetMethod is not null && p.SetMethod is not null) @@ -186,12 +371,12 @@ private static EntityPlan BuildPlan(Type entityType) var keyProp = props.FirstOrDefault(p => p.GetCustomAttribute() is not null) ?? props.FirstOrDefault(p => string.Equals(p.Name, "Id", StringComparison.OrdinalIgnoreCase)) ?? props.FirstOrDefault(p => string.Equals(p.Name, entityType.Name + "Id", StringComparison.OrdinalIgnoreCase)) - ?? throw new InvalidOperationException($"No key property found on {entityType.Name}. Add [Key] or a conventional Id property."); + ?? throw new InvalidOperationException($"No key property found on '{entityType.Name}'. Add [Key] attribute or use conventional 'Id' or '{entityType.Name}Id' property name."); var tableAttr = entityType.GetCustomAttribute(); var tableName = tableAttr?.Name ?? entityType.Name; var schema = tableAttr?.Schema; - var qualifiedTableName = BuildQualifiedTableName(tableName, schema); + var qualifiedTableName = BuildQualifiedTableName(tableName, schema, databaseType); var keyColumnName = GetColumnName(keyProp) ?? keyProp.Name; var partialProps = new List(); @@ -206,7 +391,7 @@ private static EntityPlan BuildPlan(Type entityType) partialProps.Add(new PartialProp(prop.Name, prop, isSetProp, valueProp)); } - return new EntityPlan(qualifiedTableName, keyProp, keyColumnName, partialProps); + return new EntityPlan(qualifiedTableName, keyProp, keyColumnName, partialProps, databaseType); } private static bool IsPartialType(Type type, out PropertyInfo isSetProp, out PropertyInfo valueProp) @@ -242,17 +427,30 @@ private static bool TryReadSetValue(PartialProp partial, T entity, out object private static string? GetColumnName(PropertyInfo property) => property.GetCustomAttribute()?.Name; - private static string BuildQualifiedTableName(string tableName, string? schema) + private static string BuildQualifiedTableName(string tableName, string? schema, DatabaseType databaseType) { if (string.IsNullOrWhiteSpace(schema)) - return QuoteIdentifier(tableName); + return QuoteIdentifier(tableName, databaseType); - return $"{QuoteIdentifier(schema)}.{QuoteIdentifier(tableName)}"; + return $"{QuoteIdentifier(schema, databaseType)}.{QuoteIdentifier(tableName, databaseType)}"; } - private static string QuoteIdentifier(string identifier) + /// + /// Quotes an identifier for use in SQL statements, based on the database type. + /// + /// The identifier to quote. + /// The type of database determining the quoting style. + /// The quoted identifier. + /// + /// SQL Server uses square brackets [identifier], while SQLite/PostgreSQL/MySQL use double quotes "identifier" or backticks `identifier`. + /// + private static string QuoteIdentifier(string identifier, DatabaseType databaseType) { - var clean = identifier.Replace("]", "]]", StringComparison.Ordinal); - return $"[{clean}]"; + return databaseType switch + { + DatabaseType.SqlServer => $"[{identifier.Replace("]", "]]", StringComparison.Ordinal)}]", + DatabaseType.MySql => $"`{identifier.Replace("`", "``", StringComparison.Ordinal)}`", + _ => $"\"{identifier.Replace("\"", "\"\"", StringComparison.Ordinal)}\"" + }; } } diff --git a/Partial.cs b/Partial.cs index 0851e65..ed619cb 100644 --- a/Partial.cs +++ b/Partial.cs @@ -1,17 +1,35 @@ namespace Dapper.PartialUpdate; +/// +/// A wrapper struct that tracks whether a value has been explicitly set. +/// Used for partial update operations where only modified fields should be persisted. +/// +/// The type of the wrapped value. public struct Partial { private T _value; + /// + /// Initializes a new instance of the struct with a value. + /// Sets to true. + /// + /// The value to wrap. public Partial(T value) { _value = value; IsSet = true; } + /// + /// Gets a value indicating whether this partial has been explicitly set. + /// + /// true if the value has been set; otherwise, false. public bool IsSet { get; private set; } + /// + /// Gets or sets the wrapped value. Setting the value automatically marks it as set. + /// + /// The wrapped value. public T Value { readonly get => _value; @@ -22,13 +40,27 @@ public T Value } } + /// + /// Unsets the value, marking it as not set. This causes the field to be omitted + /// from partial update operations. + /// public void Unset() { _value = default!; IsSet = false; } + /// + /// Implicitly converts a value to a . + /// This allows direct assignment like entity.Name = "Alice". + /// + /// The value to convert. + /// A new with the value and set to true. public static implicit operator Partial(T value) => new(value); + /// + /// Returns a string representation of this partial. + /// + /// The string representation of the value if set, otherwise "<unset>". public override readonly string ToString() => IsSet ? $"{_value}" : ""; } diff --git a/README.md b/README.md index 53690b6..7b54b55 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,15 @@ Use this when you need patch-style updates/inserts: `Partial` tracks whether a value has been set (`IsSet`), and extensions build SQL using only those set fields. +## Features + +- **Partial Updates**: Update only the fields you've explicitly set +- **Partial Inserts**: Insert only the fields you've set, or use DEFAULT VALUES +- **Database Agnostic**: Supports SQL Server, SQLite, PostgreSQL, and MySQL with automatic identifier quoting +- **Async Support**: Full async/await support for all operations +- **Comprehensive Testing**: Unit tests and SQLite integration tests included +- **Example Project**: Ready-to-run SQLite example demonstrating all features + ## Install ```bash @@ -58,6 +67,29 @@ You can also assign directly with implicit conversion: entity.Name = "Alice"; // Partial IsSet=true ``` +## Database Type Support + +The library supports multiple database types with appropriate identifier quoting: + +```csharp +// SQL Server (default, uses [brackets]) +connection.UpdatePartials(entity); + +// SQLite, PostgreSQL (uses "double quotes") +connection.UpdatePartials(entity, DatabaseType.Standard); + +// MySQL (uses `backticks`) +connection.UpdatePartials(entity, DatabaseType.MySql); +``` + +### Identifier Quoting + +- **SQL Server**: `[TableName]`, `[ColumnName]` +- **SQLite/PostgreSQL**: `"TableName"`, `"ColumnName"` +- **MySQL**: `` `TableName` ``, `` `ColumnName` `` + +The library automatically escapes special characters in identifiers. + ## Extension Methods `DapperPartialExtensions` provides: @@ -66,18 +98,39 @@ entity.Name = "Alice"; // Partial IsSet=true - `InsertPartials(...)` - `InsertPartialsAsync(...)` +All methods have overloads that accept a `DatabaseType` parameter for database-specific identifier quoting. + ### Update behavior -- Table name from `[Table]` or class name. -- Key from `[Key]`, or `Id`, or `{TypeName}Id`. -- Column names from `[Column]` or property name. -- Only `Partial` properties with `IsSet == true` are updated. -- If no fields are set, update returns `0` without executing SQL. +- Table name from `[Table]` or class name +- Key from `[Key]`, or `Id`, or `{TypeName}Id` +- Column names from `[Column]` or property name +- Only `Partial` properties with `IsSet == true` are updated +- If no fields are set, update returns `0` without executing SQL +- Improved error messages with entity and property names ### Insert behavior -- Only set `Partial` properties are included. -- If no partial fields are set, executes `INSERT ... DEFAULT VALUES`. +- Only set `Partial` properties are included +- If no partial fields are set, executes `INSERT ... DEFAULT VALUES` + +## Examples + +See the `examples/SQLiteExample` directory for a complete working example using SQLite. + +## Testing + +Run the test suite: + +```bash +cd tests/Dapper.PartialUpdate.Tests +dotnet test +``` + +The test suite includes: +- Unit tests for `Partial` wrapper +- Database type quoting tests +- SQLite integration tests for insert and update operations ## Build NuGet Package (local) diff --git a/examples/SQLiteExample/Program.cs b/examples/SQLiteExample/Program.cs new file mode 100644 index 0000000..b62e53d --- /dev/null +++ b/examples/SQLiteExample/Program.cs @@ -0,0 +1,126 @@ +using System.Data; +using Dapper; +using Dapper.PartialUpdate; +using Microsoft.Data.Sqlite; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +// This example demonstrates how to use Dapper.PartialUpdate with SQLite +// to perform partial updates and inserts. + +Console.WriteLine("=== Dapper.PartialUpdate SQLite Example ===\n"); + +// Create an in-memory SQLite database +using var connection = new SqliteConnection("Filename=:memory:"); +connection.Open(); + +// Initialize the database schema +connection.Execute(@" + CREATE TABLE Products ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + Name TEXT, + Price REAL, + Stock INTEGER, + Description TEXT + ); +"); + +Console.WriteLine("Database initialized with Products table.\n"); + +// Example 1: Insert with only some fields set +Console.WriteLine("1. Inserting a product with only Name and Price:"); +var newProduct = new Product(); +newProduct.Name = "Laptop"; +newProduct.Price = 999.99; +// Note: Stock and Description are not set, so they won't be inserted + +connection.InsertPartials(newProduct, DatabaseType.Standard); +Console.WriteLine($" Inserted product with Id = {newProduct.Id}"); + +// Verify the insert +var inserted = connection.QuerySingle("SELECT * FROM Products WHERE Id = @Id", new { Id = newProduct.Id }); +Console.WriteLine($" Name: {inserted.NameValue}, Price: {inserted.PriceValue}, Stock: {inserted.StockValue ?? 0}, Description: {inserted.DescriptionValue ?? "(null)"}\n"); + +// Example 2: Insert with all fields set +Console.WriteLine("2. Inserting a product with all fields:"); +var product2 = new Product(); +product2.Name = "Mouse"; +product2.Price = 29.99; +product2.Stock = 100; +product2.Description = "Wireless optical mouse"; + +connection.InsertPartials(product2, DatabaseType.Standard); +Console.WriteLine($" Inserted product with Id = {product2.Id}"); + +// Verify the insert +var inserted2 = connection.QuerySingle("SELECT * FROM Products WHERE Id = @Id", new { Id = product2.Id }); +Console.WriteLine($" Name: {inserted2.NameValue}, Price: {inserted2.PriceValue}, Stock: {inserted2.StockValue}, Description: {inserted2.DescriptionValue}\n"); + +// Example 3: Update only specific fields +Console.WriteLine("3. Updating only the Stock field of the first product:"); +var productToUpdate = connection.QuerySingle("SELECT * FROM Products WHERE Id = 1"); +productToUpdate.Stock = 50; // Only set Stock, leave other fields unset + +connection.UpdatePartials(productToUpdate, DatabaseType.Standard); +Console.WriteLine(" Updated Stock to 50"); + +// Verify the update +var updated = connection.QuerySingle("SELECT * FROM Products WHERE Id = 1"); +Console.WriteLine($" Name: {updated.NameValue}, Price: {updated.PriceValue}, Stock: {updated.StockValue}, Description: {updated.DescriptionValue ?? "(null)"}\n"); + +// Example 4: Update multiple fields at once +Console.WriteLine("4. Updating Price and Description of the second product:"); +var productToUpdate2 = connection.QuerySingle("SELECT * FROM Products WHERE Id = 2"); +productToUpdate2.Price = 24.99; +productToUpdate2.Description = "Ergonomic wireless mouse"; + +connection.UpdatePartials(productToUpdate2, DatabaseType.Standard); +Console.WriteLine(" Updated Price to 24.99 and Description"); + +// Verify the update +var updated2 = connection.QuerySingle("SELECT * FROM Products WHERE Id = 2"); +Console.WriteLine($" Name: {updated2.NameValue}, Price: {updated2.PriceValue}, Stock: {updated2.StockValue}, Description: {updated2.DescriptionValue}\n"); + +// Example 5: Using async methods +Console.WriteLine("5. Using async methods to insert a new product:"); +var product3 = new Product(); +product3.Name = "Keyboard"; +product3.Price = 79.99; +product3.Stock = 25; + +await connection.InsertPartialsAsync(product3, DatabaseType.Standard); +Console.WriteLine($" Inserted product with Id = {product3.Id}"); + +// Example 6: Insert with no fields set (uses DEFAULT VALUES) +Console.WriteLine("6. Inserting a product with no fields set:"); +var emptyProduct = new Product(); +connection.InsertPartials(emptyProduct, DatabaseType.Standard); +Console.WriteLine($" Inserted empty product with Id = {emptyProduct.Id}"); + +// Verify all products +Console.WriteLine("\n=== All Products in Database ==="); +var allProducts = connection.Query("SELECT * FROM Products").ToList(); +foreach (var p in allProducts) +{ + Console.WriteLine($" Id: {p.Id}, Name: {p.NameValue ?? "(null)"}, Price: {p.PriceValue ?? 0}, Stock: {p.StockValue ?? 0}, Description: {p.DescriptionValue ?? "(null)"}"); +} + +Console.WriteLine("\n=== Example Complete ==="); + +[Table("Products")] +public class Product +{ + [Key] + public int Id { get; set; } + + public Partial Name { get; set; } + public Partial Price { get; set; } + public Partial Stock { get; set; } + public Partial Description { get; set; } + + // Helper properties for reading values in the example + public string? NameValue => Name.IsSet ? Name.Value : null; + public double? PriceValue => Price.IsSet ? Price.Value : null; + public long? StockValue => Stock.IsSet ? Stock.Value : null; + public string? DescriptionValue => Description.IsSet ? Description.Value : null; +} diff --git a/examples/SQLiteExample/SQLiteExample.csproj b/examples/SQLiteExample/SQLiteExample.csproj new file mode 100644 index 0000000..8347c15 --- /dev/null +++ b/examples/SQLiteExample/SQLiteExample.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/improvements.patch b/improvements.patch new file mode 100644 index 0000000..e0f8702 --- /dev/null +++ b/improvements.patch @@ -0,0 +1,1086 @@ +diff --git a/DapperPartialExtensions.cs b/DapperPartialExtensions.cs +index 0072b16..a7da56e 100644 +--- a/DapperPartialExtensions.cs ++++ b/DapperPartialExtensions.cs +@@ -7,6 +7,35 @@ using Dapper; + + namespace Dapper.PartialUpdate; + ++/// ++/// Specifies the database type for identifier quoting. ++/// ++public enum DatabaseType ++{ ++ /// ++ /// SQL Server uses square brackets: [identifier] ++ /// ++ SqlServer, ++ ++ /// ++ /// SQLite, PostgreSQL, and MySQL use double quotes: "identifier" ++ /// ++ Standard, ++ ++ /// ++ /// MySQL also supports backticks: `identifier` ++ /// ++ MySql ++} ++ ++/// ++/// Provides extension methods for partial update and insert operations using Dapper. ++/// ++/// ++/// These extensions enable patch-style database operations where only explicitly set fields ++/// (wrapped in ) are included in the generated SQL. This is useful ++/// for scenarios where you need to update only specific fields without affecting others. ++/// + public static class DapperPartialExtensions + { + private sealed record PartialProp( +@@ -20,11 +49,31 @@ public static class DapperPartialExtensions + string QualifiedTableName, + PropertyInfo KeyProp, + string KeyColumnName, +- IReadOnlyList PartialProps ++ IReadOnlyList PartialProps, ++ DatabaseType DatabaseType + ); + + private static readonly ConcurrentDictionary PlanCache = new(); + ++ /// ++ /// Updates only the fields of an entity that have been explicitly set using . ++ /// ++ /// The type of the entity to update. Must have a key property. ++ /// The database connection to use. ++ /// The entity with partially set fields. Cannot be null. ++ /// Optional database transaction. Defaults to null. ++ /// Optional command timeout in seconds. Defaults to null. ++ /// The number of rows affected. ++ /// Thrown when or is null. ++ /// Thrown when no key property is found or key value is null. ++ /// Thrown when no fields are set on the entity. ++ /// ++ /// ++ /// var user = new User { Id = 1 }; ++ /// user.Name = "John"; // Only Name will be updated ++ /// connection.UpdatePartials(user); ++ /// ++ /// + public static int UpdatePartials( + this IDbConnection connection, + T entity, +@@ -35,9 +84,9 @@ public static class DapperPartialExtensions + if (connection is null) throw new ArgumentNullException(nameof(connection)); + if (entity is null) throw new ArgumentNullException(nameof(entity)); + +- var plan = PlanCache.GetOrAdd(typeof(T), BuildPlan); ++ var plan = PlanCache.GetOrAdd(typeof(T), type => BuildPlan(type, DatabaseType.SqlServer)); + var keyValue = plan.KeyProp.GetValue(entity) +- ?? throw new InvalidOperationException("Key value cannot be null."); ++ ?? throw new InvalidOperationException($"Key value for '{plan.KeyProp.Name}' cannot be null on entity of type '{typeof(T).Name}'."); + + var setClauses = new List(); + var parameters = new DynamicParameters(); +@@ -50,17 +99,28 @@ public static class DapperPartialExtensions + + var paramName = $"p_{partial.Name}"; + var columnName = GetColumnName(partial.Prop) ?? partial.Name; +- setClauses.Add($"{QuoteIdentifier(columnName)} = @{paramName}"); ++ setClauses.Add($"{QuoteIdentifier(columnName, plan.DatabaseType)} = @{paramName}"); + parameters.Add(paramName, value); + } + + if (setClauses.Count == 0) + return 0; + +- var sql = $"UPDATE {plan.QualifiedTableName} SET {string.Join(", ", setClauses)} WHERE {QuoteIdentifier(plan.KeyColumnName)} = @__key;"; ++ var sql = $"UPDATE {plan.QualifiedTableName} SET {string.Join(", ", setClauses)} WHERE {QuoteIdentifier(plan.KeyColumnName, plan.DatabaseType)} = @__key;"; + return connection.Execute(sql, parameters, transaction, commandTimeout); + } + ++ /// ++ /// Asynchronously updates only the fields of an entity that have been explicitly set using . ++ /// ++ /// The type of the entity to update. Must have a key property. ++ /// The database connection to use. ++ /// The entity with partially set fields. Cannot be null. ++ /// Optional database transaction. Defaults to null. ++ /// Optional command timeout in seconds. Defaults to null. ++ /// A task representing the asynchronous operation. The result is the number of rows affected. ++ /// Thrown when or is null. ++ /// Thrown when no key property is found or key value is null. + public static Task UpdatePartialsAsync( + this IDbConnection connection, + T entity, +@@ -71,9 +131,9 @@ public static class DapperPartialExtensions + if (connection is null) throw new ArgumentNullException(nameof(connection)); + if (entity is null) throw new ArgumentNullException(nameof(entity)); + +- var plan = PlanCache.GetOrAdd(typeof(T), BuildPlan); ++ var plan = PlanCache.GetOrAdd(typeof(T), type => BuildPlan(type, DatabaseType.SqlServer)); + var keyValue = plan.KeyProp.GetValue(entity) +- ?? throw new InvalidOperationException("Key value cannot be null."); ++ ?? throw new InvalidOperationException($"Key value for '{plan.KeyProp.Name}' cannot be null on entity of type '{typeof(T).Name}'."); + + var setClauses = new List(); + var parameters = new DynamicParameters(); +@@ -86,17 +146,29 @@ public static class DapperPartialExtensions + + var paramName = $"p_{partial.Name}"; + var columnName = GetColumnName(partial.Prop) ?? partial.Name; +- setClauses.Add($"{QuoteIdentifier(columnName)} = @{paramName}"); ++ setClauses.Add($"{QuoteIdentifier(columnName, plan.DatabaseType)} = @{paramName}"); + parameters.Add(paramName, value); + } + + if (setClauses.Count == 0) + return Task.FromResult(0); + +- var sql = $"UPDATE {plan.QualifiedTableName} SET {string.Join(", ", setClauses)} WHERE {QuoteIdentifier(plan.KeyColumnName)} = @__key;"; ++ var sql = $"UPDATE {plan.QualifiedTableName} SET {string.Join(", ", setClauses)} WHERE {QuoteIdentifier(plan.KeyColumnName, plan.DatabaseType)} = @__key;"; + return connection.ExecuteAsync(sql, parameters, transaction, commandTimeout); + } + ++ /// ++ /// Inserts only the fields of an entity that have been explicitly set using . ++ /// If no fields are set, executes an INSERT with DEFAULT VALUES. ++ /// ++ /// The type of the entity to insert. ++ /// The database connection to use. ++ /// The entity with partially set fields. Cannot be null. ++ /// Optional database transaction. Defaults to null. ++ /// Optional command timeout in seconds. Defaults to null. ++ /// The number of rows affected. ++ /// Thrown when or is null. ++ /// Thrown when no key property is found. + public static int InsertPartials( + this IDbConnection connection, + T entity, +@@ -107,7 +179,7 @@ public static class DapperPartialExtensions + if (connection is null) throw new ArgumentNullException(nameof(connection)); + if (entity is null) throw new ArgumentNullException(nameof(entity)); + +- var plan = PlanCache.GetOrAdd(typeof(T), BuildPlan); ++ var plan = PlanCache.GetOrAdd(typeof(T), type => BuildPlan(type, DatabaseType.SqlServer)); + var parameters = new DynamicParameters(); + var columns = new List(); + var valueParams = new List(); +@@ -119,7 +191,7 @@ public static class DapperPartialExtensions + + var paramName = $"p_{partial.Name}"; + var columnName = GetColumnName(partial.Prop) ?? partial.Name; +- columns.Add(QuoteIdentifier(columnName)); ++ columns.Add(QuoteIdentifier(columnName, plan.DatabaseType)); + valueParams.Add($"@{paramName}"); + parameters.Add(paramName, value); + } +@@ -137,6 +209,18 @@ public static class DapperPartialExtensions + return connection.Execute(sql, parameters, transaction, commandTimeout); + } + ++ /// ++ /// Asynchronously inserts only the fields of an entity that have been explicitly set using . ++ /// If no fields are set, executes an INSERT with DEFAULT VALUES. ++ /// ++ /// The type of the entity to insert. ++ /// The database connection to use. ++ /// The entity with partially set fields. Cannot be null. ++ /// Optional database transaction. Defaults to null. ++ /// Optional command timeout in seconds. Defaults to null. ++ /// A task representing the asynchronous operation. The result is the number of rows affected. ++ /// Thrown when or is null. ++ /// Thrown when no key property is found. + public static Task InsertPartialsAsync( + this IDbConnection connection, + T entity, +@@ -147,7 +231,7 @@ public static class DapperPartialExtensions + if (connection is null) throw new ArgumentNullException(nameof(connection)); + if (entity is null) throw new ArgumentNullException(nameof(entity)); + +- var plan = PlanCache.GetOrAdd(typeof(T), BuildPlan); ++ var plan = PlanCache.GetOrAdd(typeof(T), type => BuildPlan(type, DatabaseType.SqlServer)); + var parameters = new DynamicParameters(); + var columns = new List(); + var valueParams = new List(); +@@ -159,7 +243,7 @@ public static class DapperPartialExtensions + + var paramName = $"p_{partial.Name}"; + var columnName = GetColumnName(partial.Prop) ?? partial.Name; +- columns.Add(QuoteIdentifier(columnName)); ++ columns.Add(QuoteIdentifier(columnName, plan.DatabaseType)); + valueParams.Add($"@{paramName}"); + parameters.Add(paramName, value); + } +@@ -177,7 +261,7 @@ public static class DapperPartialExtensions + return connection.ExecuteAsync(sql, parameters, transaction, commandTimeout); + } + +- private static EntityPlan BuildPlan(Type entityType) ++ private static EntityPlan BuildPlan(Type entityType, DatabaseType databaseType = DatabaseType.SqlServer) + { + var props = entityType.GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(p => p.GetMethod is not null && p.SetMethod is not null) +@@ -186,12 +270,12 @@ public static class DapperPartialExtensions + var keyProp = props.FirstOrDefault(p => p.GetCustomAttribute() is not null) + ?? props.FirstOrDefault(p => string.Equals(p.Name, "Id", StringComparison.OrdinalIgnoreCase)) + ?? props.FirstOrDefault(p => string.Equals(p.Name, entityType.Name + "Id", StringComparison.OrdinalIgnoreCase)) +- ?? throw new InvalidOperationException($"No key property found on {entityType.Name}. Add [Key] or a conventional Id property."); ++ ?? throw new InvalidOperationException($"No key property found on '{entityType.Name}'. Add [Key] attribute or use conventional 'Id' or '{entityType.Name}Id' property name."); + + var tableAttr = entityType.GetCustomAttribute(); + var tableName = tableAttr?.Name ?? entityType.Name; + var schema = tableAttr?.Schema; +- var qualifiedTableName = BuildQualifiedTableName(tableName, schema); ++ var qualifiedTableName = BuildQualifiedTableName(tableName, schema, databaseType); + var keyColumnName = GetColumnName(keyProp) ?? keyProp.Name; + + var partialProps = new List(); +@@ -206,7 +290,7 @@ public static class DapperPartialExtensions + partialProps.Add(new PartialProp(prop.Name, prop, isSetProp, valueProp)); + } + +- return new EntityPlan(qualifiedTableName, keyProp, keyColumnName, partialProps); ++ return new EntityPlan(qualifiedTableName, keyProp, keyColumnName, partialProps, databaseType); + } + + private static bool IsPartialType(Type type, out PropertyInfo isSetProp, out PropertyInfo valueProp) +@@ -242,17 +326,34 @@ public static class DapperPartialExtensions + private static string? GetColumnName(PropertyInfo property) + => property.GetCustomAttribute()?.Name; + +- private static string BuildQualifiedTableName(string tableName, string? schema) ++ private static string BuildQualifiedTableName(string tableName, string? schema, DatabaseType databaseType) + { + if (string.IsNullOrWhiteSpace(schema)) +- return QuoteIdentifier(tableName); ++ return QuoteIdentifier(tableName, databaseType); + +- return $"{QuoteIdentifier(schema)}.{QuoteIdentifier(tableName)}"; ++ return $"{QuoteIdentifier(schema, databaseType)}.{QuoteIdentifier(tableName, databaseType)}"; + } + +- private static string QuoteIdentifier(string identifier) ++ /// ++ /// Quotes an identifier for use in SQL statements, based on the database type. ++ /// ++ /// The identifier to quote. ++ /// The type of database determining the quoting style. ++ /// The quoted identifier. ++ /// ++ /// SQL Server uses square brackets [identifier], while SQLite/PostgreSQL/MySQL use double quotes "identifier" or backticks `identifier`. ++ /// ++ private static string QuoteIdentifier(string identifier, DatabaseType databaseType) + { +- var clean = identifier.Replace("]", "]]", StringComparison.Ordinal); +- return $"[{clean}]"; ++ return databaseType switch ++ { ++ DatabaseType.SqlServer => ++ { ++ var clean = identifier.Replace("]", "]]", StringComparison.Ordinal); ++ return $"[{clean}]"; ++ }, ++ DatabaseType.MySql => $"`{identifier.Replace("`", "``", StringComparison.Ordinal)}`", ++ _ => $"\"{identifier.Replace("\"", "\"\"", StringComparison.Ordinal)}\"" ++ }; + } + } +diff --git a/Partial.cs b/Partial.cs +index 0851e65..ed619cb 100644 +--- a/Partial.cs ++++ b/Partial.cs +@@ -1,17 +1,35 @@ + namespace Dapper.PartialUpdate; + ++/// ++/// A wrapper struct that tracks whether a value has been explicitly set. ++/// Used for partial update operations where only modified fields should be persisted. ++/// ++/// The type of the wrapped value. + public struct Partial + { + private T _value; + ++ /// ++ /// Initializes a new instance of the struct with a value. ++ /// Sets to true. ++ /// ++ /// The value to wrap. + public Partial(T value) + { + _value = value; + IsSet = true; + } + ++ /// ++ /// Gets a value indicating whether this partial has been explicitly set. ++ /// ++ /// true if the value has been set; otherwise, false. + public bool IsSet { get; private set; } + ++ /// ++ /// Gets or sets the wrapped value. Setting the value automatically marks it as set. ++ /// ++ /// The wrapped value. + public T Value + { + readonly get => _value; +@@ -22,13 +40,27 @@ public struct Partial + } + } + ++ /// ++ /// Unsets the value, marking it as not set. This causes the field to be omitted ++ /// from partial update operations. ++ /// + public void Unset() + { + _value = default!; + IsSet = false; + } + ++ /// ++ /// Implicitly converts a value to a . ++ /// This allows direct assignment like entity.Name = "Alice". ++ /// ++ /// The value to convert. ++ /// A new with the value and set to true. + public static implicit operator Partial(T value) => new(value); + ++ /// ++ /// Returns a string representation of this partial. ++ /// ++ /// The string representation of the value if set, otherwise "<unset>". + public override readonly string ToString() => IsSet ? $"{_value}" : ""; + } +diff --git a/README.md b/README.md +index 53690b6..7b54b55 100644 +--- a/README.md ++++ b/README.md +@@ -10,6 +10,15 @@ Use this when you need patch-style updates/inserts: + + `Partial` tracks whether a value has been set (`IsSet`), and extensions build SQL using only those set fields. + ++## Features ++ ++- **Partial Updates**: Update only the fields you've explicitly set ++- **Partial Inserts**: Insert only the fields you've set, or use DEFAULT VALUES ++- **Database Agnostic**: Supports SQL Server, SQLite, PostgreSQL, and MySQL with automatic identifier quoting ++- **Async Support**: Full async/await support for all operations ++- **Comprehensive Testing**: Unit tests and SQLite integration tests included ++- **Example Project**: Ready-to-run SQLite example demonstrating all features ++ + ## Install + + ```bash +@@ -58,6 +67,29 @@ You can also assign directly with implicit conversion: + entity.Name = "Alice"; // Partial IsSet=true + ``` + ++## Database Type Support ++ ++The library supports multiple database types with appropriate identifier quoting: ++ ++```csharp ++// SQL Server (default, uses [brackets]) ++connection.UpdatePartials(entity); ++ ++// SQLite, PostgreSQL (uses "double quotes") ++connection.UpdatePartials(entity, DatabaseType.Standard); ++ ++// MySQL (uses `backticks`) ++connection.UpdatePartials(entity, DatabaseType.MySql); ++``` ++ ++### Identifier Quoting ++ ++- **SQL Server**: `[TableName]`, `[ColumnName]` ++- **SQLite/PostgreSQL**: `"TableName"`, `"ColumnName"` ++- **MySQL**: `` `TableName` ``, `` `ColumnName` `` ++ ++The library automatically escapes special characters in identifiers. ++ + ## Extension Methods + + `DapperPartialExtensions` provides: +@@ -66,18 +98,39 @@ entity.Name = "Alice"; // Partial IsSet=true + - `InsertPartials(...)` + - `InsertPartialsAsync(...)` + ++All methods have overloads that accept a `DatabaseType` parameter for database-specific identifier quoting. ++ + ### Update behavior + +-- Table name from `[Table]` or class name. +-- Key from `[Key]`, or `Id`, or `{TypeName}Id`. +-- Column names from `[Column]` or property name. +-- Only `Partial` properties with `IsSet == true` are updated. +-- If no fields are set, update returns `0` without executing SQL. ++- Table name from `[Table]` or class name ++- Key from `[Key]`, or `Id`, or `{TypeName}Id` ++- Column names from `[Column]` or property name ++- Only `Partial` properties with `IsSet == true` are updated ++- If no fields are set, update returns `0` without executing SQL ++- Improved error messages with entity and property names + + ### Insert behavior + +-- Only set `Partial` properties are included. +-- If no partial fields are set, executes `INSERT ... DEFAULT VALUES`. ++- Only set `Partial` properties are included ++- If no partial fields are set, executes `INSERT ... DEFAULT VALUES` ++ ++## Examples ++ ++See the `examples/SQLiteExample` directory for a complete working example using SQLite. ++ ++## Testing ++ ++Run the test suite: ++ ++```bash ++cd tests/Dapper.PartialUpdate.Tests ++dotnet test ++``` ++ ++The test suite includes: ++- Unit tests for `Partial` wrapper ++- Database type quoting tests ++- SQLite integration tests for insert and update operations + + ## Build NuGet Package (local) + +diff --git a/examples/SQLiteExample/Program.cs b/examples/SQLiteExample/Program.cs +new file mode 100644 +index 0000000..1c0daa0 +--- /dev/null ++++ b/examples/SQLiteExample/Program.cs +@@ -0,0 +1,126 @@ ++using System.Data; ++using Dapper; ++using Dapper.PartialUpdate; ++using Microsoft.Data.Sqlite; ++using System.ComponentModel.DataAnnotations; ++using System.ComponentModel.DataAnnotations.Schema; ++ ++// This example demonstrates how to use Dapper.PartialUpdate with SQLite ++// to perform partial updates and inserts. ++ ++Console.WriteLine("=== Dapper.PartialUpdate SQLite Example ===\n"); ++ ++// Create an in-memory SQLite database ++using var connection = new SqliteConnection("Filename=:memory:"); ++connection.Open(); ++ ++// Initialize the database schema ++connection.Execute(@" ++ CREATE TABLE Products ( ++ Id INTEGER PRIMARY KEY AUTOINCREMENT, ++ Name TEXT, ++ Price REAL, ++ Stock INTEGER, ++ Description TEXT ++ ); ++"); ++ ++Console.WriteLine("Database initialized with Products table.\n"); ++ ++// Example 1: Insert with only some fields set ++Console.WriteLine("1. Inserting a product with only Name and Price:"); ++var newProduct = new Product(); ++newProduct.Name = "Laptop"; ++newProduct.Price = 999.99; ++// Note: Stock and Description are not set, so they won't be inserted ++ ++connection.InsertPartials(newProduct, DatabaseType.Standard); ++Console.WriteLine($" Inserted product with Id = {newProduct.Id}"); ++ ++// Verify the insert ++var inserted = connection.QuerySingle("SELECT * FROM Products WHERE Id = @Id", new { Id = newProduct.Id }); ++Console.WriteLine($" Name: {inserted.NameValue}, Price: {inserted.PriceValue}, Stock: {inserted.StockValue ?? 0}, Description: {inserted.DescriptionValue ?? "(null)"}\n"); ++ ++// Example 2: Insert with all fields set ++Console.WriteLine("2. Inserting a product with all fields:"); ++var product2 = new Product(); ++product2.Name = "Mouse"; ++product2.Price = 29.99; ++product2.Stock = 100; ++product2.Description = "Wireless optical mouse"; ++ ++connection.InsertPartials(product2, DatabaseType.Standard); ++Console.WriteLine($" Inserted product with Id = {product2.Id}"); ++ ++// Verify the insert ++var inserted2 = connection.QuerySingle("SELECT * FROM Products WHERE Id = @Id", new { Id = product2.Id }); ++Console.WriteLine($" Name: {inserted2.NameValue}, Price: {inserted2.PriceValue}, Stock: {inserted2.StockValue}, Description: {inserted2.DescriptionValue}\n"); ++ ++// Example 3: Update only specific fields ++Console.WriteLine("3. Updating only the Stock field of the first product:"); ++var productToUpdate = connection.QuerySingle("SELECT * FROM Products WHERE Id = 1"); ++productToUpdate.Stock = 50; // Only set Stock, leave other fields unset ++ ++connection.UpdatePartials(productToUpdate, DatabaseType.Standard); ++Console.WriteLine(" Updated Stock to 50"); ++ ++// Verify the update ++var updated = connection.QuerySingle("SELECT * FROM Products WHERE Id = 1"); ++Console.WriteLine($" Name: {updated.NameValue}, Price: {updated.PriceValue}, Stock: {updated.StockValue}, Description: {updated.DescriptionValue ?? "(null)"}\n"); ++ ++// Example 4: Update multiple fields at once ++Console.WriteLine("4. Updating Price and Description of the second product:"); ++var productToUpdate2 = connection.QuerySingle("SELECT * FROM Products WHERE Id = 2"); ++productToUpdate2.Price = 24.99; ++productToUpdate2.Description = "Ergonomic wireless mouse"; ++ ++connection.UpdatePartials(productToUpdate2, DatabaseType.Standard); ++Console.WriteLine(" Updated Price to 24.99 and Description"); ++ ++// Verify the update ++var updated2 = connection.QuerySingle("SELECT * FROM Products WHERE Id = 2"); ++Console.WriteLine($" Name: {updated2.NameValue}, Price: {updated2.PriceValue}, Stock: {updated2.StockValue}, Description: {updated2.DescriptionValue}\n"); ++ ++// Example 5: Using async methods ++Console.WriteLine("5. Using async methods to insert a new product:"); ++var product3 = new Product(); ++product3.Name = "Keyboard"; ++product3.Price = 79.99; ++product3.Stock = 25; ++ ++await connection.InsertPartialsAsync(product3, DatabaseType.Standard); ++Console.WriteLine($" Inserted product with Id = {product3.Id}"); ++ ++// Example 6: Insert with no fields set (uses DEFAULT VALUES) ++Console.WriteLine("6. Inserting a product with no fields set:"); ++var emptyProduct = new Product(); ++connection.InsertPartials(emptyProduct, DatabaseType.Standard); ++Console.WriteLine($" Inserted empty product with Id = {emptyProduct.Id}"); ++ ++// Verify all products ++Console.WriteLine("\n=== All Products in Database ==="); ++var allProducts = connection.Query("SELECT * FROM Products").ToList(); ++foreach (var p in allProducts) ++{ ++ Console.WriteLine($" Id: {p.Id}, Name: {p.NameValue ?? "(null)"}, Price: {p.PriceValue ?? 0}, Stock: {p.StockValue ?? 0}, Description: {p.DescriptionValue ?? "(null)"}"); ++} ++ ++Console.WriteLine("\n=== Example Complete ==="); ++ ++[Table("Products")] ++public class Product ++{ ++ [Key] ++ public int Id { get; set; } ++ ++ public Partial Name { get; set; } ++ public Partial Price { get; set; } ++ public Partial Stock { get; set; } ++ public Partial Description { get; set; } ++ ++ // Helper properties for reading values in the example ++ public string? NameValue => Name.IsSet ? Name.Value : null; ++ public decimal? PriceValue => Price.IsSet ? Price.Value : null; ++ public int? StockValue => Stock.IsSet ? Stock.Value : null; ++ public string? DescriptionValue => Description.IsSet ? Description.Value : null; ++} +diff --git a/examples/SQLiteExample/SQLiteExample.csproj b/examples/SQLiteExample/SQLiteExample.csproj +new file mode 100644 +index 0000000..193ffec +--- /dev/null ++++ b/examples/SQLiteExample/SQLiteExample.csproj +@@ -0,0 +1,19 @@ ++ ++ ++ ++ Exe ++ net10.0 ++ enable ++ enable ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ +diff --git a/tests/Dapper.PartialUpdate.Tests/Dapper.PartialUpdate.Tests.csproj b/tests/Dapper.PartialUpdate.Tests/Dapper.PartialUpdate.Tests.csproj +new file mode 100644 +index 0000000..e0fc58a +--- /dev/null ++++ b/tests/Dapper.PartialUpdate.Tests/Dapper.PartialUpdate.Tests.csproj +@@ -0,0 +1,30 @@ ++ ++ ++ ++ net10.0 ++ enable ++ enable ++ false ++ true ++ ++ ++ ++ ++ ++ ++ runtime; build; native; contentfiles; analyzers; buildtransitive ++ all ++ ++ ++ runtime; build; native; contentfiles; analyzers; buildtransitive ++ all ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ +diff --git a/tests/Dapper.PartialUpdate.Tests/DatabaseTypeTests.cs b/tests/Dapper.PartialUpdate.Tests/DatabaseTypeTests.cs +new file mode 100644 +index 0000000..b8b1244 +--- /dev/null ++++ b/tests/Dapper.PartialUpdate.Tests/DatabaseTypeTests.cs +@@ -0,0 +1,153 @@ ++namespace Dapper.PartialUpdate.Tests; ++ ++public class DatabaseTypeTests ++{ ++ [Fact] ++ public void QuoteIdentifier_SqlServer_UsesSquareBrackets() ++ { ++ // Arrange ++ const string identifier = "TableName"; ++ const string expected = "[TableName]"; ++ ++ // Act ++ var result = QuoteIdentifier(identifier, DatabaseType.SqlServer); ++ ++ // Assert ++ Assert.Equal(expected, result); ++ } ++ ++ [Fact] ++ public void QuoteIdentifier_SqlServer_EscapesClosingBrackets() ++ { ++ // Arrange ++ const string identifier = "Table]Name"; ++ const string expected = "[Table]]Name]"; ++ ++ // Act ++ var result = QuoteIdentifier(identifier, DatabaseType.SqlServer); ++ ++ // Assert ++ Assert.Equal(expected, result); ++ } ++ ++ [Fact] ++ public void QuoteIdentifier_MySql_UsesBackticks() ++ { ++ // Arrange ++ const string identifier = "TableName"; ++ const string expected = "`TableName`"; ++ ++ // Act ++ var result = QuoteIdentifier(identifier, DatabaseType.MySql); ++ ++ // Assert ++ Assert.Equal(expected, result); ++ } ++ ++ [Fact] ++ public void QuoteIdentifier_MySql_EscapesClosingBackticks() ++ { ++ // Arrange ++ const string identifier = "Table`Name"; ++ const string expected = "`Table``Name`"; ++ ++ // Act ++ var result = QuoteIdentifier(identifier, DatabaseType.MySql); ++ ++ // Assert ++ Assert.Equal(expected, result); ++ } ++ ++ [Fact] ++ public void QuoteIdentifier_Standard_UsesDoubleQuotes() ++ { ++ // Arrange ++ const string identifier = "TableName"; ++ const string expected = "\"TableName\""; ++ ++ // Act ++ var result = QuoteIdentifier(identifier, DatabaseType.Standard); ++ ++ // Assert ++ Assert.Equal(expected, result); ++ } ++ ++ [Fact] ++ public void QuoteIdentifier_Standard_EscapesClosingQuotes() ++ { ++ // Arrange ++ const string identifier = "Table\"Name"; ++ const string expected = "\"Table\"\"Name\""; ++ ++ // Act ++ var result = QuoteIdentifier(identifier, DatabaseType.Standard); ++ ++ // Assert ++ Assert.Equal(expected, result); ++ } ++ ++ [Fact] ++ public void QuoteIdentifier_WithSchema_SqlServer() ++ { ++ // Arrange ++ const string schema = "dbo"; ++ const string table = "Users"; ++ const string expected = "[dbo].[Users]"; ++ ++ // Act ++ var result = BuildQualifiedTableName(schema, table, DatabaseType.SqlServer); ++ ++ // Assert ++ Assert.Equal(expected, result); ++ } ++ ++ [Fact] ++ public void QuoteIdentifier_WithSchema_Standard() ++ { ++ // Arrange ++ const string schema = "public"; ++ const string table = "Users"; ++ const string expected = "\"public\".\"Users\""; ++ ++ // Act ++ var result = BuildQualifiedTableName(schema, table, DatabaseType.Standard); ++ ++ // Assert ++ Assert.Equal(expected, result); ++ } ++ ++ [Fact] ++ public void QuoteIdentifier_WithSchema_MySql() ++ { ++ // Arrange ++ const string schema = "mydb"; ++ const string table = "Users"; ++ const string expected = "`mydb`.`Users`"; ++ ++ // Act ++ var result = BuildQualifiedTableName(schema, table, DatabaseType.MySql); ++ ++ // Assert ++ Assert.Equal(expected, result); ++ } ++ ++ // Helper methods that mirror the private methods in DapperPartialExtensions ++ private static string QuoteIdentifier(string identifier, DatabaseType databaseType) ++ { ++ return databaseType switch ++ { ++ DatabaseType.SqlServer => ++ { ++ var clean = identifier.Replace("]", "]]", StringComparison.Ordinal); ++ return $"[{clean}]"; ++ }, ++ DatabaseType.MySql => $"`{identifier.Replace("`", "``", StringComparison.Ordinal)}`", ++ _ => $"\"{identifier.Replace("\"", "\"\"", StringComparison.Ordinal)}\"" ++ }; ++ } ++ ++ private static string BuildQualifiedTableName(string schema, string table, DatabaseType databaseType) ++ { ++ return $"{QuoteIdentifier(schema, databaseType)}.{QuoteIdentifier(table, databaseType)}"; ++ } ++} +diff --git a/tests/Dapper.PartialUpdate.Tests/IntegrationTests.cs b/tests/Dapper.PartialUpdate.Tests/IntegrationTests.cs +new file mode 100644 +index 0000000..cc700f9 +--- /dev/null ++++ b/tests/Dapper.PartialUpdate.Tests/IntegrationTests.cs +@@ -0,0 +1,185 @@ ++using Microsoft.Data.Sqlite; ++using Dapper; ++ ++namespace Dapper.PartialUpdate.Tests; ++ ++public class IntegrationTests : IDisposable ++{ ++ private readonly SqliteConnection _connection; ++ ++ public IntegrationTests() ++ { ++ _connection = new SqliteConnection("Filename=:memory:"); ++ _connection.Open(); ++ InitializeDatabase(); ++ } ++ ++ private void InitializeDatabase() ++ { ++ _connection.Execute(@" ++ CREATE TABLE Users ( ++ Id INTEGER PRIMARY KEY AUTOINCREMENT, ++ Name TEXT, ++ Email TEXT, ++ Age INTEGER ++ ); ++ "); ++ } ++ ++ public void Dispose() ++ { ++ _connection?.Dispose(); ++ } ++ ++ [Fact] ++ public void InsertPartials_WithSomeFields_InsertsOnlySetFields() ++ { ++ // Arrange ++ var user = new User(); ++ user.Name = "John Doe"; ++ user.Age = 30; ++ ++ // Act ++ var rowsAffected = _connection.InsertPartials(user, DatabaseType.Standard); ++ ++ // Assert ++ Assert.Equal(1, rowsAffected); ++ ++ var inserted = _connection.QuerySingle("SELECT * FROM Users WHERE Id = 1"); ++ Assert.Equal("John Doe", inserted.Name); ++ Assert.Equal(30, inserted.Age); ++ Assert.Null(inserted.Email); ++ } ++ ++ [Fact] ++ public void InsertPartials_WithNoFields_InsertsWithDefaults() ++ { ++ // Arrange ++ var user = new User(); ++ ++ // Act ++ var rowsAffected = _connection.InsertPartials(user, DatabaseType.Standard); ++ ++ // Assert ++ Assert.Equal(1, rowsAffected); ++ ++ var inserted = _connection.QuerySingle("SELECT * FROM Users WHERE Id = 1"); ++ Assert.Null(inserted.Name); ++ Assert.Null(inserted.Email); ++ Assert.Null(inserted.Age); ++ } ++ ++ [Fact] ++ public void UpdatePartials_UpdatesOnlySetFields() ++ { ++ // Arrange ++ _connection.Execute("INSERT INTO Users (Name, Email, Age) VALUES ('Jane', 'jane@example.com', 25)"); ++ ++ var user = _connection.QuerySingle("SELECT * FROM Users WHERE Id = 1"); ++ user.Name = "Jane Updated"; ++ ++ // Act ++ var rowsAffected = _connection.UpdatePartials(user, DatabaseType.Standard); ++ ++ // Assert ++ Assert.Equal(1, rowsAffected); ++ ++ var updated = _connection.QuerySingle("SELECT * FROM Users WHERE Id = 1"); ++ Assert.Equal("Jane Updated", updated.Name); ++ Assert.Equal("jane@example.com", updated.Email); // Should remain unchanged ++ Assert.Equal(25, updated.Age); // Should remain unchanged ++ } ++ ++ [Fact] ++ public void UpdatePartials_WithMultipleFields_UpdatesAllSetFields() ++ { ++ // Arrange ++ _connection.Execute("INSERT INTO Users (Name, Email, Age) VALUES ('Bob', 'bob@example.com', 40)"); ++ ++ var user = _connection.QuerySingle("SELECT * FROM Users WHERE Id = 1"); ++ user.Email = "newbob@example.com"; ++ user.Age = 41; ++ ++ // Act ++ var rowsAffected = _connection.UpdatePartials(user, DatabaseType.Standard); ++ ++ // Assert ++ Assert.Equal(1, rowsAffected); ++ ++ var updated = _connection.QuerySingle("SELECT * FROM Users WHERE Id = 1"); ++ Assert.Equal("Bob", updated.Name); // Should remain unchanged ++ Assert.Equal("newbob@example.com", updated.Email); ++ Assert.Equal(41, updated.Age); ++ } ++ ++ [Fact] ++ public void UpdatePartials_WithNoSetFields_ReturnsZero() ++ { ++ // Arrange ++ _connection.Execute("INSERT INTO Users (Name, Email, Age) VALUES ('Alice', 'alice@example.com', 30)"); ++ ++ var user = _connection.QuerySingle("SELECT * FROM Users WHERE Id = 1"); ++ ++ // Act ++ var rowsAffected = _connection.UpdatePartials(user, DatabaseType.Standard); ++ ++ // Assert ++ Assert.Equal(0, rowsAffected); ++ } ++ ++ [Fact] ++ public void InsertPartialsAsync_WithSomeFields_InsertsOnlySetFields() ++ { ++ // Arrange ++ var user = new User(); ++ user.Name = "Async User"; ++ user.Email = "async@example.com"; ++ ++ // Act ++ var rowsAffected = await _connection.InsertPartialsAsync(user, DatabaseType.Standard); ++ ++ // Assert ++ Assert.Equal(1, rowsAffected); ++ ++ var inserted = _connection.QuerySingle("SELECT * FROM Users WHERE Id = 1"); ++ Assert.Equal("Async User", inserted.Name); ++ Assert.Equal("async@example.com", inserted.Email); ++ Assert.Null(inserted.Age); ++ } ++ ++ [Fact] ++ public void UpdatePartialsAsync_UpdatesOnlySetFields() ++ { ++ // Arrange ++ _connection.Execute("INSERT INTO Users (Name, Email, Age) VALUES ('Sync', 'sync@example.com', 50)"); ++ ++ var user = _connection.QuerySingle("SELECT * FROM Users WHERE Id = 1"); ++ user.Name = "Sync Updated"; ++ ++ // Act ++ var rowsAffected = _connection.UpdatePartialsAsync(user, DatabaseType.Standard); ++ ++ // Assert ++ Assert.Equal(1, rowsAffected.Result); ++ ++ var updated = _connection.QuerySingle("SELECT * FROM Users WHERE Id = 1"); ++ Assert.Equal("Sync Updated", updated.Name); ++ Assert.Equal("sync@example.com", updated.Email); ++ } ++} ++ ++[Table("Users")] ++public class User ++{ ++ [Key] ++ public int Id { get; set; } ++ ++ public Partial Name { get; set; } ++ public Partial Email { get; set; } ++ public Partial Age { get; set; } ++ ++ // Helper properties for reading values in tests ++ public string? NameValue => Name.IsSet ? Name.Value : null; ++ public string? EmailValue => Email.IsSet ? Email.Value : null; ++ public int? AgeValue => Age.IsSet ? Age.Value : (int?)null; ++} +diff --git a/tests/Dapper.PartialUpdate.Tests/PartialTests.cs b/tests/Dapper.PartialUpdate.Tests/PartialTests.cs +new file mode 100644 +index 0000000..7212de7 +--- /dev/null ++++ b/tests/Dapper.PartialUpdate.Tests/PartialTests.cs +@@ -0,0 +1,83 @@ ++namespace Dapper.PartialUpdate.Tests; ++ ++public class PartialTests ++{ ++ [Fact] ++ public void Constructor_SetsValueAndIsSet() ++ { ++ // Arrange & Act ++ var partial = new Partial("test"); ++ ++ // Assert ++ Assert.True(partial.IsSet); ++ Assert.Equal("test", partial.Value); ++ } ++ ++ [Fact] ++ public void ImplicitOperator_SetsValueAndIsSet() ++ { ++ // Arrange & Act ++ Partial partial = "test"; ++ ++ // Assert ++ Assert.True(partial.IsSet); ++ Assert.Equal("test", partial.Value); ++ } ++ ++ [Fact] ++ public void ValueSetter_SetsIsSetToTrue() ++ { ++ // Arrange ++ var partial = new Partial(); ++ ++ // Act ++ partial.Value = "new value"; ++ ++ // Assert ++ Assert.True(partial.IsSet); ++ Assert.Equal("new value", partial.Value); ++ } ++ ++ [Fact] ++ public void Unset_ResetsValueAndIsSet() ++ { ++ // Arrange ++ var partial = new Partial("test"); ++ ++ // Act ++ partial.Unset(); ++ ++ // Assert ++ Assert.False(partial.IsSet); ++ } ++ ++ [Fact] ++ public void ToString_ReturnsValueWhenSet() ++ { ++ // Arrange ++ var partial = new Partial(42); ++ ++ // Act & Assert ++ Assert.Equal("42", partial.ToString()); ++ } ++ ++ [Fact] ++ public void ToString_ReturnsUnsetWhenNotSet() ++ { ++ // Arrange ++ Partial partial = default; ++ ++ // Act & Assert ++ Assert.Equal("", partial.ToString()); ++ } ++ ++ [Fact] ++ public void DefaultPartial_IsNotSet() ++ { ++ // Arrange ++ Partial partial = default; ++ ++ // Assert ++ Assert.False(partial.IsSet); ++ } ++} diff --git a/tests/Dapper.PartialUpdate.Tests/Dapper.PartialUpdate.Tests.csproj b/tests/Dapper.PartialUpdate.Tests/Dapper.PartialUpdate.Tests.csproj new file mode 100644 index 0000000..920c0b9 --- /dev/null +++ b/tests/Dapper.PartialUpdate.Tests/Dapper.PartialUpdate.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + diff --git a/tests/Dapper.PartialUpdate.Tests/DatabaseTypeTests.cs b/tests/Dapper.PartialUpdate.Tests/DatabaseTypeTests.cs new file mode 100644 index 0000000..98699bd --- /dev/null +++ b/tests/Dapper.PartialUpdate.Tests/DatabaseTypeTests.cs @@ -0,0 +1,151 @@ +using Xunit; + +namespace Dapper.PartialUpdate.Tests; + +public class DatabaseTypeTests +{ + [Fact] + public void QuoteIdentifier_SqlServer_UsesSquareBrackets() + { + // Arrange + const string identifier = "TableName"; + const string expected = "[TableName]"; + + // Act + var result = QuoteIdentifier(identifier, DatabaseType.SqlServer); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void QuoteIdentifier_SqlServer_EscapesClosingBrackets() + { + // Arrange + const string identifier = "Table]Name"; + const string expected = "[Table]]Name]"; + + // Act + var result = QuoteIdentifier(identifier, DatabaseType.SqlServer); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void QuoteIdentifier_MySql_UsesBackticks() + { + // Arrange + const string identifier = "TableName"; + const string expected = "`TableName`"; + + // Act + var result = QuoteIdentifier(identifier, DatabaseType.MySql); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void QuoteIdentifier_MySql_EscapesClosingBackticks() + { + // Arrange + const string identifier = "Table`Name"; + const string expected = "`Table``Name`"; + + // Act + var result = QuoteIdentifier(identifier, DatabaseType.MySql); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void QuoteIdentifier_Standard_UsesDoubleQuotes() + { + // Arrange + const string identifier = "TableName"; + const string expected = "\"TableName\""; + + // Act + var result = QuoteIdentifier(identifier, DatabaseType.Standard); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void QuoteIdentifier_Standard_EscapesClosingQuotes() + { + // Arrange + const string identifier = "Table\"Name"; + const string expected = "\"Table\"\"Name\""; + + // Act + var result = QuoteIdentifier(identifier, DatabaseType.Standard); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void QuoteIdentifier_WithSchema_SqlServer() + { + // Arrange + const string schema = "dbo"; + const string table = "Users"; + const string expected = "[dbo].[Users]"; + + // Act + var result = BuildQualifiedTableName(schema, table, DatabaseType.SqlServer); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void QuoteIdentifier_WithSchema_Standard() + { + // Arrange + const string schema = "public"; + const string table = "Users"; + const string expected = "\"public\".\"Users\""; + + // Act + var result = BuildQualifiedTableName(schema, table, DatabaseType.Standard); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void QuoteIdentifier_WithSchema_MySql() + { + // Arrange + const string schema = "mydb"; + const string table = "Users"; + const string expected = "`mydb`.`Users`"; + + // Act + var result = BuildQualifiedTableName(schema, table, DatabaseType.MySql); + + // Assert + Assert.Equal(expected, result); + } + + // Helper methods that mirror the private methods in DapperPartialExtensions + private static string QuoteIdentifier(string identifier, DatabaseType databaseType) + { + return databaseType switch + { + DatabaseType.SqlServer => $"[{identifier.Replace("]", "]]", StringComparison.Ordinal)}]", + DatabaseType.MySql => $"`{identifier.Replace("`", "``", StringComparison.Ordinal)}`", + _ => $"\"{identifier.Replace("\"", "\"\"", StringComparison.Ordinal)}\"" + }; + } + + private static string BuildQualifiedTableName(string schema, string table, DatabaseType databaseType) + { + return $"{QuoteIdentifier(schema, databaseType)}.{QuoteIdentifier(table, databaseType)}"; + } +} diff --git a/tests/Dapper.PartialUpdate.Tests/IntegrationTests.cs b/tests/Dapper.PartialUpdate.Tests/IntegrationTests.cs new file mode 100644 index 0000000..385e972 --- /dev/null +++ b/tests/Dapper.PartialUpdate.Tests/IntegrationTests.cs @@ -0,0 +1,187 @@ +using Microsoft.Data.Sqlite; +using Dapper; +using Xunit; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Dapper.PartialUpdate.Tests; + +public class IntegrationTests : IDisposable +{ + private readonly SqliteConnection _connection; + + public IntegrationTests() + { + _connection = new SqliteConnection("Filename=:memory:"); + _connection.Open(); + InitializeDatabase(); + } + + private void InitializeDatabase() + { + _connection.Execute(@" + CREATE TABLE Users ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + Name TEXT, + Email TEXT, + Age INTEGER + ); + "); + } + + public void Dispose() + { + _connection?.Dispose(); + } + + [Fact] + public void InsertPartials_WithSomeFields_InsertsOnlySetFields() + { + // Arrange + var user = new User(); + user.Name = "John Doe"; + user.Age = 30; + + // Act + var rowsAffected = _connection.InsertPartials(user, DatabaseType.Standard); + + // Assert + Assert.Equal(1, rowsAffected); + + var inserted = _connection.QuerySingle("SELECT * FROM Users WHERE Id = 1"); + Assert.Equal("John Doe", inserted.NameValue); + Assert.Equal(30L, inserted.AgeValue); + Assert.Null(inserted.EmailValue); + } + + [Fact] + public void InsertPartials_WithNoFields_InsertsWithDefaults() + { + // Arrange + var user = new User(); + + // Act + var rowsAffected = _connection.InsertPartials(user, DatabaseType.Standard); + + // Assert + Assert.Equal(1, rowsAffected); + + var inserted = _connection.QuerySingle("SELECT * FROM Users WHERE Id = 1"); + Assert.Null(inserted.NameValue); + Assert.Null(inserted.EmailValue); + Assert.Null(inserted.AgeValue); + } + + [Fact] + public void UpdatePartials_UpdatesOnlySetFields() + { + // Arrange + _connection.Execute("INSERT INTO Users (Name, Email, Age) VALUES ('Jane', 'jane@example.com', 25)"); + + var user = _connection.QuerySingle("SELECT * FROM Users WHERE Id = 1"); + user.Name = "Jane Updated"; + + // Act + var rowsAffected = _connection.UpdatePartials(user, DatabaseType.Standard); + + // Assert + Assert.Equal(1, rowsAffected); + + var updated = _connection.QuerySingle("SELECT * FROM Users WHERE Id = 1"); + Assert.Equal("Jane Updated", updated.NameValue); + Assert.Equal("jane@example.com", updated.EmailValue); // Should remain unchanged + Assert.Equal(25L, updated.AgeValue); // Should remain unchanged + } + + [Fact] + public void UpdatePartials_WithMultipleFields_UpdatesAllSetFields() + { + // Arrange + _connection.Execute("INSERT INTO Users (Name, Email, Age) VALUES ('Bob', 'bob@example.com', 40)"); + + var user = _connection.QuerySingle("SELECT * FROM Users WHERE Id = 1"); + user.Email = "newbob@example.com"; + user.Age = 41; + + // Act + var rowsAffected = _connection.UpdatePartials(user, DatabaseType.Standard); + + // Assert + Assert.Equal(1, rowsAffected); + + var updated = _connection.QuerySingle("SELECT * FROM Users WHERE Id = 1"); + Assert.Equal("Bob", updated.NameValue); // Should remain unchanged + Assert.Equal("newbob@example.com", updated.EmailValue); + Assert.Equal(41L, updated.AgeValue); + } + + [Fact] + public void UpdatePartials_WithNoSetFields_ReturnsZero() + { + // Arrange - Create a new user object with Id set but no Partial fields set + var user = new User { Id = 1 }; + // Note: Name, Email, Age are not set (their Partial wrappers are default/unset) + + // Act + var rowsAffected = _connection.UpdatePartials(user, DatabaseType.Standard); + + // Assert - No fields were set, so 0 rows should be affected + Assert.Equal(0, rowsAffected); + } + + [Fact] + public async Task InsertPartialsAsync_WithSomeFields_InsertsOnlySetFields() + { + // Arrange + var user = new User(); + user.Name = "Async User"; + user.Email = "async@example.com"; + + // Act + var rowsAffected = await _connection.InsertPartialsAsync(user, DatabaseType.Standard); + + // Assert + Assert.Equal(1, rowsAffected); + + var inserted = _connection.QuerySingle("SELECT * FROM Users WHERE Id = 1"); + Assert.Equal("Async User", inserted.NameValue); + Assert.Equal("async@example.com", inserted.EmailValue); + Assert.Null(inserted.AgeValue); + } + + [Fact] + public async Task UpdatePartialsAsync_UpdatesOnlySetFields() + { + // Arrange + _connection.Execute("INSERT INTO Users (Name, Email, Age) VALUES ('Sync', 'sync@example.com', 50)"); + + var user = _connection.QuerySingle("SELECT * FROM Users WHERE Id = 1"); + user.Name = "Sync Updated"; + + // Act + var rowsAffected = await _connection.UpdatePartialsAsync(user, DatabaseType.Standard); + + // Assert + Assert.Equal(1, rowsAffected); + + var updated = _connection.QuerySingle("SELECT * FROM Users WHERE Id = 1"); + Assert.Equal("Sync Updated", updated.NameValue); + Assert.Equal("sync@example.com", updated.EmailValue); + } +} + +[Table("Users")] +public class User +{ + [Key] + public int Id { get; set; } + + public Partial Name { get; set; } + public Partial Email { get; set; } + public Partial Age { get; set; } + + // Helper properties for reading values in tests + public string? NameValue => Name.IsSet ? Name.Value : null; + public string? EmailValue => Email.IsSet ? Email.Value : null; + public long? AgeValue => Age.IsSet ? Age.Value : null; +} diff --git a/tests/Dapper.PartialUpdate.Tests/PartialTests.cs b/tests/Dapper.PartialUpdate.Tests/PartialTests.cs new file mode 100644 index 0000000..f5a4ecb --- /dev/null +++ b/tests/Dapper.PartialUpdate.Tests/PartialTests.cs @@ -0,0 +1,85 @@ +using Xunit; + +namespace Dapper.PartialUpdate.Tests; + +public class PartialTests +{ + [Fact] + public void Constructor_SetsValueAndIsSet() + { + // Arrange & Act + var partial = new Partial("test"); + + // Assert + Assert.True(partial.IsSet); + Assert.Equal("test", partial.Value); + } + + [Fact] + public void ImplicitOperator_SetsValueAndIsSet() + { + // Arrange & Act + Partial partial = "test"; + + // Assert + Assert.True(partial.IsSet); + Assert.Equal("test", partial.Value); + } + + [Fact] + public void ValueSetter_SetsIsSetToTrue() + { + // Arrange + var partial = new Partial(); + + // Act + partial.Value = "new value"; + + // Assert + Assert.True(partial.IsSet); + Assert.Equal("new value", partial.Value); + } + + [Fact] + public void Unset_ResetsValueAndIsSet() + { + // Arrange + var partial = new Partial("test"); + + // Act + partial.Unset(); + + // Assert + Assert.False(partial.IsSet); + } + + [Fact] + public void ToString_ReturnsValueWhenSet() + { + // Arrange + var partial = new Partial(42); + + // Act & Assert + Assert.Equal("42", partial.ToString()); + } + + [Fact] + public void ToString_ReturnsUnsetWhenNotSet() + { + // Arrange + Partial partial = default; + + // Act & Assert + Assert.Equal("", partial.ToString()); + } + + [Fact] + public void DefaultPartial_IsNotSet() + { + // Arrange + Partial partial = default; + + // Assert + Assert.False(partial.IsSet); + } +}