Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1200,6 +1200,11 @@ var dto = mapper.MapWithConvention<ApiResponse, UserDto>(
NamingConvention.PascalCase);
```

The convention pass skips a member the mapper binds, or the configuration ignores or resolves with
`MapFrom`. Any other member is filled while it still holds the value a new `UserDto` starts with, so
what an `AfterMap` set is kept. A member initialised to a new instance, such as `Tags = new()`, is
always filled, because every destination starts with a different instance.

### Check Name Matching

```csharp
Expand All @@ -1210,6 +1215,10 @@ bool match = NamingConvention.NamesMatch(
// Result: true
```

Matching compares the letters and digits and ignores case and where the words break, so `user_id`
matches `UserID` and `address_line_1` matches `AddressLine1`. When a name is converted, an acronym
stays one word: `HTTPServerID` becomes `http_server_id`.

### Real-World Example: External API Integration

```csharp
Expand Down
11 changes: 11 additions & 0 deletions changelog.d/naming-conventions-word-splitting.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
- `Mapsicle.NamingConventions`: an acronym is one word, so `HTTPServerID` converts to
`http_server_id` instead of `h_t_t_p_server_i_d`, and `user_id` fills `UserID`.
- `Mapsicle.NamingConventions`: names match on their letters and digits wherever the words break,
so `address_line_1` fills `AddressLine1`. Non-ASCII letters are part of a word, so `straße_name`
fills `StraßeName`.
- `Mapsicle.NamingConventions`: the `IMapper` overload of `MapWithConvention` fills a member that
still holds its initial value. It used to test for `default(T)`, so a `string` initialised to
`""`, a `bool` initialised to `true` or a list initialised to `new()` was never filled.
- `Mapsicle.NamingConventions`: the same overload leaves a member the mapper binds or a `MapFrom`
resolves. A `MapFrom` that returned the default value used to be overwritten by the convention
match.
56 changes: 26 additions & 30 deletions src/Mapsicle.NamingConventions/NamingConvention.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@ namespace Mapsicle.NamingConventions
/// </summary>
public abstract class NamingConvention
{
// Compiled regex for better performance - splits on word boundaries
// Matches sequences like "User", "Name", "ID", "XMLParser", etc.
// The capitalised-word alternative used to come first and accept a lone capital, so the
// acronym alternative never ran and "HTTPServerID" split into H, T, T, P, Server, I, D. The
// classes were ASCII only, so "StraßeName" lost its ß and split around the gap.
/// <summary>Splits an identifier into words at case changes and separators.</summary>
protected static readonly Regex WordBoundaryRegex = new(
@"([A-Z][a-z0-9]*|[A-Z]+(?=[A-Z][a-z]|$)|[a-z0-9]+)",
@"[\p{Lu}\p{Lt}]+(?>\p{Nd}*)(?![\p{Ll}\p{Lo}\p{Lm}\p{M}])"
+ @"|[\p{Lu}\p{Lt}][\p{Ll}\p{Lo}\p{Lm}\p{M}\p{Nd}]*"
+ @"|[\p{Ll}\p{Lo}\p{Lm}\p{M}\p{Nd}]+",
RegexOptions.Compiled);

/// <summary>
Expand Down Expand Up @@ -57,6 +60,17 @@ public abstract class NamingConvention
/// </summary>
public abstract string FromWords(string[] words);

internal static string[] SplitAtCaseChanges(string name)
{
var matches = WordBoundaryRegex.Matches(name);
var words = new string[matches.Count];
for (int i = 0; i < matches.Count; i++)
{
words[i] = matches[i].Value;
}
return words;
}

/// <summary>
/// Converts a name from one convention to another.
/// </summary>
Expand All @@ -76,17 +90,13 @@ public static bool NamesMatch(string sourceName, NamingConvention sourceConventi
if (string.IsNullOrEmpty(sourceName) || string.IsNullOrEmpty(destName))
return false;

var sourceWords = sourceConvention.ToWords(sourceName);
var destWords = destConvention.ToWords(destName);

if (sourceWords.Length != destWords.Length) return false;

for (int i = 0; i < sourceWords.Length; i++)
{
if (!string.Equals(sourceWords[i], destWords[i], StringComparison.OrdinalIgnoreCase))
return false;
}
return true;
// Compared word by word, this refused address_line_1 against AddressLine1, because one
// side has three words and the other two. Where a word ends is a property of the
// convention, not of the name, so only the letters are compared.
return string.Equals(
string.Concat(sourceConvention.ToWords(sourceName)),
string.Concat(destConvention.ToWords(destName)),
StringComparison.OrdinalIgnoreCase);
}
}

Expand All @@ -96,14 +106,7 @@ internal class PascalCaseConvention : NamingConvention

public override string[] ToWords(string name)
{
// Use compiled regex for better performance
var matches = WordBoundaryRegex.Matches(name);
var words = new string[matches.Count];
for (int i = 0; i < matches.Count; i++)
{
words[i] = matches[i].Value;
}
return words;
return SplitAtCaseChanges(name);
}

public override string FromWords(string[] words)
Expand All @@ -126,14 +129,7 @@ internal class CamelCaseConvention : NamingConvention

public override string[] ToWords(string name)
{
// Use compiled regex for better performance
var matches = WordBoundaryRegex.Matches(name);
var words = new string[matches.Count];
for (int i = 0; i < matches.Count; i++)
{
words[i] = matches[i].Value;
}
return words;
return SplitAtCaseChanges(name);
}

public override string FromWords(string[] words)
Expand Down
55 changes: 45 additions & 10 deletions src/Mapsicle.NamingConventions/NamingConventionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ namespace Mapsicle.NamingConventions
public static class NamingConventionExtensions
{
private static readonly ConcurrentDictionary<(Type, Type, string, string), Dictionary<string, string>> _propertyMappingCache = new();
private static readonly ConcurrentDictionary<(Type, Type), HashSet<string>> _boundMemberCache = new();
private static readonly ConcurrentDictionary<Type, Dictionary<string, object?>> _initialValueCache = new();

/// <summary>
/// Creates a mapper that applies naming conventions when matching properties.
Expand Down Expand Up @@ -100,26 +102,29 @@ public static class NamingConventionExtensions
var sourceType = typeof(TSource);
var destType = typeof(TDest);

// A member the configuration ignores is left at its default by the mapper, which is
// exactly what this pass reads as "not mapped yet", so it used to fill it anyway.
// "Not mapped yet" used to mean equal to default(T) and nothing else. A string initialised
// to "" or a list initialised to a new instance was therefore never filled, and a member
// the configuration ignored or resolved to its default was filled over. The mapper and
// its configuration are asked first, and the value only decides what neither accounts for.
var typeMap = (mapper as FluentMapper)?.Configuration.GetTypeMap(sourceType, destType);
var bound = _boundMemberCache.GetOrAdd(
(source.GetType(), dest.GetType()),
pair => new HashSet<string>(Mapper.GetBoundMembers(pair.Item1, pair.Item2).Keys, StringComparer.OrdinalIgnoreCase));
var initialValues = _initialValueCache.GetOrAdd(destType, _ => ReadInitialValues(new TDest()));
Comment thread
arnelirobles marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

foreach (var mapping in propertyMappings)
{
if (typeMap?.IsIgnored(mapping.Value) == true) continue;
if (bound.Contains(mapping.Value)) continue;
if (typeMap?.IsIgnored(mapping.Value) == true || typeMap?.HasCustomMapping(mapping.Value) == true) continue;

var sourceProp = sourceType.GetProperty(mapping.Key);
var destProp = destType.GetProperty(mapping.Value);

if (sourceProp?.GetGetMethod() != null && destProp?.CanWrite == true)
{
// Only set if dest property is default/null (wasn't mapped by standard mapper)
var currentValue = destProp.GetValue(dest);
var defaultValue = destProp.PropertyType.IsValueType
? Activator.CreateInstance(destProp.PropertyType)
: null;
initialValues.TryGetValue(destProp.Name, out var initialValue);

if (Equals(currentValue, defaultValue))
if (StillUnset(destProp.GetValue(dest), initialValue))
{
try
{
Expand Down Expand Up @@ -204,7 +209,37 @@ public static string ConvertName(this string name, NamingConvention from, Naming
/// <summary>
/// Clears the property mapping cache. Useful for testing scenarios.
/// </summary>
public static void ClearMappingCache() => _propertyMappingCache.Clear();
public static void ClearMappingCache()
{
_propertyMappingCache.Clear();
_boundMemberCache.Clear();
_initialValueCache.Clear();
}

// A hook such as AfterMap can set a member nothing else accounts for, and a value that moved
// off its initial one is the only sign of it. An initializer that builds a new instance
// gives every destination a different reference, so there the comparison says nothing and
// the member is filled.
private static bool StillUnset(object? current, object? initial) =>
Equals(current, initial) || initial is not (null or string or ValueType);

private static Dictionary<string, object?> ReadInitialValues(object fresh)
{
var values = new Dictionary<string, object?>();
foreach (var prop in fresh.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (prop.GetGetMethod() == null || prop.GetIndexParameters().Length > 0) continue;
try
{
values[prop.Name] = prop.GetValue(fresh);
}
catch (Exception)
{
values[prop.Name] = null;
}
}
return values;
}

private static object? ConvertValue(object value, Type targetType)
{
Expand Down
1 change: 1 addition & 0 deletions src/Mapsicle/Mapsicle.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<InternalsVisibleTo Include="Mapsicle.Fluent" />
<InternalsVisibleTo Include="Mapsicle.EntityFramework" />
<InternalsVisibleTo Include="Mapsicle.Audit" />
<InternalsVisibleTo Include="Mapsicle.NamingConventions" />
<InternalsVisibleTo Include="Mapsicle.SourceGen.Tests" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ public class NamingConventionTests
[Theory]
[InlineData("UserName", new[] { "User", "Name" })]
[InlineData("FirstName", new[] { "First", "Name" })]
[InlineData("ID", new[] { "I", "D" })]
[InlineData("XMLParser", new[] { "X", "M", "L", "Parser" })]
[InlineData("ID", new[] { "ID" })]
[InlineData("XMLParser", new[] { "XML", "Parser" })]
[InlineData("userId", new[] { "user", "Id" })]
public void PascalCase_ToWords_SplitsCorrectly(string input, string[] expected)
{
Expand Down
Loading
Loading