Skip to content
Closed
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,16 @@ FastCloner.FastCloner.ClearAllTypeBehaviors(); // Reset all

> **Note**: Changing runtime behavior invalidates the cache. Try to configure once at startup, or use compile-time attributes when possible.

##### Registering external ignore attributes

If your models already carry framework-level ignore attributes (e.g. `[JsonIgnore]`, `[BsonIgnore]`, `[NotMapped]`), you can register them globally so FastCloner skips those members without adding `[FastClonerIgnore]`:

```csharp
FastCloner.FastCloner.RegisterIgnoreAttribute<JsonIgnoreAttribute>();
```

Registered attributes are matched by **presence only** (conditional constructors like `JsonIgnore(Condition = ...)` are not evaluated). Precedence: member-level `[FastClonerIgnore]` > registered external attributes > default. This feature works in **reflection mode only** — source-generated cloners cannot observe runtime registration.

#### Precedence (highest to lowest)

1. Runtime `SetTypeBehavior<T>()`
Expand Down
230 changes: 230 additions & 0 deletions src/FastCloner.Tests/ExternalIgnoreAttributeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
using System;
using FastCloner.Code;

namespace FastCloner.Tests;

/// <summary>
/// Tests for the externally-registered ignore-attribute feature
/// (FastCloner.RegisterIgnoreAttribute).
/// </summary>
[NotInParallel]
public class ExternalIgnoreAttributeTests
{
#region Test attributes & classes

/// <summary>
/// Stands in for framework attributes such as JsonIgnoreAttribute / BsonIgnoreAttribute / NotMappedAttribute.
/// </summary>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property | AttributeTargets.Event)]
private sealed class MyExternalIgnoreAttribute : Attribute
{
}

/// <summary>
/// Stands in for a second, independent framework attribute.
/// </summary>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property | AttributeTargets.Event)]
private sealed class MyOtherIgnoreAttribute : Attribute
{
}

private class DtoWithExternalIgnore
{
public string Name { get; set; } = "";

[MyExternalIgnore]
public string Secret { get; set; } = "";

public InnerPayload Payload { get; set; } = null!;
}

private class DtoWithBothAttributes
{
[FastClonerIgnore]
[MyExternalIgnore]
public string Token { get; set; } = "";
}

private class DtoWithExternalIgnoreOnField
{
public string Name { get; set; } = "";

[MyExternalIgnore]
public string Secret = "";
}

private class InnerPayload
{
public string Value { get; set; } = "";
}

#endregion

[Test]
public async Task WithoutRegistration_ExternalIgnoreAttributeIsNotIgnored()
{
// Arrange
DtoWithExternalIgnore original = MakeDto();

// Act
DtoWithExternalIgnore clone = original.DeepClone();

// Assert - nothing registered, so [MyExternalIgnore] is treated like any other member
await Assert.That(clone.Secret).IsEqualTo("top-secret");
await Assert.That(clone.Payload).IsNotSameReferenceAs(original.Payload);
await Assert.That(clone.Payload.Value).IsEqualTo("v");
}

[Test]
public async Task AfterRegister_RegisteredAttributeCausesIgnore()
{
// Arrange
DtoWithExternalIgnore original = MakeDto();
FastCloner.RegisterIgnoreAttribute<MyExternalIgnoreAttribute>();

try
{
// Act
DtoWithExternalIgnore clone = original.DeepClone();

// Assert - member decorated with the registered attribute is skipped (default / null)
await Assert.That(clone.Secret).IsEqualTo(default(string));
await Assert.That(clone.Payload).IsNotSameReferenceAs(original.Payload);
}
finally
{
FastCloner.ResetIgnoreAttributes();
}
}

[Test]
public async Task FastClonerIgnoreTakesPrecedenceOverRegisteredAttribute()
{
// Arrange
DtoWithBothAttributes original = new DtoWithBothAttributes { Token = "abc" };
FastCloner.RegisterIgnoreAttribute<MyExternalIgnoreAttribute>();

try
{
// Act
DtoWithBothAttributes clone = original.DeepClone();

// Assert - member is ignored. Precedence is guaranteed by the implementation checking
// FastClonerBehaviorAttribute before any registered external attribute.
await Assert.That(clone.Token).IsEqualTo(default(string));
}
finally
{
FastCloner.ResetIgnoreAttributes();
}
}

[Test]
public async Task MultipleRegisteredAttributes_AllHonored()
{
// Arrange
DtoWithExternalIgnore original = MakeDto();
FastCloner.RegisterIgnoreAttribute<MyExternalIgnoreAttribute>();
FastCloner.RegisterIgnoreAttribute<MyOtherIgnoreAttribute>();

try
{
// Act - both attributes registered; a member marked with either should be ignored
DtoWithExternalIgnore clone = original.DeepClone();
await Assert.That(clone.Secret).IsEqualTo(default(string));
}
finally
{
FastCloner.ResetIgnoreAttributes();
}
}

[Test]
public async Task FieldWithRegisteredExternalAttribute_IsIgnored()
{
// Arrange - the registered attribute is applied to a field, not a property
DtoWithExternalIgnoreOnField original = new DtoWithExternalIgnoreOnField { Name = "n", Secret = "top-secret" };
FastCloner.RegisterIgnoreAttribute<MyExternalIgnoreAttribute>();

try
{
// Act
DtoWithExternalIgnoreOnField clone = original.DeepClone();

// Assert - fields are also recognized (the API promises field/property/event support)
await Assert.That(clone.Secret).IsEqualTo(default(string));
await Assert.That(clone.Name).IsEqualTo("n");
}
finally
{
FastCloner.ResetIgnoreAttributes();
}
}

[Test]
public async Task DisableOptionalFeatures_GatesExternalIgnore()
{
// Arrange
DtoWithExternalIgnore original = MakeDto();
FastCloner.RegisterIgnoreAttribute<MyExternalIgnoreAttribute>();
FastCloner.SetDisableOptionalFeatures(true);

try
{
// Act
DtoWithExternalIgnore clone = original.DeepClone();

// Assert - with optional features disabled, even registered ignore attributes are not honored
await Assert.That(clone.Secret).IsEqualTo("top-secret");
}
finally
{
FastCloner.SetDisableOptionalFeatures(false);
FastCloner.ResetIgnoreAttributes();
}
}

[Test]
public async Task RegisterIgnoreAttribute_NonAttributeType_Throws()
{
await Assert.That(() => FastCloner.RegisterIgnoreAttribute(typeof(string)))
.Throws<ArgumentException>();
}

[Test]
public async Task RegisterIgnoreAttribute_Null_Throws()
{
await Assert.That(() => FastCloner.RegisterIgnoreAttribute(null!))
.Throws<ArgumentNullException>();
}

[Test]
public async Task ConcurrentRegistration_IsThreadSafe()
{
// Arrange
DtoWithExternalIgnore original = MakeDto();

// Act - hammer registration from many threads; no exceptions expected.
System.Threading.Tasks.Parallel.For(0, 50, _ =>
{
FastCloner.RegisterIgnoreAttribute<MyExternalIgnoreAttribute>();
});

try
{
DtoWithExternalIgnore clone = original.DeepClone();
await Assert.That(clone.Secret).IsEqualTo(default(string));
}
finally
{
FastCloner.ResetIgnoreAttributes();
}
}

private static DtoWithExternalIgnore MakeDto() => new DtoWithExternalIgnore
{
Name = "n",
Secret = "top-secret",
Payload = new InnerPayload { Value = "v" }
};
}
4 changes: 4 additions & 0 deletions src/FastCloner/Code/FastClonerCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ internal sealed class TypeShape
internal static volatile bool HasTypeBehaviorOverrides;
internal static volatile bool HasActiveTypeBehaviorOverrides;

