1+ using System . Collections ;
2+ using System . Dynamic ;
3+ using System . Reflection ;
4+ using System . Text . Json ;
5+
6+ namespace FAST . FBasicInterpreter . Types
7+ {
8+ /// <summary>
9+ /// Key Features:
10+ /// Generic method MapToType<T>() and non-generic MapToType() for flexibility
11+ /// Deep cloning - recursively maps nested objects and collections at any depth
12+ /// Array support - handles arrays of any type including nested objects
13+ /// Collection support - works with List<T>, IList<T>, ICollection<T>, IEnumerable<T>
14+ /// Record support - properly handles C# records with primary constructors and init-only properties
15+ /// Case-insensitive matching - property names are matched regardless of casing
16+ /// Graceful bypassing - ignores properties that don't exist or don't match in either the source or target
17+ /// Type conversion - automatically converts compatible types (primitives, DateTime, Guid, etc.)
18+ ///
19+ /// How it works:
20+ /// Creates an instance of the target type (handles parameterless and parameterized constructors)
21+ /// Iterates through all writable properties
22+ /// Matches properties by name (case-insensitive)
23+ /// Recursively maps nested ExpandoObjects, arrays, and collections
24+ /// Silently skips properties that can't be mapped
25+ /// The mapper handles complex scenarios like records with primary constructors, nested objects at any depth, and mixed collections of primitives and complex types.
26+ /// </summary>
27+ public static class ExpandoMapper
28+ {
29+ /// <summary>
30+ /// Maps an ExpandoObject to a specified type or record, copying matching properties recursively.
31+ /// </summary>
32+ /// <typeparam name="T">The target type to map to</typeparam>
33+ /// <param name="expandoObject">The source ExpandoObject</param>
34+ /// <returns>An instance of T with mapped properties</returns>
35+ public static T MapToType < T > ( ExpandoObject expandoObject )
36+ {
37+ return ( T ) MapToType ( expandoObject , typeof ( T ) ) ;
38+ }
39+
40+ /// <summary>
41+ /// Maps an ExpandoObject to a specified type or record, copying matching properties recursively.
42+ /// </summary>
43+ /// <param name="expandoObject">The source ExpandoObject</param>
44+ /// <param name="targetType">The target type to map to</param>
45+ /// <returns>An instance of the target type with mapped properties</returns>
46+ public static object MapToType ( ExpandoObject expandoObject , Type targetType )
47+ {
48+ if ( expandoObject == null )
49+ return null ! ;
50+
51+ // Get the dictionary representation of the ExpandoObject
52+ var expandoDict = ( IDictionary < string , object > ) expandoObject ;
53+
54+ // Create an instance of the target type
55+ object instance = CreateInstance ( targetType , expandoDict ) ;
56+
57+ // Get all writable properties of the target type
58+ var properties = targetType . GetProperties ( BindingFlags . Public | BindingFlags . Instance )
59+ . Where ( p => p . CanWrite || IsInitOnlyProperty ( p ) ) ;
60+
61+ foreach ( var property in properties )
62+ {
63+ // Try to find a matching key in the expando object (case-insensitive)
64+ var matchingKey = expandoDict . Keys . FirstOrDefault ( k =>
65+ string . Equals ( k , property . Name , StringComparison . OrdinalIgnoreCase ) ) ;
66+
67+ if ( matchingKey == null )
68+ continue ;
69+
70+ var expandoValue = expandoDict [ matchingKey ] ;
71+
72+ if ( expandoValue == null )
73+ {
74+ SetPropertyValue ( instance , property , null ) ;
75+ continue ;
76+ }
77+
78+ try
79+ {
80+ var mappedValue = MapValue ( expandoValue , property . PropertyType ) ;
81+ SetPropertyValue ( instance , property , mappedValue ) ;
82+ }
83+ catch
84+ {
85+ // Bypass if mapping fails
86+ continue ;
87+ }
88+ }
89+
90+ return instance ;
91+ }
92+
93+
94+ private static object MapValue ( object value , Type targetType )
95+ {
96+ if ( value == null )
97+ return null ;
98+
99+
100+ if ( value is JsonElement )
101+ {
102+ value = JsonElementConverter . ConvertToPlainValue ( ( JsonElement ) value ) ;
103+ }
104+
105+ var valueType = value . GetType ( ) ;
106+
107+ // Handle nullable types
108+ var underlyingType = Nullable . GetUnderlyingType ( targetType ) ;
109+ if ( underlyingType != null )
110+ targetType = underlyingType ;
111+
112+ // Direct assignment for matching types or convertible types
113+ if ( targetType . IsAssignableFrom ( valueType ) )
114+ return value ;
115+
116+ // Handle primitive types and strings
117+ if ( targetType . IsPrimitive || targetType == typeof ( string ) || targetType == typeof ( decimal ) ||
118+ targetType == typeof ( DateTime ) || targetType == typeof ( Guid ) )
119+ {
120+ return Convert . ChangeType ( value , targetType ) ;
121+ }
122+
123+ // Handle arrays
124+ if ( targetType . IsArray )
125+ {
126+ return MapArray ( value , targetType ) ;
127+ }
128+
129+ // Handle generic lists and collections
130+ if ( targetType . IsGenericType )
131+ {
132+ var genericTypeDef = targetType . GetGenericTypeDefinition ( ) ;
133+
134+ if ( genericTypeDef == typeof ( List < > ) ||
135+ genericTypeDef == typeof ( IList < > ) ||
136+ genericTypeDef == typeof ( ICollection < > ) ||
137+ genericTypeDef == typeof ( IEnumerable < > ) )
138+ {
139+ return MapList ( value , targetType ) ;
140+ }
141+ }
142+
143+ // Handle nested ExpandoObjects
144+ if ( value is ExpandoObject expandoValue )
145+ {
146+ return MapToType ( expandoValue , targetType ) ;
147+ }
148+
149+ // Handle dictionaries as ExpandoObjects
150+ if ( value is IDictionary < string , object > dict )
151+ {
152+ var expando = new ExpandoObject ( ) ;
153+ var expandoDict = ( IDictionary < string , object > ) expando ;
154+ foreach ( var kvp in dict )
155+ {
156+ expandoDict [ kvp . Key ] = kvp . Value ;
157+ }
158+ return MapToType ( expando , targetType ) ;
159+ }
160+
161+ // Try to convert as a last resort
162+ return Convert . ChangeType ( value , targetType ) ;
163+ }
164+
165+ private static object MapArray ( object value , Type targetType )
166+ {
167+ var elementType = targetType . GetElementType ( ) ;
168+
169+ if ( value is IEnumerable enumerable )
170+ {
171+ var items = enumerable . Cast < object > ( ) . ToList ( ) ;
172+ var array = Array . CreateInstance ( elementType , items . Count ) ;
173+
174+ for ( int i = 0 ; i < items . Count ; i ++ )
175+ {
176+ var mappedItem = MapValue ( items [ i ] , elementType ) ;
177+ array . SetValue ( mappedItem , i ) ;
178+ }
179+
180+ return array ;
181+ }
182+
183+ throw new InvalidOperationException ( "Cannot map non-enumerable to array" ) ;
184+ }
185+
186+ private static object MapList ( object value , Type targetType )
187+ {
188+ var elementType = targetType . GetGenericArguments ( ) [ 0 ] ;
189+ var listType = typeof ( List < > ) . MakeGenericType ( elementType ) ;
190+ var list = ( IList ) Activator . CreateInstance ( listType ) ;
191+
192+ if ( value is IEnumerable enumerable )
193+ {
194+ foreach ( var item in enumerable )
195+ {
196+ var mappedItem = MapValue ( item , elementType ) ;
197+ list . Add ( mappedItem ) ;
198+ }
199+ }
200+
201+ return list ;
202+ }
203+
204+ private static object CreateInstance ( Type type , IDictionary < string , object > expandoDict )
205+ {
206+ // For records and classes with primary constructors, try to match constructor parameters
207+ var constructors = type . GetConstructors ( BindingFlags . Public | BindingFlags . Instance )
208+ . OrderByDescending ( c => c . GetParameters ( ) . Length ) ;
209+
210+ foreach ( var constructor in constructors )
211+ {
212+ var parameters = constructor . GetParameters ( ) ;
213+
214+ if ( parameters . Length == 0 )
215+ {
216+ return Activator . CreateInstance ( type ) ;
217+ }
218+
219+ // Try to match all constructor parameters with expando properties
220+ var paramValues = new object [ parameters . Length ] ;
221+ var allMatched = true ;
222+
223+ for ( int i = 0 ; i < parameters . Length ; i ++ )
224+ {
225+ var param = parameters [ i ] ;
226+ var matchingKey = expandoDict . Keys . FirstOrDefault ( k =>
227+ string . Equals ( k , param . Name , StringComparison . OrdinalIgnoreCase ) ) ;
228+
229+ if ( matchingKey != null )
230+ {
231+ try
232+ {
233+ paramValues [ i ] = MapValue ( expandoDict [ matchingKey ] , param . ParameterType ) ;
234+ }
235+ catch
236+ {
237+ paramValues [ i ] = GetDefault ( param . ParameterType ) ;
238+ }
239+ }
240+ else
241+ {
242+ paramValues [ i ] = GetDefault ( param . ParameterType ) ;
243+ }
244+ }
245+
246+ try
247+ {
248+ return Activator . CreateInstance ( type , paramValues ) ;
249+ }
250+ catch
251+ {
252+ continue ;
253+ }
254+ }
255+
256+ // Fallback to parameterless constructor
257+ return Activator . CreateInstance ( type ) ;
258+ }
259+
260+ private static void SetPropertyValue ( object instance , PropertyInfo property , object value )
261+ {
262+ if ( property . CanWrite )
263+ {
264+ property . SetValue ( instance , value ) ;
265+ }
266+ else if ( IsInitOnlyProperty ( property ) )
267+ {
268+ // Handle init-only properties (records)
269+ property . SetValue ( instance , value ) ;
270+ }
271+ }
272+
273+ private static bool IsInitOnlyProperty ( PropertyInfo property )
274+ {
275+ return property . SetMethod ? . ReturnParameter
276+ ? . GetRequiredCustomModifiers ( )
277+ ? . Any ( t => t . Name == "IsExternalInit" ) ?? false ;
278+ }
279+
280+ private static object GetDefault ( Type type )
281+ {
282+ return type . IsValueType ? Activator . CreateInstance ( type ) : null ;
283+ }
284+ }
285+ }
0 commit comments