From 8a792c66294f527f82699518f9b3e843498b7e62 Mon Sep 17 00:00:00 2001 From: "Eric Sibly [chullybun]" Date: Thu, 19 Jan 2023 15:54:23 -0800 Subject: [PATCH] v2.5.0 - New features and fixes (#53) * StringCase + CodeAnalysis * Fix doco type. * Further Code Analysis updates. * Fix/enhance dictionary capabilities. Correct SQL identifier quoting. * Update change log. --- CHANGELOG.md | 7 ++ Common.targets | 2 +- .../AutoMapperConverterWrapper.cs | 4 +- src/CoreEx.Cosmos/CosmosDb.cs | 5 +- src/CoreEx.Database/Extended/DatabaseArgs.cs | 2 +- .../Extended/DatabaseExtendedExtensions.cs | 18 ++++- .../Extended/RefDataMultiSetCollArgs.cs | 2 - .../Mapping/ChangeLogDatabaseMapper.cs | 2 +- .../Mapping/ChangeLogExDatabaseMapper.cs | 2 +- src/CoreEx.Database/MultiSetCollArgsT.cs | 3 +- src/CoreEx.EntityFrameworkCore/EfDbEntity.cs | 2 +- .../Json/JsonPreFilterInspector.cs | 2 +- src/CoreEx.Validation/PropertyContext.cs | 4 +- src/CoreEx.Validation/PropertyRuleBase.cs | 3 - src/CoreEx.Validation/ValidationExtensions.cs | 3 - src/CoreEx/Abstractions/ObjectExtensions.cs | 2 + .../Reflection/PropertyExpression.cs | 2 +- src/CoreEx/Entities/Cleaner.cs | 39 ++++++++-- src/CoreEx/Entities/CompositeKey.cs | 2 +- src/CoreEx/Entities/EntityKeyCollection.cs | 2 +- .../Entities/Extended/EntityBaseCollection.cs | 10 ++- .../Entities/Extended/EntityBaseDictionary.cs | 20 +++-- src/CoreEx/Entities/Extended/EntityCore.cs | 73 ++++++++++++++----- .../Entities/Extended/ObservableDictionary.cs | 38 +++++++++- src/CoreEx/Entities/IdentifierGenerator.cs | 5 +- src/CoreEx/Entities/StringCase.cs | 38 ++++++++++ src/CoreEx/Globalization/TexInfoExtensions.cs | 1 + src/CoreEx/Globalization/TextInfoCasing.cs | 9 ++- src/CoreEx/Http/TypedHttpClientBaseT.cs | 13 +++- .../Json/Merge/DictionaryMergeApproach.cs | 2 +- src/CoreEx/Mapping/CollectionMapper.cs | 4 +- .../Mapping/Converters/ValueConverter.cs | 2 +- src/CoreEx/RefData/ReferenceDataCollection.cs | 7 ++ .../Text/Json/JsonPreFilterInspector.cs | 2 +- .../Entities/Extended/EntityCoreTest.cs | 16 +++- .../Extended/ObservableDictionaryTest.cs | 41 +++++------ .../Framework/Globalization/TextInfoTest.cs | 3 + 37 files changed, 289 insertions(+), 103 deletions(-) create mode 100644 src/CoreEx/Entities/StringCase.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b71f4a2..b243ec26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ Represents the **NuGet** versions. +## v2.5.0 +- *Enhancement:* Added string casing support to `Cleaner` and `EntityCore` using new `StringCase`; being `None` (as-is default), `Upper`, `Lower` and `Title`. Leverages standard .NET `TextInfo` to implement underlying case conversion. +- *Fixed:* Applied all changes identified by Code Analysis. +- *Fixed:* `NullReferenceException` in `EntityBaseDictionary` where item value is `null` corrected. +- *Enhancement:* Added `KeyModifier` function to `ObservableDictionary` to allow key manipulation to ensure consistency, i.e. all uppercase. +- *Fixed:* Potential SQL Injection opportunity within `DatabaseExtendedExtensions.ReferenceData` corrected when schema and table names were being specified explicitly; now quoted using `DbCommandBuilder.QuoteIdentifier`. + ## v2.4.0 - *Enhancement:* Added `CompareValuesRule`, `EnumRule` and `EnumValueRule` as additional validation rules. diff --git a/Common.targets b/Common.targets index 3f5b894d..4eca2f38 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@  - 2.4.0 + 2.5.0 preview Avanade Avanade diff --git a/src/CoreEx.AutoMapper/AutoMapperConverterWrapper.cs b/src/CoreEx.AutoMapper/AutoMapperConverterWrapper.cs index 376a48fe..39216fa1 100644 --- a/src/CoreEx.AutoMapper/AutoMapperConverterWrapper.cs +++ b/src/CoreEx.AutoMapper/AutoMapperConverterWrapper.cs @@ -41,7 +41,7 @@ public abstract class AutoMapperConverterWrapper /// Represents the source to destination struct. /// - public struct ToDestinationMapper : AutoMapper.IValueConverter + public readonly struct ToDestinationMapper : AutoMapper.IValueConverter { private readonly IValueConverter _valueConverter; @@ -54,7 +54,7 @@ public struct ToDestinationMapper : AutoMapper.IValueConverter /// Represents the destination to source struct. /// - public struct ToSourceMapper : AutoMapper.IValueConverter + public readonly struct ToSourceMapper : AutoMapper.IValueConverter { private readonly IValueConverter _valueConverter; diff --git a/src/CoreEx.Cosmos/CosmosDb.cs b/src/CoreEx.Cosmos/CosmosDb.cs index da9cdde5..fc9a8e99 100644 --- a/src/CoreEx.Cosmos/CosmosDb.cs +++ b/src/CoreEx.Cosmos/CosmosDb.cs @@ -20,7 +20,10 @@ public class CosmosDb : ICosmosDb private readonly ConcurrentDictionary> _filters = new(); private PartitionKey? _partitionKey; - private struct Key + /// + /// Provides key as combination of model type and container identifier. + /// + private readonly struct Key { public Key(Type modelType, string containerId) { diff --git a/src/CoreEx.Database/Extended/DatabaseArgs.cs b/src/CoreEx.Database/Extended/DatabaseArgs.cs index 7e615f8e..190bd745 100644 --- a/src/CoreEx.Database/Extended/DatabaseArgs.cs +++ b/src/CoreEx.Database/Extended/DatabaseArgs.cs @@ -9,7 +9,7 @@ namespace CoreEx.Database.Extended /// public struct DatabaseArgs { - private IDatabaseMapper? _mapper = null; + private readonly IDatabaseMapper? _mapper = null; /// /// Initializes a new instance of the struct. diff --git a/src/CoreEx.Database/Extended/DatabaseExtendedExtensions.cs b/src/CoreEx.Database/Extended/DatabaseExtendedExtensions.cs index d9a458a9..f757a655 100644 --- a/src/CoreEx.Database/Extended/DatabaseExtendedExtensions.cs +++ b/src/CoreEx.Database/Extended/DatabaseExtendedExtensions.cs @@ -50,14 +50,26 @@ public static RefDataLoader ReferenceData( /// The item . /// The . /// The . - /// The database schema name. + /// The database schema name (optional). /// The database table name. /// The . - public static RefDataLoader ReferenceData(this IDatabase database, string schemaName, string tableName) + /// The and should not be escaped/quoted as this is performed internally to minimize SQL injection opportunity. + public static RefDataLoader ReferenceData(this IDatabase database, string? schemaName, string tableName) where TColl : class, IReferenceDataCollection, new() where TItem : class, IReferenceData, new() where TId : IComparable, IEquatable - => ReferenceData((database ?? throw new ArgumentNullException(nameof(database))).SqlStatement($"SELECT * FROM [{schemaName ?? throw new ArgumentNullException(nameof(schemaName))}].[{tableName ?? throw new ArgumentNullException(nameof(tableName))}]")); + { + if (!database.Provider.CanCreateCommandBuilder) + throw new NotSupportedException("Database Provider can not CreateCommandBuilder which is required to quote the identifiers to minimize SQL inject possibility."); + + var cb = database.Provider.CreateCommandBuilder(); + if (string.IsNullOrEmpty(schemaName)) + return ReferenceData((database ?? throw new ArgumentNullException(nameof(database))) + .SqlStatement($"SELECT * FROM {cb.QuoteIdentifier(tableName ?? throw new ArgumentNullException(nameof(tableName)))}")); + else + return ReferenceData((database ?? throw new ArgumentNullException(nameof(database))) + .SqlStatement($"SELECT * FROM {cb.QuoteIdentifier(schemaName ?? throw new ArgumentNullException(nameof(schemaName)))}.{cb.QuoteIdentifier(tableName ?? throw new ArgumentNullException(nameof(tableName)))}")); + } /// /// Creates a to enable select-like capabilities. diff --git a/src/CoreEx.Database/Extended/RefDataMultiSetCollArgs.cs b/src/CoreEx.Database/Extended/RefDataMultiSetCollArgs.cs index 9e972fc9..e706e714 100644 --- a/src/CoreEx.Database/Extended/RefDataMultiSetCollArgs.cs +++ b/src/CoreEx.Database/Extended/RefDataMultiSetCollArgs.cs @@ -19,7 +19,6 @@ internal class RefDataMultiSetCollArgs : MultiSetCollArgs { private readonly Action _item; private readonly RefDataMapper _refDataMapper; - private readonly Action? _additionalProperties; private readonly Func? _confirmItemIsToBeAdded; /// @@ -35,7 +34,6 @@ public RefDataMultiSetCollArgs(IDatabase database, Action item, string? i { _item = item; _refDataMapper = new RefDataMapper(database, idColumnName, additionalProperties); - _additionalProperties = additionalProperties; _confirmItemIsToBeAdded = confirmItemIsToBeAdded; } diff --git a/src/CoreEx.Database/Mapping/ChangeLogDatabaseMapper.cs b/src/CoreEx.Database/Mapping/ChangeLogDatabaseMapper.cs index 712e58af..b403ebf5 100644 --- a/src/CoreEx.Database/Mapping/ChangeLogDatabaseMapper.cs +++ b/src/CoreEx.Database/Mapping/ChangeLogDatabaseMapper.cs @@ -9,7 +9,7 @@ namespace CoreEx.Database.Mapping /// /// Represents a . /// - public struct ChangeLogDatabaseMapper : IDatabaseMapper + public readonly struct ChangeLogDatabaseMapper : IDatabaseMapper { private static readonly Lazy _default = new(() => new(), true); diff --git a/src/CoreEx.Database/Mapping/ChangeLogExDatabaseMapper.cs b/src/CoreEx.Database/Mapping/ChangeLogExDatabaseMapper.cs index f9586e4b..c6fa88ff 100644 --- a/src/CoreEx.Database/Mapping/ChangeLogExDatabaseMapper.cs +++ b/src/CoreEx.Database/Mapping/ChangeLogExDatabaseMapper.cs @@ -9,7 +9,7 @@ namespace CoreEx.Database.Mapping /// /// Represents a . /// - public struct ChangeLogExDatabaseMapper : IDatabaseMapper + public readonly struct ChangeLogExDatabaseMapper : IDatabaseMapper { private static readonly Lazy _default = new(() => new(), true); diff --git a/src/CoreEx.Database/MultiSetCollArgsT.cs b/src/CoreEx.Database/MultiSetCollArgsT.cs index 63b415ce..c45846e3 100644 --- a/src/CoreEx.Database/MultiSetCollArgsT.cs +++ b/src/CoreEx.Database/MultiSetCollArgsT.cs @@ -45,8 +45,7 @@ public override void DatasetRecord(DatabaseRecord dr) if (dr == null) throw new ArgumentNullException(nameof(dr)); - if (_coll == null) - _coll = new TColl(); + _coll ??= new TColl(); var item = Mapper.MapFromDb(dr); if (item != null) diff --git a/src/CoreEx.EntityFrameworkCore/EfDbEntity.cs b/src/CoreEx.EntityFrameworkCore/EfDbEntity.cs index a4285c11..5c6b245b 100644 --- a/src/CoreEx.EntityFrameworkCore/EfDbEntity.cs +++ b/src/CoreEx.EntityFrameworkCore/EfDbEntity.cs @@ -9,7 +9,7 @@ namespace CoreEx.EntityFrameworkCore /// /// The resultant . /// The entity framework model . - public struct EfDbEntity : IEfDbEntity where T : class, IEntityKey, new() where TModel : class, new() + public readonly struct EfDbEntity : IEfDbEntity where T : class, IEntityKey, new() where TModel : class, new() { /// /// Initializes a new instance of the class. diff --git a/src/CoreEx.Newtonsoft/Json/JsonPreFilterInspector.cs b/src/CoreEx.Newtonsoft/Json/JsonPreFilterInspector.cs index a4ec996c..15c1408e 100644 --- a/src/CoreEx.Newtonsoft/Json/JsonPreFilterInspector.cs +++ b/src/CoreEx.Newtonsoft/Json/JsonPreFilterInspector.cs @@ -9,7 +9,7 @@ namespace CoreEx.Newtonsoft.Json /// /// Provides pre (prior) to filtering JSON inspection. /// - public struct JsonPreFilterInspector : IJsonPreFilterInspector + public readonly struct JsonPreFilterInspector : IJsonPreFilterInspector { /// /// Initializes a new instance of the struct. diff --git a/src/CoreEx.Validation/PropertyContext.cs b/src/CoreEx.Validation/PropertyContext.cs index f8e01949..d65b183a 100644 --- a/src/CoreEx.Validation/PropertyContext.cs +++ b/src/CoreEx.Validation/PropertyContext.cs @@ -216,9 +216,7 @@ public ValidationArgs CreateValidationArgs() // Copy the configuration values; do not allow the higher-level dictionaries (stack) to be extended by lower-level validators. if (Parent?.Config != null) { - if (args.Config == null) - args.Config = new Dictionary(); - + args.Config ??= new Dictionary(); foreach (var cfg in Parent.Config) { args.Config.Add(cfg.Key, cfg.Value); diff --git a/src/CoreEx.Validation/PropertyRuleBase.cs b/src/CoreEx.Validation/PropertyRuleBase.cs index 62ee025c..74eee58a 100644 --- a/src/CoreEx.Validation/PropertyRuleBase.cs +++ b/src/CoreEx.Validation/PropertyRuleBase.cs @@ -1,19 +1,16 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx -using CoreEx.Abstractions.Reflection; using CoreEx.Localization; using CoreEx.Validation.Clauses; using CoreEx.Validation.Rules; using System; using System.Collections.Generic; using System.Linq; -using System.Linq.Expressions; using System.Threading; using System.Threading.Tasks; namespace CoreEx.Validation { - /// /// Represents a base validation rule for an entity property. /// diff --git a/src/CoreEx.Validation/ValidationExtensions.cs b/src/CoreEx.Validation/ValidationExtensions.cs index e51fc149..c74135bc 100644 --- a/src/CoreEx.Validation/ValidationExtensions.cs +++ b/src/CoreEx.Validation/ValidationExtensions.cs @@ -540,8 +540,6 @@ public static IPropertyRule CompareProperty /// Adds a validation with a maximum length (see ). /// @@ -577,7 +575,6 @@ public static IPropertyRule String(this IPropertyRule< /// A . public static IPropertyRule String(this IPropertyRule rule, Regex? regex = null, LText? errorText = null) where TEntity : class => (rule ?? throw new ArgumentNullException(nameof(rule))).AddRule(new StringRule { Regex = regex, ErrorText = errorText }); -#pragma warning restore CA1720 #endregion diff --git a/src/CoreEx/Abstractions/ObjectExtensions.cs b/src/CoreEx/Abstractions/ObjectExtensions.cs index 6144b2df..b38125a1 100644 --- a/src/CoreEx/Abstractions/ObjectExtensions.cs +++ b/src/CoreEx/Abstractions/ObjectExtensions.cs @@ -2,6 +2,7 @@ using CoreEx.Abstractions.Reflection; using System; +using System.Diagnostics.CodeAnalysis; namespace CoreEx { @@ -33,6 +34,7 @@ public static T Adjust(this T value, Action adjuster) where T : class /// The as sentence case. /// For example a value of 'VarNameDB' would return 'Var Name DB'. /// Uses the function to perform the conversion. + [return: NotNullIfNotNull("text")] public static string? ToSentenceCase(this string? text) => PropertyExpression.ToSentenceCase(text); } } \ No newline at end of file diff --git a/src/CoreEx/Abstractions/Reflection/PropertyExpression.cs b/src/CoreEx/Abstractions/Reflection/PropertyExpression.cs index 44ceab14..e874ce23 100644 --- a/src/CoreEx/Abstractions/Reflection/PropertyExpression.cs +++ b/src/CoreEx/Abstractions/Reflection/PropertyExpression.cs @@ -19,7 +19,7 @@ public static class PropertyExpression private static IMemoryCache? _fallbackCache; /// - /// The pattern for splitting sentence strings into words. + /// The pattern for splitting strings into a sentence of words. /// public const string SentenceCaseWordSplitPattern = "([a-z](?=[A-Z])|[A-Z](?=[A-Z][a-z]))"; diff --git a/src/CoreEx/Entities/Cleaner.cs b/src/CoreEx/Entities/Cleaner.cs index 7e704a99..43054d06 100644 --- a/src/CoreEx/Entities/Cleaner.cs +++ b/src/CoreEx/Entities/Cleaner.cs @@ -1,5 +1,6 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +using CoreEx.Globalization; using System; using System.Collections.Generic; using System.Globalization; @@ -15,6 +16,7 @@ public static class Cleaner private static DateTimeTransform _dateTimeTransform = DateTimeTransform.DateTimeUtc; private static StringTransform _stringTransform = StringTransform.EmptyToNull; private static StringTrim _stringTrim = StringTrim.End; + private static StringCase _stringCase = StringCase.None; /// /// Gets or sets the default for all entities unless explicitly overridden. Defaults to . @@ -26,7 +28,7 @@ public static DateTimeTransform DefaultDateTimeTransform } /// - /// Gets or sets the default for all entities unless explicitly overridden. Defaults to . + /// Gets or sets the default for all entities unless explicitly overridden. Defaults to . /// public static StringTransform DefaultStringTransform { @@ -35,7 +37,7 @@ public static StringTransform DefaultStringTransform } /// - /// Gets or sets the default for all entities unless explicitly overridden. Defaults to . + /// Gets or sets the default for all entities unless explicitly overridden. Defaults to . /// public static StringTrim DefaultStringTrim { @@ -43,13 +45,22 @@ public static StringTrim DefaultStringTrim set => _stringTrim = value == StringTrim.UseDefault ? throw new ArgumentException("The default cannot be set to UseDefault.", nameof(DefaultStringTrim)) : value; } + /// + /// Gets or sets the default for all entities unless explicitly overridden. Defaults to . + /// + public static StringCase DefaultStringCase + { + get => _stringCase; + set => _stringCase = value == StringCase.UseDefault ? throw new ArgumentException("The default cannot be set to UseDefault.", nameof(DefaultStringCase)) : value; + } + /// /// Cleans a . /// /// The value to clean. /// The cleaned value. /// The will be trimmed and transformed using the respective and values. - public static string? Clean(string? value) => Clean(value, StringTrim.UseDefault, StringTransform.UseDefault); + public static string? Clean(string? value) => Clean(value, StringTrim.UseDefault, StringTransform.UseDefault, StringCase.UseDefault); /// /// Cleans a using the specified and . @@ -57,8 +68,9 @@ public static StringTrim DefaultStringTrim /// The value to clean. /// The (defaults to ). /// The (defaults to ). + /// The (defaults to ). /// The cleaned value. - public static string? Clean(string? value, StringTrim trim = StringTrim.UseDefault, StringTransform transform = StringTransform.UseDefault) + public static string? Clean(string? value, StringTrim trim = StringTrim.UseDefault, StringTransform transform = StringTransform.UseDefault, StringCase casing = StringCase.UseDefault) { if (trim == StringTrim.UseDefault) trim = DefaultStringTrim; @@ -66,6 +78,9 @@ public static StringTrim DefaultStringTrim if (transform == StringTransform.UseDefault) transform = DefaultStringTransform; + if (casing == StringCase.UseDefault) + casing = DefaultStringCase; + // Handle a null string. if (value == null) { @@ -85,12 +100,24 @@ public static StringTrim DefaultStringTrim }; // Transform the string. - return transform switch + tmp = transform switch { StringTransform.EmptyToNull => (tmp.Length == 0) ? null : tmp, StringTransform.NullToEmpty => tmp ?? string.Empty, _ => tmp, }; + + if (string.IsNullOrEmpty(tmp)) + return tmp; + + // Apply casing to the string. + return casing switch + { + StringCase.Lower => CultureInfo.CurrentCulture.TextInfo.ToCasing(tmp, TextInfoCasing.Lower), + StringCase.Upper => CultureInfo.CurrentCulture.TextInfo.ToCasing(tmp, TextInfoCasing.Upper), + StringCase.Title => CultureInfo.CurrentCulture.TextInfo.ToCasing(tmp, TextInfoCasing.Title), + _ => tmp, + }; } /// @@ -193,7 +220,7 @@ public static DateTime Clean(DateTime value, DateTimeTransform transform) public static T Clean(T value, bool overrideWithDefaultWhenIsInitial) { if (value is string str) - return (T)Convert.ChangeType(Clean(str, StringTrim.UseDefault, StringTransform.UseDefault), typeof(string), CultureInfo.CurrentCulture)!; + return (T)Convert.ChangeType(Clean(str, StringTrim.UseDefault, StringTransform.UseDefault, StringCase.UseDefault), typeof(string), CultureInfo.CurrentCulture)!; else if (value is DateTime dte) return (T)Convert.ChangeType(Clean(dte, DateTimeTransform.UseDefault), typeof(DateTime), CultureInfo.CurrentCulture); diff --git a/src/CoreEx/Entities/CompositeKey.cs b/src/CoreEx/Entities/CompositeKey.cs index 5e9c0792..b6982954 100644 --- a/src/CoreEx/Entities/CompositeKey.cs +++ b/src/CoreEx/Entities/CompositeKey.cs @@ -15,7 +15,7 @@ namespace CoreEx.Entities /// are supported: , , , , , , , , , (converted to a ) and . [System.Diagnostics.DebuggerStepThrough] [System.Diagnostics.DebuggerDisplay("Key = {ToString()}")] - public struct CompositeKey : IEquatable + public readonly struct CompositeKey : IEquatable { private readonly ImmutableArray _args; diff --git a/src/CoreEx/Entities/EntityKeyCollection.cs b/src/CoreEx/Entities/EntityKeyCollection.cs index b665a9dd..509bdbb7 100644 --- a/src/CoreEx/Entities/EntityKeyCollection.cs +++ b/src/CoreEx/Entities/EntityKeyCollection.cs @@ -43,6 +43,6 @@ public class EntityKeyCollection : List, ICompositeKeyCollection where /// Removes all items with the specified primary . /// /// The key values. - void RemoveByKey(params object?[] keys) => RemoveByKey(new CompositeKey(keys)); + public void RemoveByKey(params object?[] keys) => RemoveByKey(new CompositeKey(keys)); } } \ No newline at end of file diff --git a/src/CoreEx/Entities/Extended/EntityBaseCollection.cs b/src/CoreEx/Entities/Extended/EntityBaseCollection.cs index 4399274f..c796abe2 100644 --- a/src/CoreEx/Entities/Extended/EntityBaseCollection.cs +++ b/src/CoreEx/Entities/Extended/EntityBaseCollection.cs @@ -22,13 +22,19 @@ public abstract class EntityBaseCollection : ObservableCollectio /// /// Initializes a new instance of the class. /// - protected EntityBaseCollection() : base() { } + protected EntityBaseCollection() : base() => OnInitialization(); /// /// Initializes a new instance of the class. /// /// The collection to add. - protected EntityBaseCollection(IEnumerable collection) : base(collection) { } + protected EntityBaseCollection(IEnumerable collection) : base(collection) => OnInitialization(); + + /// + /// Provides an opportunity to extend initialization when the object is constructed. + /// + /// Added to support scenarios whether the class is defined using the likes of partial classes to provide a means to easily add functionality during the constructor process. + protected virtual void OnInitialization() { } /// /// Adds the items of the specified collection to the end of the . diff --git a/src/CoreEx/Entities/Extended/EntityBaseDictionary.cs b/src/CoreEx/Entities/Extended/EntityBaseDictionary.cs index 87f6c7af..94349b1c 100644 --- a/src/CoreEx/Entities/Extended/EntityBaseDictionary.cs +++ b/src/CoreEx/Entities/Extended/EntityBaseDictionary.cs @@ -21,13 +21,19 @@ public class EntityBaseDictionary : ObservableDictionary /// Initializes a new instance of the class using the for the comparer. /// - protected EntityBaseDictionary() : base(StringComparer.OrdinalIgnoreCase) { } + protected EntityBaseDictionary() : base(StringComparer.OrdinalIgnoreCase) => OnInitialization(); /// /// Initializes a new instance of the class using the for the comparer adding the passed . /// /// The items to add. - protected EntityBaseDictionary(IEnumerable> collection) : base(collection, StringComparer.OrdinalIgnoreCase) { } + protected EntityBaseDictionary(IEnumerable> collection) : base(collection, StringComparer.OrdinalIgnoreCase) => OnInitialization(); + + /// + /// Provides an opportunity to extend initialization when the object is constructed. + /// + /// Added to support scenarios whether the class is defined using the likes of partial classes to provide a means to easily add functionality during the constructor process. + protected virtual void OnInitialization() { } /// /// Creates a deep copy of the entity dictionary (all items will also be cloned). @@ -36,7 +42,7 @@ protected EntityBaseDictionary(IEnumerable> collec public object Clone() { var clone = new TSelf(); - this.ForEach(item => clone.Add(item.Key, (TEntity)item.Value.Clone())); + this.ForEach(item => clone.Add(item.Key, item.Value == null ? default! : item.Value.Clone())); return clone; } @@ -90,7 +96,7 @@ public override int GetHashCode() foreach (var item in this) { hash.Add(item.Key.GetHashCode()); - hash.Add(item.Value.GetHashCode()); + hash.Add(item.Value?.GetHashCode() ?? 0); } return hash.ToHashCode(); @@ -99,7 +105,7 @@ public override int GetHashCode() /// /// Performs a clean-up of the resetting item values as appropriate to ensure a basic level of data consistency. /// - public void CleanUp() => this.ForEach(item => item.Value.CleanUp()); + public void CleanUp() => this.ForEach(item => item.Value?.CleanUp()); /// /// Collections do not support an initial state; will always be false. @@ -168,7 +174,7 @@ private void Item_PropertyChanged(object? sender, PropertyChangedEventArgs e) /// This will trigger an for each item. public virtual void AcceptChanges() { - this.ForEach(item => item.Value.AcceptChanges()); + this.ForEach(item => item.Value?.AcceptChanges()); IsChanged = false; } @@ -186,7 +192,7 @@ public virtual void AcceptChanges() /// This will trigger a for each item. public void MakeReadOnly() { - this.ForEach(item => item.Value.MakeReadOnly()); + this.ForEach(item => item.Value?.MakeReadOnly()); IsChanged = false; IsReadOnly = true; } diff --git a/src/CoreEx/Entities/Extended/EntityCore.cs b/src/CoreEx/Entities/Extended/EntityCore.cs index cdf44278..14309066 100644 --- a/src/CoreEx/Entities/Extended/EntityCore.cs +++ b/src/CoreEx/Entities/Extended/EntityCore.cs @@ -11,13 +11,24 @@ namespace CoreEx.Entities.Extended /// /// Represents the core Entity capabilities including support. /// - /// The is not thread-safe; it does however, place a lock around all set operations to minimise concurrency challenges. + /// The class is not thread-safe; it does however, place a lock around all set operations to minimise concurrency challenges. [System.Diagnostics.DebuggerStepThrough] public abstract class EntityCore : INotifyPropertyChanged, IChangeTracking, IReadOnly { private readonly object _lock = new(); private Dictionary? _propertyEventHandlers; + /// + /// Initializes a new instance of the class. + /// + protected EntityCore() => OnInitialization(); + + /// + /// Provides an opportunity to extend initialization when the object is constructed. + /// + /// Added to support scenarios whether the class is defined using the likes of partial classes to provide a means to easily add functionality during the constructor process. + protected virtual void OnInitialization() { } + /// /// Occurs when a property value changes. /// @@ -56,13 +67,7 @@ private void TriggerPropertyChanged(string propertyName, params string[] propert /// /// The property . /// The property value to get. - static protected T GetAutoValue(ref T propertyValue) where T : class, new() - { - if (propertyValue == null) - propertyValue = new T(); - - return propertyValue; - } + static protected T GetAutoValue(ref T propertyValue) where T : class, new() => propertyValue ??= new T(); /// /// Sets a property value and raises the event where applicable. @@ -132,8 +137,7 @@ protected bool SetValue(ref T propertyValue, T setValue, bool immutable = fal /// private PropertyChangedEventHandler GetValue_PropertyChanged(string propertyName) { - if (_propertyEventHandlers == null) - _propertyEventHandlers = new Dictionary(); + _propertyEventHandlers ??= new Dictionary(); if (!_propertyEventHandlers.ContainsKey(propertyName)) _propertyEventHandlers.Add(propertyName, (sender, e) => TriggerPropertyChanged(propertyName)); @@ -148,17 +152,18 @@ private PropertyChangedEventHandler GetValue_PropertyChanged(string propertyName /// The value to set. /// The . /// The (defaults to ). + /// The (defaults to ). /// Indicates whether the value is immutable; can not be changed once set. /// The name of the primary property that changed. /// true indicates that the property value changed; otherwise, false. - protected bool SetValue(ref string? propertyValue, string? setValue, StringTrim trim, StringTransform transform = StringTransform.UseDefault, bool immutable = false, [CallerMemberName] string? propertyName = null) + protected bool SetValue(ref string? propertyValue, string? setValue, StringTrim trim, StringTransform transform = StringTransform.UseDefault, StringCase casing = StringCase.UseDefault, bool immutable = false, [CallerMemberName] string? propertyName = null) { if (string.IsNullOrEmpty(propertyName)) throw new ArgumentNullException(nameof(propertyName)); lock (_lock) { - string? val = Cleaner.Clean(setValue, trim, transform); + string? val = Cleaner.Clean(setValue, trim, transform, casing); if (val == propertyValue) return false; @@ -175,6 +180,34 @@ protected bool SetValue(ref string? propertyValue, string? setValue, StringTrim } } + /// + /// Sets a property value and raises the event where applicable. + /// + /// The property value to set. + /// The value to set. + /// The . + /// The (defaults to ). + /// The (defaults to ). + /// Indicates whether the value is immutable; can not be changed once set. + /// The name of the primary property that changed. + /// true indicates that the property value changed; otherwise, false. + protected bool SetValue(ref string? propertyValue, string? setValue, StringTransform transform, StringTrim trim = StringTrim.UseDefault, StringCase casing = StringCase.UseDefault, bool immutable = false, [CallerMemberName] string? propertyName = null) + => SetValue(ref propertyValue, setValue, trim, transform, casing, immutable, propertyName); + + /// + /// Sets a property value and raises the event where applicable. + /// + /// The property value to set. + /// The value to set. + /// The . + /// The (defaults to ). + /// The (defaults to ). + /// Indicates whether the value is immutable; can not be changed once set. + /// The name of the primary property that changed. + /// true indicates that the property value changed; otherwise, false. + protected bool SetValue(ref string? propertyValue, string? setValue, StringCase casing, StringTransform transform = StringTransform.UseDefault, StringTrim trim = StringTrim.UseDefault, bool immutable = false, [CallerMemberName] string? propertyName = null) + => SetValue(ref propertyValue, setValue, trim, transform, casing, immutable, propertyName); + /// /// Sets a property value and raises the event where applicable. /// @@ -245,8 +278,11 @@ protected bool SetValue(ref DateTime? propertyValue, DateTime? setValue, DateTim /// This will trigger the to perform the operation for all properties. public void AcceptChanges() { - OnAcceptChanges(); - IsChanged = false; + lock (_lock) + { + OnAcceptChanges(); + IsChanged = false; + } } /// @@ -268,9 +304,12 @@ protected virtual void OnAcceptChanges() { } /// This will trigger the to perform the operation for all properties. public void MakeReadOnly() { - OnMakeReadOnly(); - IsChanged = false; - IsReadOnly = true; + lock (_lock) + { + OnMakeReadOnly(); + IsChanged = false; + IsReadOnly = true; + } } /// diff --git a/src/CoreEx/Entities/Extended/ObservableDictionary.cs b/src/CoreEx/Entities/Extended/ObservableDictionary.cs index 0f2d8769..81a02175 100644 --- a/src/CoreEx/Entities/Extended/ObservableDictionary.cs +++ b/src/CoreEx/Entities/Extended/ObservableDictionary.cs @@ -18,6 +18,7 @@ namespace CoreEx.Entities.Extended public class ObservableDictionary : IDictionary, IDictionary, IReadOnlyDictionary, INotifyCollectionChanged, INotifyPropertyChanged where TKey : notnull { private readonly Dictionary _dict; + private Func? _keyModifier; /// /// Initializes a new instance of the with the default for the type of the key. @@ -43,6 +44,23 @@ public class ObservableDictionary : IDictionary, IDi /// The items to add. public ObservableDictionary(IEnumerable> collection, IEqualityComparer comparer) => _dict = new(collection, comparer); + /// + /// Gets or sets the function that enabled modification of the key before usage. + /// + /// Enables an opportunity to modify the key before used internally; for example, to modify so that the key value is always uppercase. + /// The by default leverages this function; however, the when overridden may chose to ignore. + public Func? KeyModifier + { + get => _keyModifier; + set + { + if (_dict.Count > 0) + throw new InvalidOperationException($"{nameof(KeyModifier)} can only be updated when there are no items contained already within the dictionary; i.e. {nameof(Count)} must be zero."); + + _keyModifier = value; + } + } + /// public int Count => _dict.Count; @@ -88,7 +106,7 @@ public class ObservableDictionary : IDictionary, IDi /// public TValue this[TKey key] { - get => _dict[key]; + get => _dict[OnModifyKey(key)]; set { @@ -138,10 +156,10 @@ public TValue this[TKey key] bool ICollection>.Contains(KeyValuePair item) => ((ICollection>)_dict).Contains(item); /// - public bool ContainsKey(TKey key) => _dict.ContainsKey(key); + public bool ContainsKey(TKey key) => _dict.ContainsKey(OnModifyKey(key)); /// - public bool TryGetValue(TKey key, [NotNullWhen(true)] out TValue value) => _dict.TryGetValue(key, out value); + public bool TryGetValue(TKey key, [NotNullWhen(true)] out TValue value) => _dict.TryGetValue(OnModifyKey(key), out value); /// public bool Remove(TKey key) => TryGetValue(key, out var value) && RemoveItem(new KeyValuePair(key, value)); @@ -174,6 +192,9 @@ protected virtual void ClearItems() /// The new replacement item. protected virtual void ReplaceItem(KeyValuePair oldItem, KeyValuePair newItem) { + oldItem = new KeyValuePair(OnModifyKey(oldItem.Key), oldItem.Value); + newItem = new KeyValuePair(OnModifyKey(newItem.Key), newItem.Value); + _dict[newItem.Key] = newItem.Value; OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, newItem, oldItem)); RaisePropertyChanged(); @@ -185,6 +206,8 @@ protected virtual void ReplaceItem(KeyValuePair oldItem, KeyValueP /// The that was added. protected virtual void AddItem(KeyValuePair item) { + item = new KeyValuePair(OnModifyKey(item.Key), item.Value); + _dict.Add(item.Key, item.Value); OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item)); RaisePropertyChanged(); @@ -197,6 +220,7 @@ protected virtual void AddItem(KeyValuePair item) /// true if the item was successfully removed from the ; otherwise, false. protected virtual bool RemoveItem(KeyValuePair item) { + item = new KeyValuePair(OnModifyKey(item.Key), item.Value); if (!_dict.Remove(item.Key)) return false; @@ -231,5 +255,13 @@ protected virtual bool RemoveItem(KeyValuePair item) /// Occurs when a property changes, either within the dictionary or to an item within. /// public event PropertyChangedEventHandler? PropertyChanged; + + /// + /// Modifies the key before usage; by default uses the . + /// + /// The key. + /// The modified key. + /// Enables an opportunity to modify the key before used internally; for example, to modify so that it is always uppercase. + protected virtual TKey OnModifyKey(TKey key) => KeyModifier is null ? key : KeyModifier(key); } } \ No newline at end of file diff --git a/src/CoreEx/Entities/IdentifierGenerator.cs b/src/CoreEx/Entities/IdentifierGenerator.cs index 6366a361..36b4eb42 100644 --- a/src/CoreEx/Entities/IdentifierGenerator.cs +++ b/src/CoreEx/Entities/IdentifierGenerator.cs @@ -25,10 +25,7 @@ public async Task AssignIdentifierAsync(TFor value) return; if (value is IIdentifier iis) - { - if (iis.Id == null) - iis.Id = await ((IIdentifierGenerator)this).GenerateIdentifierAsync().ConfigureAwait(false); - } + iis.Id ??= await ((IIdentifierGenerator)this).GenerateIdentifierAsync().ConfigureAwait(false); else if (value is IIdentifier iig) { if (iig.Id == Guid.Empty) diff --git a/src/CoreEx/Entities/StringCase.cs b/src/CoreEx/Entities/StringCase.cs new file mode 100644 index 00000000..feb2f755 --- /dev/null +++ b/src/CoreEx/Entities/StringCase.cs @@ -0,0 +1,38 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using System.Globalization; + +namespace CoreEx.Entities +{ + /// + /// Represents a casing conversion option. + /// + /// See . + public enum StringCase + { + /// + /// Indicates that the value should be used. + /// + UseDefault, + + /// + /// No casing conversion required; the value will remain as-is. + /// + None, + + /// + /// The string value will be converted to lower case (see ). + /// + Lower, + + /// + /// The string value will be converted to upper case (see ). + /// + Upper, + + /// + /// The string value will be converted to title case (see ). + /// + Title + } +} \ No newline at end of file diff --git a/src/CoreEx/Globalization/TexInfoExtensions.cs b/src/CoreEx/Globalization/TexInfoExtensions.cs index 79d7a7dc..7206b86e 100644 --- a/src/CoreEx/Globalization/TexInfoExtensions.cs +++ b/src/CoreEx/Globalization/TexInfoExtensions.cs @@ -20,6 +20,7 @@ public static class TexInfoExtensions { TextInfoCasing.Lower => text == null ? null : textInfo.ToLower(text), TextInfoCasing.Upper => text == null ? null : textInfo.ToUpper(text), + TextInfoCasing.Title => text == null ? null : textInfo.ToTitleCase(text), _ => text }; } diff --git a/src/CoreEx/Globalization/TextInfoCasing.cs b/src/CoreEx/Globalization/TextInfoCasing.cs index f01d79a1..95744b72 100644 --- a/src/CoreEx/Globalization/TextInfoCasing.cs +++ b/src/CoreEx/Globalization/TextInfoCasing.cs @@ -10,7 +10,7 @@ namespace CoreEx.Globalization public enum TextInfoCasing { /// - /// No text casing is to be applied. + /// No text casing is to be applied; leave as-is. /// None, @@ -22,6 +22,11 @@ public enum TextInfoCasing /// /// Use . /// - Upper + Upper, + + /// + /// Use . + /// + Title } } \ No newline at end of file diff --git a/src/CoreEx/Http/TypedHttpClientBaseT.cs b/src/CoreEx/Http/TypedHttpClientBaseT.cs index 988e960b..783ace53 100644 --- a/src/CoreEx/Http/TypedHttpClientBaseT.cs +++ b/src/CoreEx/Http/TypedHttpClientBaseT.cs @@ -189,6 +189,14 @@ public TSelf EnsureSuccess() /// Will result in a where condition is not met. public TSelf EnsureCreated() => Ensure(HttpStatusCode.Created); + /// + /// Adds the to the accepted list to be verified against the resulting . + /// + /// This instance to support fluent-style method-chaining. + /// This references the equivalent method within the . This is after each invocation; see . + /// Will result in a where condition is not met. + public TSelf EnsureNotFound() => Ensure(HttpStatusCode.NotFound); + /// /// Adds the to the accepted list to be verified against the resulting . /// @@ -326,8 +334,7 @@ protected override async Task SendAsync(HttpRequestMessage { return await Client.SendAsync(req, SetCancellationBasedOnTimeout(cancellationToken, out cts)).ConfigureAwait(false); } - catch (OperationCanceledException) - when (!cancellationToken.IsCancellationRequested) + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { throw new TimeoutException(); } @@ -337,7 +344,7 @@ protected override async Task SendAsync(HttpRequestMessage } catch (Exception ex) when (ex is TimeoutException || ex is SocketException) { - // both TimeoutException and SocketException are transient and indicate a connection was terminated + // Both TimeoutException and SocketException are transient and indicate a connection was terminated. throw new TransientException("Timeout when calling service", ex); } catch (HttpRequestException hrex) diff --git a/src/CoreEx/Json/Merge/DictionaryMergeApproach.cs b/src/CoreEx/Json/Merge/DictionaryMergeApproach.cs index c42a49d0..bad47ec3 100644 --- a/src/CoreEx/Json/Merge/DictionaryMergeApproach.cs +++ b/src/CoreEx/Json/Merge/DictionaryMergeApproach.cs @@ -20,7 +20,7 @@ public enum DictionaryMergeApproach Merge, /// - /// Indicates that the dictionary (see ) merge will be treated the same as any where the result is a replacement (overwrie) operation. + /// Indicates that the dictionary (see ) merge will be treated the same as any where the result is a replacement (overwrite) operation. /// Replace } diff --git a/src/CoreEx/Mapping/CollectionMapper.cs b/src/CoreEx/Mapping/CollectionMapper.cs index 5fe3c12e..8b487256 100644 --- a/src/CoreEx/Mapping/CollectionMapper.cs +++ b/src/CoreEx/Mapping/CollectionMapper.cs @@ -63,9 +63,7 @@ public class CollectionMapper /// The source . /// The destination . - public struct ValueConverter : IValueConverter + public readonly struct ValueConverter : IValueConverter { private readonly Func _converter; diff --git a/src/CoreEx/RefData/ReferenceDataCollection.cs b/src/CoreEx/RefData/ReferenceDataCollection.cs index ea82f955..06712518 100644 --- a/src/CoreEx/RefData/ReferenceDataCollection.cs +++ b/src/CoreEx/RefData/ReferenceDataCollection.cs @@ -30,8 +30,15 @@ public ReferenceDataCollection(ReferenceDataSortOrder sortOrder = ReferenceDataS { SortOrder = sortOrder; _rdcCode = new ConcurrentDictionary(codeComparer ?? StringComparer.OrdinalIgnoreCase); + OnInitialization(); } + /// + /// Provides an opportunity to extend initialization when the object is constructed. + /// + /// Added to support scenarios whether the class is defined using the likes of partial classes to provide a means to easily add functionality during the constructor process. + protected virtual void OnInitialization() { } + /// /// Gets or sets the used by . /// diff --git a/src/CoreEx/Text/Json/JsonPreFilterInspector.cs b/src/CoreEx/Text/Json/JsonPreFilterInspector.cs index d8d138b4..ab48bc1e 100644 --- a/src/CoreEx/Text/Json/JsonPreFilterInspector.cs +++ b/src/CoreEx/Text/Json/JsonPreFilterInspector.cs @@ -8,7 +8,7 @@ namespace CoreEx.Text.Json /// /// Provides pre (prior) to filtering JSON inspection. /// - public struct JsonPreFilterInspector : IJsonPreFilterInspector + public readonly struct JsonPreFilterInspector : IJsonPreFilterInspector { /// /// Initializes a new instance of the struct. diff --git a/tests/CoreEx.Test/Framework/Entities/Extended/EntityCoreTest.cs b/tests/CoreEx.Test/Framework/Entities/Extended/EntityCoreTest.cs index b728c809..4122ccc5 100644 --- a/tests/CoreEx.Test/Framework/Entities/Extended/EntityCoreTest.cs +++ b/tests/CoreEx.Test/Framework/Entities/Extended/EntityCoreTest.cs @@ -11,13 +11,14 @@ public class EntityCoreTest [Test] public void SettingAndGetting() { - var ta = new TestA { Id = 88, Code = " A ", Text = " B ", DateOnly = new DateTime(2000, 01, 01, 12, 59, 59), DateTime = new DateTime(2000, 01, 01, 12, 59, 59) }; + var ta = new TestA { Id = 88, Code = " A ", Text = " B ", DateOnly = new DateTime(2000, 01, 01, 12, 59, 59), DateTime = new DateTime(2000, 01, 01, 12, 59, 59), Description = "the AB code." }; Assert.AreEqual(88, ta.Id); - Assert.AreEqual("A", ta.Code); + Assert.AreEqual("a", ta.Code); Assert.AreEqual(" B", ta.Text); Assert.AreEqual(new DateTime(2000, 01, 01), ta.DateOnly); Assert.AreEqual(new DateTime(2000, 01, 01, 12, 59, 59, DateTimeKind.Utc), ta.DateTime); Assert.IsTrue(ta.IsChanged); + Assert.AreEqual("The AB Code.", ta.Description); ta.AcceptChanges(); Assert.IsFalse(ta.IsChanged); @@ -25,10 +26,12 @@ public void SettingAndGetting() ta.Code = null; ta.Text = null; ta.DateTime = null; + ta.Description = null; Assert.IsEmpty(ta.Code); Assert.IsNull(ta.Text); Assert.IsNull(ta.DateTime); Assert.IsTrue(ta.IsChanged); + Assert.IsNull(ta.Description); } private class TestA : EntityCore @@ -38,6 +41,7 @@ private class TestA : EntityCore private string? _text; private DateTime _dateOnly; private DateTime? _dateTime; + private string? _desc; public long Id { @@ -48,7 +52,7 @@ public long Id public string? Code { get { return _code; } - set { SetValue(ref _code, value, StringTrim.Both, StringTransform.NullToEmpty); } + set { SetValue(ref _code, value, StringTrim.Both, StringTransform.NullToEmpty, StringCase.Lower); } } public string? Text @@ -68,6 +72,12 @@ public DateTime? DateTime get { return _dateTime; } set { SetValue(ref _dateTime, value); } } + + public string? Description + { + get { return _desc; } + set { SetValue(ref _desc, value, casing: StringCase.Title); } + } } } } \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Entities/Extended/ObservableDictionaryTest.cs b/tests/CoreEx.Test/Framework/Entities/Extended/ObservableDictionaryTest.cs index c1d4daa5..4109e4ba 100644 --- a/tests/CoreEx.Test/Framework/Entities/Extended/ObservableDictionaryTest.cs +++ b/tests/CoreEx.Test/Framework/Entities/Extended/ObservableDictionaryTest.cs @@ -1,5 +1,6 @@ using CoreEx.Entities.Extended; using NUnit.Framework; +using System; using System.Collections.Generic; using System.Collections.Specialized; @@ -8,17 +9,19 @@ namespace CoreEx.Test.Framework.Entities.Extended [TestFixture] public class ObservableDictionaryTest { + private static readonly Func _keyModifier = k => k.ToUpperInvariant(); + [Test] public void ObserveAdd() { - var od = new ObservableDictionary(); + var od = new ObservableDictionary { KeyModifier = _keyModifier }; var i = 0; var j = 0; od.CollectionChanged += (_, e) => { Assert.AreEqual(NotifyCollectionChangedAction.Add, e.Action); Assert.AreEqual(1, e.NewItems.Count); - Assert.AreEqual(new KeyValuePair("a", 88), e.NewItems[0]); + Assert.AreEqual(new KeyValuePair("A", 88), e.NewItems[0]); Assert.IsNull(e.OldItems); i++; }; @@ -39,14 +42,14 @@ public void ObserveAdd() [Test] public void ObserveIndexerAdd() { - var od = new ObservableDictionary(); + var od = new ObservableDictionary() { KeyModifier = _keyModifier }; var i = 0; var j = 0; od.CollectionChanged += (_, e) => { Assert.AreEqual(NotifyCollectionChangedAction.Add, e.Action); Assert.AreEqual(1, e.NewItems.Count); - Assert.AreEqual(new KeyValuePair("a", 88), e.NewItems[0]); + Assert.AreEqual(new KeyValuePair("A", 88), e.NewItems[0]); Assert.IsNull(e.OldItems); i++; }; @@ -67,10 +70,8 @@ public void ObserveIndexerAdd() [Test] public void ObserveIndexerReplace() { - var od = new ObservableDictionary - { - { "a", 99 } - }; + var od = new ObservableDictionary { KeyModifier = _keyModifier }; + od.Add("a", 99); var i = 0; var j = 0; @@ -78,9 +79,9 @@ public void ObserveIndexerReplace() { Assert.AreEqual(NotifyCollectionChangedAction.Replace, e.Action); Assert.AreEqual(1, e.OldItems.Count); - Assert.AreEqual(new KeyValuePair("a", 99), e.OldItems[0]); + Assert.AreEqual(new KeyValuePair("A", 99), e.OldItems[0]); Assert.AreEqual(1, e.NewItems.Count); - Assert.AreEqual(new KeyValuePair("a", 88), e.NewItems[0]); + Assert.AreEqual(new KeyValuePair("A", 88), e.NewItems[0]); i++; }; od.PropertyChanged += (_, e) => @@ -100,10 +101,8 @@ public void ObserveIndexerReplace() [Test] public void ObserveRemove() { - var od = new ObservableDictionary - { - { "a", 99 } - }; + var od = new ObservableDictionary { KeyModifier = _keyModifier }; + od.Add("a", 99); var i = 0; var j = 0; @@ -111,7 +110,7 @@ public void ObserveRemove() { Assert.AreEqual(NotifyCollectionChangedAction.Remove, e.Action); Assert.AreEqual(1, e.OldItems.Count); - Assert.AreEqual(new KeyValuePair("a", 99), e.OldItems[0]); + Assert.AreEqual(new KeyValuePair("A", 99), e.OldItems[0]); Assert.IsNull(e.NewItems); i++; }; @@ -132,11 +131,9 @@ public void ObserveRemove() [Test] public void ObserveClear() { - var od = new ObservableDictionary - { - { "a", 99 }, - { "b", 98 } - }; + var od = new ObservableDictionary { KeyModifier = _keyModifier }; + od.Add("a", 99); + od.Add("b", 98); var i = 0; var j = 0; @@ -146,8 +143,8 @@ public void ObserveClear() { Assert.AreEqual(NotifyCollectionChangedAction.Remove, e.Action); Assert.AreEqual(2, e.OldItems.Count); - Assert.AreEqual(new KeyValuePair("a", 99), e.OldItems[0]); - Assert.AreEqual(new KeyValuePair("b", 98), e.OldItems[1]); + Assert.AreEqual(new KeyValuePair("A", 99), e.OldItems[0]); + Assert.AreEqual(new KeyValuePair("B", 98), e.OldItems[1]); Assert.IsNull(e.NewItems); } else diff --git a/tests/CoreEx.Test/Framework/Globalization/TextInfoTest.cs b/tests/CoreEx.Test/Framework/Globalization/TextInfoTest.cs index ada3667a..6fbe871b 100644 --- a/tests/CoreEx.Test/Framework/Globalization/TextInfoTest.cs +++ b/tests/CoreEx.Test/Framework/Globalization/TextInfoTest.cs @@ -15,5 +15,8 @@ public class TextInfoTest [Test] public void ToCasing_Upper() => Assert.AreEqual("ABCD", CultureInfo.InvariantCulture.TextInfo.ToCasing("AbCd", TextInfoCasing.Upper)); + + [Test] + public void ToCasing_Title() => Assert.AreEqual("The Quick BROWN Fox.", CultureInfo.InvariantCulture.TextInfo.ToCasing("the qUick BROWN fox.", TextInfoCasing.Title)); } } \ No newline at end of file