// Registered external "ignore" attributes (e.g. JsonIgnore, BsonIgnore, NotMapped).
// When present on a member, the member is treated like [FastClonerIgnore].
internal static readonly ConcurrentDictionary<Type, byte> ExternalIgnoreAttributes = [];

internal static bool IsTypeIgnored(Type type)
{
return HasActiveTypeBehaviorOverrides &&
Expand Down
8 changes: 8 additions & 0 deletions src/FastCloner/Code/FastClonerExprGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ internal static bool MemberShouldCopyReference(MemberInfo memberInfo)
if (behaviorAttr is not null)
return behaviorAttr.Behavior;

// 1.5 Check registered external ignore attributes (e.g. JsonIgnore, BsonIgnore, NotMapped).
// A member carrying any of these is treated exactly like [FastClonerIgnore].
foreach (Type ignoreAttr in FastClonerCache.ExternalIgnoreAttributes.Keys)
{
if (mi.GetCustomAttribute(ignoreAttr) is not null)
return CloneBehavior.Ignore;
}

// 2. Check [NonSerialized] (treat as Ignore)
NonSerializedAttribute? nonSerialized = mi.GetCustomAttribute<NonSerializedAttribute>();
if (nonSerialized is not null)
Expand Down
50 changes: 50 additions & 0 deletions src/FastCloner/FastCloner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,56 @@ public static void ClearAllTypeBehaviors()
}
}

#region External ignore attributes

/// <summary>
/// Registers an external attribute type as an "ignore" signal. Any member (field, property, or event)
/// decorated with this attribute will be skipped during cloning (set to its default value), exactly like
/// <see cref="FastClonerIgnoreAttribute"/>.
/// </summary>
/// <typeparam name="T">An attribute type such as JsonIgnoreAttribute, BsonIgnoreAttribute, NotMappedAttribute, etc.</typeparam>
/// <remarks>
/// This is matched on attribute <em>presence</em> only. Attributes with conditional constructors
/// (e.g. <c>JsonIgnore(Condition = ...)</c>) are not evaluated; the member is always ignored while registered.
/// Works in reflection mode. Source-generated cloners cannot observe runtime registration and are unaffected.
/// Registering the same attribute type multiple times is idempotent.
/// </remarks>
public static void RegisterIgnoreAttribute<T>() where T : Attribute
=> RegisterIgnoreAttribute(typeof(T));

/// <summary>
/// Registers an external attribute type as an "ignore" signal. See <see cref="RegisterIgnoreAttribute{T}"/>.
/// </summary>
/// <param name="attributeType">The attribute type to recognize as an ignore signal. Must derive from <see cref="Attribute"/>.</param>
public static void RegisterIgnoreAttribute(Type attributeType)
{
if (attributeType is null)
throw new ArgumentNullException(nameof(attributeType));
if (!typeof(Attribute).IsAssignableFrom(attributeType))
throw new ArgumentException($"'{attributeType}' is not an Attribute type.", nameof(attributeType));

lock (configSync)
{
FastClonerCache.ExternalIgnoreAttributes[attributeType] = 0;
FastClonerCache.ClearCache();
}
}

/// <summary>
/// Clears all registered external ignore attribute types. Internal helper used by tests to reset global state;
/// not part of the public API.
/// </summary>
internal static void ResetIgnoreAttributes()
{
lock (configSync)
{
FastClonerCache.ExternalIgnoreAttributes.Clear();
FastClonerCache.ClearCache();
}
}

#endregion

private static void PublishEngine(FastClonerPublishedEngine engine)
{
maxRecursionDepth = engine.RuntimeConfig.MaxRecursionDepth;
Expand Down
Loading