Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
49b307c
test(04-01): add failing tests for List<T>-family conversion
guy-lud Jul 15, 2026
342f827
feat(04-01): add List<T>-family converter with disjoint shape predicates
guy-lud Jul 15, 2026
64a1e0b
test(04-01): add failing tests for empty-not-null collection defaults
guy-lud Jul 15, 2026
648f4f5
feat(04-01): empty-not-null default for every collection shape
guy-lud Jul 15, 2026
d719213
test(04-02): add failing child-sequence bind tests for ConfigurationB…
guy-lud Jul 15, 2026
7433bc4
feat(04-02): bind collections from IConfiguration child-section seque…
guy-lud Jul 15, 2026
0497ab9
test(04-02): D-06 secret redaction on the child-sequence bind path
guy-lud Jul 15, 2026
944f049
feat(04-03): sync validation contracts, context ctors, validator attr…
guy-lud Jul 15, 2026
180317d
test(04-03): add failing tests for core-path settings validation
guy-lud Jul 15, 2026
f99d604
feat(04-03): run declared validators in the core populate path
guy-lud Jul 15, 2026
b1156bd
test(04-05): add failing AllowEmpty=false empty/whitespace rejection …
guy-lud Jul 15, 2026
3f24f91
feat(04-05): reject empty/whitespace strings for AllowEmpty=false (VA…
guy-lud Jul 15, 2026
cc0d3f2
fix(04-02): single-pass child-sequence bind — reload-safe, drops empt…
guy-lud Jul 15, 2026
779f0fc
refactor(04-05): check value==null once in PropertyConversion.Convert
guy-lud Jul 15, 2026
c393202
refactor(04-03): dispatch validators via ISettingValidation<T> DIM br…
guy-lud Jul 15, 2026
53ee4a8
test(04-02): lock child-sequence element order + cover empty-element …
guy-lud Jul 15, 2026
d4ebe41
chore(04): strip internal review-ID tokens from code comments
guy-lud Jul 15, 2026
b049f8d
refactor(04): fold object validator onto [SettingsSection].ValidatorT…
guy-lud Jul 15, 2026
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
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using System.Collections.Concurrent;
using ExistForAll.SimpleSettings.Core.Reflection;
using Microsoft.Extensions.Configuration;

namespace ExistForAll.SimpleSettings.Binders
Expand Down Expand Up @@ -29,12 +30,50 @@ public void BindPropertySettings(BindingContext context)
// capturing lambda would build a fresh delegate on every call and eat most of the win.
var section = _sections.GetOrAdd(context.Section, static (name, self) => self.ResolveSection(name), this);

var isCollection = context.PropertyType.IsCollectionShape();

if (isCollection && TrySetChildSequence(section, context))
return;

var value = section[context.Key];

if (isCollection)
{
if (!string.IsNullOrWhiteSpace(value))
context.SetNewValue(value);

return;
}

if (value != null)
context.SetNewValue(value);
}

// Child-section sequence path (COLL-03): a collection property may be an indexed sub-section
// (Key:0, Key:1, ...) rather than a delimited scalar. Enumerate the live config view ONCE (a second
// pass could race a reload), collecting non-empty element values into a string[] so the element
// converter chain runs per item. Empty entries are dropped for parity with the comma-scalar split
// (RemoveEmptyEntries). Children win over the scalar; an all-empty sequence falls through to the scalar
// path. No element value is placed in any exception (S1).
private static bool TrySetChildSequence(IConfigurationSection section, BindingContext context)
{
var childSection = section.GetSection(context.Key);

List<string>? values = null;
foreach (var child in childSection.GetChildren())
{
var childValue = child.Value;
if (!string.IsNullOrEmpty(childValue))
(values ??= new List<string>()).Add(childValue);
}
Comment thread
guy-lud marked this conversation as resolved.

if (values is null)
return false;

context.SetNewValue(values.ToArray());
return true;
}

