Skip to content

Commit 8e8be8e

Browse files
AddParams(x) now sends the members of the runtime type when x is typed object (#90)
A variable typed object, or the result of a Func<T, object>, used to send nothing because it has no members of its own. AddParams now uses the members of the object's runtime type, found once per type and remembered. Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 9adfac2 commit 8e8be8e

3 files changed

Lines changed: 179 additions & 7 deletions

File tree

‎Source/Core/ExpressiveCommandBase.cs‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,8 @@ public TThis AddParamIf<T>(bool condition, T value, [CallerArgumentExpression(na
379379
/// When the real names are known, a member that the procedure does not declare is not sent, so one object can serve several procedures; parameters the procedure declares that no member matches are left to default values or to explicit <c>AddParam</c> calls.
380380
/// A parameter you add with <c>AddParam</c>, by name or from an argument, wins over a member that resolves to the same name.</para>
381381
/// <para>Every public readable property and field is read when this method is called, whether or not the procedure declares it, so a getter that throws or loads lazily runs then. Static members and indexers are ignored.
382-
/// The members are those of the static type <typeparamref name="T"/>, not of the object's runtime type: a variable typed <see cref="object"/> has no members and adds nothing, and an interface adds only its own. Nested objects are not flattened (pass <c>order.Customer</c> itself).</para>
382+
/// The members are those of the static type <typeparamref name="T"/>, not of the object's runtime type: an interface adds only its own. The one exception is <see cref="object"/>: a variable typed <see cref="object"/>, or the result of a <c>Func&lt;T, object&gt;</c>, has no members of its own, so the members of its runtime type are used.
383+
/// There, the members of each type are found once and remembered. Nested objects are not flattened (pass <c>order.Customer</c> itself).</para>
383384
/// </remarks>
384385
/// <typeparam name="T">The type of the object whose members are the parameters.</typeparam>
385386
/// <param name="source">The object to read the values from.</param>
@@ -391,6 +392,21 @@ public TThis AddParams<T>(T source)
391392
if (source is null) throw new ArgumentNullException(nameof(source));
392393
Contract.EndContractBlock();
393394

395+
// A source typed object has no members of its own: its runtime type's are used (a Func<T, object> result, a boxed anonymous object).
396+
if (typeof(T) == typeof(object))
397+
{
398+
RuntimeParamSource members = RuntimeParamSource.Of(source.GetType());
399+
if (members.Names.Length == 0) return (TThis)this;
400+
401+
// A new list is made at the size of the members, not at the default of four slots of a large struct.
402+
List<Param> own = _params ??= new(members.Names.Length);
403+
for (int i = 0; i < members.Names.Length; i++)
404+
own.Add(Param.OfObject(members.Names[i], members.Getters[i](source), members.Types[i]));
405+
406+
EnsureOptions().HasDerivedParams = true;
407+
return (TThis)this;
408+
}
409+
394410
string[] names = ParamSource<T>.Names;
395411
Func<T, object?>[] getters = ParamSource<T>.Getters;
396412
Type[] types = ParamSource<T>.Types;

‎Source/Core/RuntimeParamSource.cs‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
using System.Linq.Expressions;
2+
3+
namespace Open.Database.Extensions;
4+
5+
/// <summary>
6+
/// The public instance properties and fields of a RUNTIME type and a compiled getter for each, built once per type, for <c>AddParams(object)</c> when the argument is typed <see cref="object"/>
7+
/// and the static type has no members of its own. The members are found the way <see cref="ParamSource{T}"/> finds them.
8+
/// </summary>
9+
/// <remarks>One immutable entry per type, kept for as long as the type is alive (so a type that is unloaded takes its entry with it); the getters take the object, so no reflection runs after the first time a type is seen.</remarks>
10+
sealed class RuntimeParamSource
11+
{
12+
static readonly ConditionalWeakTable<Type, RuntimeParamSource> Cache = new();
13+
static readonly ConditionalWeakTable<Type, RuntimeParamSource>.CreateValueCallback Create = type => new RuntimeParamSource(type);
14+
15+
internal static RuntimeParamSource Of(Type type) => Cache.GetValue(type, Create);
16+
17+
internal string[] Names { get; }
18+
internal Func<object, object?>[] Getters { get; }
19+
internal Type[] Types { get; }
20+
21+
RuntimeParamSource(Type type)
22+
{
23+
var names = new List<string>();
24+
var getters = new List<Func<object, object?>>();
25+
var types = new List<Type>();
26+
var seen = new HashSet<string>(StringComparer.Ordinal);
27+
ParameterExpression source = Expression.Parameter(typeof(object), "source");
28+
UnaryExpression typed = Expression.Convert(source, type);
29+
30+
foreach (MemberInfo member in type.GetMembers(BindingFlags.Public | BindingFlags.Instance))
31+
{
32+
// Readable properties without index parameters, and fields; a member hidden by name keeps the first.
33+
bool usable = member is PropertyInfo { CanRead: true } property
34+
? property.GetIndexParameters().Length == 0 && property.GetGetMethod() is not null
35+
: member is FieldInfo;
36+
if (!usable) continue;
37+
38+
Type memberType = member is PropertyInfo p ? p.PropertyType : ((FieldInfo)member).FieldType;
39+
if (!seen.Add(member.Name)) continue;
40+
41+
Expression value = Expression.Convert(Expression.MakeMemberAccess(typed, member), typeof(object));
42+
names.Add(member.Name);
43+
types.Add(memberType);
44+
getters.Add(Expression.Lambda<Func<object, object?>>(value, source).Compile());
45+
}
46+
47+
Names = [.. names];
48+
Getters = [.. getters];
49+
Types = [.. types];
50+
}
51+
}

‎Tests/Open.Database.Extensions.Core.Tests/ParameterNameAddParamsTests.cs‎

Lines changed: 111 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,20 +46,125 @@ readonly struct Point
4646
public int X { get; init; }
4747
}
4848

49+
sealed class NoPublicMembers
50+
{
51+
internal int Hidden { get; } = 1;
52+
}
53+
4954
static SyncNameRun TextCommand() => new(CommandType.Text, "@never");
5055

51-
// ---- the static type counts ----
56+
// ---- the static type counts, except for object: then the members are those of the runtime type ----
57+
58+
[Fact]
59+
public static void AnObjectVariable_SendsTheMembersOfItsRuntimeType()
60+
{
61+
object boxed = new { Region = "west", Secret = "x" };
62+
63+
Assert.Equal(["Region", "Secret"], TextCommand().Run(c => c.AddParams(boxed)));
64+
65+
var procedure = new SyncNameRun("@region");
66+
Assert.Equal(["@region"], procedure.Run(c => c.AddParams(boxed))); // matched like the members of any other type: the undeclared one is left out
67+
Assert.Equal("west", procedure.Values["@region"]);
68+
Assert.Equal(1, procedure.Lookups);
69+
}
70+
71+
[Fact]
72+
public static void TheResultOfAFuncOfObject_SendsItsMembers_ReadAtEveryCall()
73+
{
74+
static object Make(int id) => new { Id = id, Name = "n" + id };
75+
var run = TextCommand();
76+
77+
Assert.Equal(["Id", "Name"], run.Run(c => c.AddParams(Make(3))));
78+
Assert.Equal(3, run.Values["Id"]);
79+
Assert.Equal("n3", run.Values["Name"]);
80+
81+
Assert.Equal(["Id", "Name"], run.Run(c => c.AddParams(Make(4)))); // the members are found once per type, the values are read every time
82+
Assert.Equal(4, run.Values["Id"]);
83+
Assert.Equal("n4", run.Values["Name"]);
84+
}
85+
86+
[Fact]
87+
public static void DifferentRuntimeTypesInARow_EachSendItsOwnMembers()
88+
{
89+
object[] sources = [new { A = 1 }, new { B = "b", C = 2.5 }, new { A = 3 }, new WithRegion(), new Hiding()];
90+
string[][] expected = [["A"], ["B", "C"], ["A"], ["Region", "Secret"], ["Id", "Inherited"]];
91+
92+
for (int i = 0; i < sources.Length; i++)
93+
Assert.Equal(expected[i], TextCommand().Run(c => c.AddParams(sources[i])).Order());
94+
95+
var both = TextCommand();
96+
Assert.Equal(["A", "B", "C"], both.Run(c => c.AddParams(sources[0]).AddParams(sources[1])));
97+
Assert.Equal(1, both.Values["A"]);
98+
Assert.Equal("b", both.Values["B"]);
99+
Assert.Equal(2.5, both.Values["C"]);
100+
}
101+
102+
[Fact]
103+
public static void AHiddenMember_OfARuntimeType_KeepsTheMostDerivedOne_AsItDoesForTheStaticType()
104+
{
105+
object boxed = new Hiding();
106+
var run = TextCommand();
107+
108+
Assert.Equal(["Id", "Inherited"], run.Run(c => c.AddParams(boxed)).Order());
109+
Assert.Equal("derived", run.Values["Id"]);
110+
}
111+
112+
[Fact]
113+
public static void ABoxedStruct_SendsItsMembers()
114+
{
115+
object boxed = new Point { X = 7 };
116+
var run = TextCommand();
117+
118+
Assert.Equal(["X"], run.Run(c => c.AddParams(boxed)));
119+
Assert.Equal(7, run.Values["X"]);
120+
}
52121

53122
[Fact]
54-
public static void AnObjectVariable_HasNoMembersOfItsOwn_AndSendsNothing()
123+
public static void ATypeWithNoMembers_AddsNothing_AndNobodyIsAsked()
55124
{
56-
object boxed = new { Region = "west" };
125+
object nothing = new();
126+
object noPublic = new NoPublicMembers();
57127

58-
Assert.Empty(TextCommand().Run(c => c.AddParams(boxed)));
128+
Assert.Empty(TextCommand().Run(c => c.AddParams(nothing)));
129+
Assert.Empty(TextCommand().Run(c => c.AddParams(noPublic)));
59130

60131
var procedure = new SyncNameRun("@region");
61-
Assert.Empty(procedure.Run(c => c.AddParams(boxed)));
62-
Assert.Equal(0, procedure.Lookups); // nothing was added, so there is nothing to match and nobody is asked
132+
Assert.Empty(procedure.Run(c => c.AddParams(nothing)));
133+
Assert.Equal(0, procedure.Lookups); // nothing was added, so there is nothing to match
134+
}
135+
136+
[Fact]
137+
public static void ANullObject_StillThrowsArgumentNullException()
138+
{
139+
var command = new ConnectionProbe(ConnectionState.Open).Connection.Command("select 1");
140+
141+
var ex = Assert.Throws<ArgumentNullException>(() => command.AddParams<object>(null!));
142+
143+
Assert.Equal("source", ex.ParamName);
144+
}
145+
146+
[Fact]
147+
public static void EveryPublicGetter_OfTheRuntimeType_IsReadWhenAddParamsIsCalled()
148+
{
149+
var command = new ConnectionProbe(ConnectionState.Open).Connection.Command("select 1");
150+
object boxed = new LazyMember();
151+
152+
var ex = Assert.Throws<InvalidOperationException>(() => command.AddParams(boxed));
153+
154+
Assert.Equal("lazy", ex.Message);
155+
}
156+
157+
[Fact]
158+
public static void AStaticTypeOtherThanObject_KeepsItsOwnMembers_WhateverTheObjectIs()
159+
{
160+
IHasRegion region = new WithRegion();
161+
Base derived = new Hiding(); // the static type is Base: Id is the int, and the runtime type's own members are not sent
162+
163+
Assert.Equal(["Region"], TextCommand().Run(c => c.AddParams(region)));
164+
165+
var run = TextCommand();
166+
Assert.Equal(["Id", "Inherited"], run.Run(c => c.AddParams(derived)).Order());
167+
Assert.Equal(1, run.Values["Id"]);
63168
}
64169

65170
[Fact]

0 commit comments

Comments
 (0)