private IConfigurationSection ResolveSection(string section)
{
return _configuration.GetSection(string.IsNullOrWhiteSpace(RootSection) ? section : $"{RootSection}:{section}");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ protected CollectionTypeConverter(SettingsOptions settingsOptions, TypeConverter
// The element type to convert each item to: the array's element type, or the IEnumerable<T> argument.
protected abstract Type GetElementType(Type settingsType);

public object Convert(object value, Type settingsType)
public virtual object Convert(object value, Type settingsType)
{
var elementType = GetElementType(settingsType);
var source = AsArray(value);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Collections.Concurrent;
using System.Reflection;
using ExistForAll.SimpleSettings.Core.Reflection;

namespace ExistForAll.SimpleSettings.Conversion
{
// Handles the List<T> family (List/IList/ICollection/IReadOnlyList/IReadOnlyCollection<T>) by reusing the
// base array-build path and copying the result into a List<T>. Materialization uses a cached per-element
// delegate (built once, invoked on every populate) so the warm path carries no MakeGenericType/Activator
// reflection.
internal class ListTypeConverter : CollectionTypeConverter
{
private static readonly MethodInfo CreateListMethod =
typeof(ListTypeConverter).GetMethod(nameof(CreateList), BindingFlags.NonPublic | BindingFlags.Static)!;

private static readonly ConcurrentDictionary<Type, Func<Array, object>> ListFactories = new();

public ListTypeConverter(SettingsOptions settingsOptions, TypeConvertersCollections converters)
: base(settingsOptions, converters)
{
}

public override bool CanConvert(Type settingsType)
{
return settingsType.IsListLike();
}

protected override Type GetElementType(Type settingsType)
{
return settingsType.GetTypeInfo().GetGenericArguments()[0];
}

public override object Convert(object value, Type settingsType)
{
var elementType = GetElementType(settingsType);
var builtArray = (Array)base.Convert(value, settingsType);
var factory = ListFactories.GetOrAdd(elementType, BuildFactory);

return factory(builtArray);
}

private static Func<Array, object> BuildFactory(Type elementType)
{
var closed = CreateListMethod.MakeGenericMethod(elementType);

return (Func<Array, object>)closed.CreateDelegate(typeof(Func<Array, object>));
}

// The source is the freshly built T[] (ICollection<T>), so new List<T>(source) copies into a
// right-sized buffer rather than aliasing the array.
private static object CreateList<TElement>(Array source)
{
return new List<TElement>((TElement[])source);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,19 @@ public PropertyConversion(ISettingsTypeConverter converter,
{
if (value == null)
Comment thread
guy-lud marked this conversation as resolved.
{
// AllowEmpty=false rejects a missing required value, value-free.
if (_throwOnNull)
throw new SettingsPropertyNullException(_propertyName);

return _nullResult;
// A list null-result is a factory so each bind gets a fresh mutable List<T>; arrays /
// IEnumerable / value-type defaults stay on the shared cached instance.
return _nullResult is Func<object> listFactory ? listFactory() : _nullResult;
}

// AllowEmpty=false also rejects empty/whitespace strings, not just null.
if (_throwOnNull && value is string s && string.IsNullOrWhiteSpace(s))
throw new SettingsPropertyNullException(_propertyName);

return _converter.Convert(value, _strippedType);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public TypeConvertersCollections(SettingsOptions settingsOptions)
AddLast(new UriTypeConvertor());
AddLast(new ArrayTypeConverter(settingsOptions, this));
AddLast(new EnumerableTypeConverter(settingsOptions, this));
AddLast(new ListTypeConverter(settingsOptions, this));
AddLast(new EnumTypeConverter());
AddLast(new DefaultTypeConverter());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,34 @@ public PropertyConversion CreateConversion(PropertyInfo propertyInfo, SettingsPr
return new PropertyConversion(converter, strippedType, throwOnNull, nullResult, propertyInfo.Name);
}

// The value a null bound value converts to: an empty sequence for IEnumerable<T>, a default instance
// for a value type, otherwise null. Constant per property, so it is materialized once at plan build.
private static readonly MethodInfo EmptyListFactoryMethod =
typeof(TypeConverter).GetMethod(nameof(CreateEmptyList), BindingFlags.NonPublic | BindingFlags.Static)!;

// The value a null bound value converts to per shape: a shared empty array for arrays and IEnumerable<T>,
// a fresh-per-bind empty List<T> for the List family (a mutable list must not be shared), a
// default instance for a value type, otherwise null. Resolved once at plan build; the list branch bakes a
// factory delegate (cold reflection here, plain new List<T>() per call) rather than a shared instance.
private static object? CreateNullResult(Type propertyType)
{
if (!propertyType.IsEnumerable())
return propertyType.GetTypeInfo().IsValueType ? Activator.CreateInstance(propertyType) : null;
if (propertyType.IsArray)
return Array.CreateInstance(propertyType.GetElementType()!, 0);

if (propertyType.IsEnumerable())
return Array.CreateInstance(propertyType.GetTypeInfo().GetGenericArguments()[0], 0);

if (propertyType.IsListLike())
{
var elementType = propertyType.GetTypeInfo().GetGenericArguments()[0];
return (Func<object>)EmptyListFactoryMethod.MakeGenericMethod(elementType)
.CreateDelegate(typeof(Func<object>));
}

return propertyType.GetTypeInfo().IsValueType ? Activator.CreateInstance(propertyType) : null;
}

// An empty IEnumerable<T> is just an empty T[] (arrays implement IEnumerable<T>). Array.CreateInstance
// replaces the old Enumerable.Empty<T>() built via GetMethod("Empty").MakeGenericMethod().Invoke().
var elementType = propertyType.GetTypeInfo().GetGenericArguments()[0];
return Array.CreateInstance(elementType, 0);
private static object CreateEmptyList<TElement>()
{
return new List<TElement>();
}

private static ISettingsTypeConverter GetConverter(Type strippedType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,27 @@ public static bool IsEnumerable(this Type type)

return info.IsGenericType && info.GetGenericTypeDefinition() == typeof(IEnumerable<>);
}

// Disjoint from IsEnumerable/IsArray: matches only the List<T> family the ListTypeConverter claims.
public static bool IsListLike(this Type type)
{
var info = type.GetTypeInfo();

if (!info.IsGenericType)
return false;

var definition = info.GetGenericTypeDefinition();

return definition == typeof(List<>)
|| definition == typeof(IList<>)
|| definition == typeof(ICollection<>)
|| definition == typeof(IReadOnlyList<>)
|| definition == typeof(IReadOnlyCollection<>);
}

public static bool IsCollectionShape(this Type type)
{
return type.IsArray || type.IsEnumerable() || type.IsListLike();
}
}
}
1 change: 1 addition & 0 deletions src/Core/ExistForAll.SimpleSettings/Info.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@

[assembly:InternalsVisibleTo("ExistForAll.SimpleSettings.UnitTests")]
[assembly:InternalsVisibleTo("ExistForAll.SimpleSettings.Benchmark")]
[assembly:InternalsVisibleTo("ExistForAll.SimpleSettings.Binders")]
26 changes: 25 additions & 1 deletion src/Core/ExistForAll.SimpleSettings/Resources.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Reflection;
using System.Text;
using ExistForAll.SimpleSettings.Validations;

namespace ExistForAll.SimpleSettings
{
Expand Down Expand Up @@ -40,13 +42,35 @@ public static string SettingsPropertiesExtractionMessage(Type type) =>
all properties from type [{type.FullName}]";

public static string PropertyNotAllowNullMessage(string propertyName) =>
$@"[{propertyName}] is marked as Null not allowed, yet the value is null. please provide value via binder or attribute";
$@"[{propertyName}] is marked as Null not allowed, yet the value is missing (null, empty, or whitespace). please provide value via binder or attribute";

public static string TypeIsNotInterface(string typeName) =>
$@"[{typeName}] is not an interface, SimpleSettings supports only interfaces";

public static string SettingsOptionAttributeTypeMessage(Type type) =>
$"SimpleSettings support Attribute indication of interfaces, the type provided [{type.FullName}] is not an attribute.";

// Composed ONLY from author-supplied ValidationError text (SettingsName + ErrorMessage). The inspected
// settings values are deliberately never embedded — a validator may run against a secret, and this
// message reaches logs via Exception.ToString(). See D-12/S1 and SettingsValidationException.
public static string SettingsValidationExceptionMessage(IReadOnlyList<ValidationError> errors)
{
var builder = new StringBuilder();
builder.Append("Settings validation failed with ").Append(errors.Count).Append(" error(s):");

for (var i = 0; i < errors.Count; i++)
{
var error = errors[i];
builder.Append(Environment.NewLine).Append(" - [").Append(error.SettingsName).Append("] ").Append(error.ErrorMessage);
}

return builder.ToString();
}

// Value-free like the rest of the family: names only the validator and the failure type, never the
// settings value the validator inspected (which may be a secret). See S1.
public static string SettingsValidatorInvocationExceptionMessage(Type validatorType, Type failureType) =>
$"Validator [{validatorType.Name}] threw [{failureType.Name}] while validating. The settings value is omitted to avoid leaking secrets into logs.";

}
}
29 changes: 27 additions & 2 deletions src/Core/ExistForAll.SimpleSettings/SettingsPlan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,24 @@ internal sealed class SettingsPlan
private readonly SettingsOptions _options;
private string? _sectionName;

public SettingsPlan(Type settingsType, SettingsOptions options, PropertyPlan[] properties)
public SettingsPlan(Type settingsType, SettingsOptions options, PropertyPlan[] properties, Type? objectValidatorType)
{
_settingsType = settingsType;
_options = options;
Properties = properties;
ObjectValidatorType = objectValidatorType;
HasValidators = objectValidatorType is not null || AnyPropertyHasValidator(properties);
}

private static bool AnyPropertyHasValidator(PropertyPlan[] properties)
{
for (var i = 0; i < properties.Length; i++)
{
if (properties[i].ValidatorType is not null)
return true;
}

return false;
}

// Resolved lazily and cached: a builder with no binders (e.g. the cold assembly scan) never reads it,
Expand All @@ -26,18 +39,27 @@ public SettingsPlan(Type settingsType, SettingsOptions options, PropertyPlan[] p
public string SectionName => _sectionName ??= _settingsType.GetSectionName(_options);

public PropertyPlan[] Properties { get; }

// The object-level validator ([SettingsSection].ValidatorType) declared on the settings interface,
// resolved once at plan build (null when none is declared).
public Type? ObjectValidatorType { get; }

// Computed once at plan build so the post-populate hook can short-circuit with a single field read
// before allocating anything on the validator-free warm path.
public bool HasValidators { get; }
}

// A readonly struct held inline in SettingsPlan.Properties: the whole per-type plan is then one array
// allocation plus the section string, rather than an object per property (matters at scan scale).
internal readonly struct PropertyPlan
{
public PropertyPlan(PropertyInfo property, string key, object? defaultValue, PropertyConversion conversion)
public PropertyPlan(PropertyInfo property, string key, object? defaultValue, PropertyConversion conversion, Type? validatorType)
{
Property = property;
Key = key;
DefaultValue = defaultValue;
Conversion = conversion;
ValidatorType = validatorType;
}

public PropertyInfo Property { get; }
Expand All @@ -47,5 +69,8 @@ public PropertyPlan(PropertyInfo property, string key, object? defaultValue, Pro
public object? DefaultValue { get; }

public PropertyConversion Conversion { get; }

// The [SettingsProperty(ValidatorType)] validator for this property, resolved once at plan build.
public Type? ValidatorType { get; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ public class SettingsSectionAttribute : Attribute
{
public string? Name { get; set; }

public Type? ValidatorType { get; set; }

public SettingsSectionAttribute()
{

}

public SettingsSectionAttribute(string name)
{
Name = name;
Expand Down
34 changes: 34 additions & 0 deletions src/Core/ExistForAll.SimpleSettings/SettingsValidationException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using ExistForAll.SimpleSettings.Validations;

namespace ExistForAll.SimpleSettings
{
// Aggregates every ValidationError produced by the declared validators into one exception. Like the rest of
// the family it embeds NO bound value: the message is composed only from author-supplied ValidationError text
// (SettingsName + ErrorMessage), never from the settings values the validators inspected (which may be
// secrets). See D-12/S1 and SettingsPropertyValueException. The aggregate-and-throw is centralized in
// ThrowIfAny so the core populate path and the DI-resolved path share one thrown contract.
public class SettingsValidationException : SimpleSettingsException
{
public SettingsValidationException(IReadOnlyList<ValidationError> errors)
: base(Resources.SettingsValidationExceptionMessage(errors))
{
Errors = errors;
}

public IReadOnlyList<ValidationError> Errors { get; }

// The single aggregate-and-throw entry point shared by the core populate path (Plan 03) and the
// DI-resolved runner (Plan 04): throws one aggregated exception when any error is present, otherwise
// returns. Centralizing it keeps the thrown contract (type + Errors + value-free message) identical
// across both paths.
public static void ThrowIfAny(IReadOnlyList<ValidationError> errors)
{
if (errors == null) throw new ArgumentNullException(nameof(errors));

if (errors.Count == 0)
return;

throw new SettingsValidationException(errors);
}
}
}
Loading
Loading