diff --git a/AmalgamationTool/DynamORM.Amalgamation.cs b/AmalgamationTool/DynamORM.Amalgamation.cs index 7fb9fe2..55e0de4 100644 --- a/AmalgamationTool/DynamORM.Amalgamation.cs +++ b/AmalgamationTool/DynamORM.Amalgamation.cs @@ -10,6 +10,7 @@ using DynamORM.Builders; using DynamORM.Helpers.Dynamics; using DynamORM.Helpers; using DynamORM.Mapper; +using DynamORM.Objects; using DynamORM.TypedSql; using DynamORM.Validation; using System.Collections.Concurrent; @@ -2680,6 +2681,69 @@ namespace DynamORM .ExecuteNonQuery(); } } + /// Execute typed stored procedure descriptor. + /// Procedure descriptor type. + /// Procedure result. + public virtual object Procedure() + { + return Procedure(null); + } + /// Execute typed stored procedure descriptor. + /// Procedure descriptor type. + /// Procedure arguments contract. + /// Procedure result. + public virtual object Procedure(object args) + { + if ((Options & DynamicDatabaseOptions.SupportStoredProcedures) != DynamicDatabaseOptions.SupportStoredProcedures) + throw new InvalidOperationException("Database connection desn't support stored procedures."); + + return new DynamicProcedureInvoker(this).Exec(args); + } + /// Execute typed stored procedure descriptor with strong result type. + /// Procedure descriptor type. + /// Procedure result type. + /// Procedure result. + public virtual TResult Procedure() + where TProcedure : IProcedureDescriptor + { + return Procedure(null); + } + /// Execute typed stored procedure descriptor with strong result type. + /// Procedure descriptor type. + /// Procedure result type. + /// Procedure arguments contract. + /// Procedure result. + public virtual TResult Procedure(object args) + where TProcedure : IProcedureDescriptor + { + if ((Options & DynamicDatabaseOptions.SupportStoredProcedures) != DynamicDatabaseOptions.SupportStoredProcedures) + throw new InvalidOperationException("Database connection desn't support stored procedures."); + + return new DynamicProcedureInvoker(this).Exec(args); + } + /// Create typed stored procedure execution handle. + /// Procedure descriptor type. + /// Typed execution handle. + public virtual TypedProcedureCall TypedProcedure() + where TProcedure : IProcedureDescriptor + { + if ((Options & DynamicDatabaseOptions.SupportStoredProcedures) != DynamicDatabaseOptions.SupportStoredProcedures) + throw new InvalidOperationException("Database connection desn't support stored procedures."); + + return new TypedProcedureCall(new DynamicProcedureInvoker(this)); + } + /// Create typed stored procedure execution handle. + /// Procedure descriptor type. + /// Procedure result type. + /// Typed execution handle. + public virtual TypedProcedureCall TypedProcedure() + where TProcedure : IProcedureDescriptor + { + if ((Options & DynamicDatabaseOptions.SupportStoredProcedures) != DynamicDatabaseOptions.SupportStoredProcedures) + throw new InvalidOperationException("Database connection desn't support stored procedures."); + + return new TypedProcedureCall(new DynamicProcedureInvoker(this)); + } #endregion Procedure #region Execute @@ -5330,6 +5394,50 @@ namespace DynamORM _prefixes = prefixes; _db = db; } + /// Execute typed stored procedure descriptor. + /// Procedure descriptor type. + /// Optional procedure arguments contract. + /// Procedure result. + public virtual object Exec(object args = null) + { + DynamicProcedureDescriptor descriptor = DynamicProcedureDescriptor.Resolve(typeof(TProcedure)); + return InvokeProcedure( + descriptor.ProcedureName, + descriptor.ResultName, + new List(), + args == null ? new object[0] : new[] { args }, + new CallInfo(args == null ? 0 : 1), + descriptor.ResultType, + descriptor.ArgumentsType, + descriptor.ProcedureType); + } + /// Execute typed stored procedure descriptor with strong result type. + /// Procedure descriptor type. + /// Procedure result type. + /// Optional procedure arguments contract. + /// Procedure result. + public virtual TResult Exec(object args = null) + where TProcedure : IProcedureDescriptor + { + return ConvertProcedureResult(Exec(args)); + } + /// Create typed stored procedure execution handle. + /// Procedure descriptor type. + /// Typed execution handle. + public virtual TypedProcedureCall Typed() + where TProcedure : IProcedureDescriptor + { + return new TypedProcedureCall(this); + } + /// Create typed stored procedure execution handle. + /// Procedure descriptor type. + /// Procedure result type. + /// Typed execution handle. + public virtual TypedProcedureCall Typed() + where TProcedure : IProcedureDescriptor + { + return new TypedProcedureCall(this); + } /// This is where the magic begins. /// Binder to create owner. /// Binder invoke result. @@ -5358,152 +5466,145 @@ namespace DynamORM CallInfo info = binder.CallInfo; // Get generic types - IList types = binder.GetGenericTypeArguments(); + IList types = binder.GetGenericTypeArguments() ?? new List(); + result = InvokeProcedure(binder.Name, binder.Name, types, args, info, null, null, null); + return true; + } + internal object InvokeProcedure(string procedureName, string resultName, IList types, object[] args, CallInfo info, Type declaredResultType, Type expectedArgumentsType, Type procedureType) + { + object result; Dictionary retParams = null; + if (expectedArgumentsType != null) + { + if (args.Length > 1) + throw new InvalidOperationException("Exec(args) accepts at most one arguments contract instance."); + + if (args.Length == 1 && args[0] != null && !expectedArgumentsType.IsAssignableFrom(args[0].GetType())) + throw new InvalidOperationException(string.Format("Exec<{0}>(args) expects argument of type '{1}', received '{2}'.", procedureType == null ? expectedArgumentsType.FullName : procedureType.FullName, expectedArgumentsType.FullName, args[0].GetType().FullName)); + } using (IDbConnection con = _db.Open()) using (IDbCommand cmd = con.CreateCommand()) { if (_prefixes == null || _prefixes.Count == 0) - cmd.SetCommand(CommandType.StoredProcedure, binder.Name); + cmd.SetCommand(CommandType.StoredProcedure, procedureName); else - cmd.SetCommand(CommandType.StoredProcedure, string.Format("{0}.{1}", string.Join(".", _prefixes), binder.Name)); + cmd.SetCommand(CommandType.StoredProcedure, string.Format("{0}.{1}", string.Join(".", _prefixes), procedureName)); #region Prepare arguments int alen = args.Length; bool retIsAdded = false; + if (declaredResultType == null) + declaredResultType = alen == 1 ? DynamicProcedureResultBinder.GetDeclaredResultType(args[0]) : null; if (alen > 0) { - for (int i = 0; i < alen; i++) + if (alen == 1 && DynamicProcedureParameterBinder.CanBind(args[0])) { - object arg = args[i]; - - if (arg is DynamicExpando) - cmd.AddParameters(_db, (DynamicExpando)arg); - else if (arg is ExpandoObject) - cmd.AddParameters(_db, (ExpandoObject)arg); - else if (arg is DynamicColumn) + DynamicProcedureParameterBinder.BindingResult bindingResult = DynamicProcedureParameterBinder.Bind(_db, cmd, args[0]); + retParams = bindingResult.ReturnParameters; + retIsAdded = bindingResult.ReturnValueAdded; + } + else + { + for (int i = 0; i < alen; i++) { - var dcv = (DynamicColumn)arg; + object arg = args[i]; - string paramName = i.ToString(); - bool isOut = false; - bool isRet = false; - bool isBoth = false; - - if (info.ArgumentNames.Count > i) + if (arg is DynamicExpando) + cmd.AddParameters(_db, (DynamicExpando)arg); + else if (arg is ExpandoObject) + cmd.AddParameters(_db, (ExpandoObject)arg); + else if (arg is DynamicColumn) { - isOut = info.ArgumentNames[i].StartsWith("out_"); - isRet = info.ArgumentNames[i].StartsWith("ret_"); - isBoth = info.ArgumentNames[i].StartsWith("both_"); + var dcv = (DynamicColumn)arg; - paramName = isOut || isRet ? - info.ArgumentNames[i].Substring(4) : - isBoth ? info.ArgumentNames[i].Substring(5) : - info.ArgumentNames[i]; + string paramName = i.ToString(); + bool isOut = false; + bool isRet = false; + bool isBoth = false; + + if (info.ArgumentNames.Count > i) + { + isOut = info.ArgumentNames[i].StartsWith("out_"); + isRet = info.ArgumentNames[i].StartsWith("ret_"); + isBoth = info.ArgumentNames[i].StartsWith("both_"); + + paramName = isOut || isRet ? + info.ArgumentNames[i].Substring(4) : + isBoth ? info.ArgumentNames[i].Substring(5) : + info.ArgumentNames[i]; + } + paramName = dcv.Alias ?? dcv.ColumnName ?? + (dcv.Schema.HasValue ? dcv.Schema.Value.Name : null) ?? + paramName; + + if (!isOut && !isRet && !isBoth) + { + isOut = dcv.ParameterDirection == ParameterDirection.Output; + isRet = dcv.ParameterDirection == ParameterDirection.ReturnValue; + isBoth = dcv.ParameterDirection == ParameterDirection.InputOutput; + } + if (isRet) + retIsAdded = true; + + if (isOut || isRet || isBoth) + { + if (retParams == null) + retParams = new Dictionary(); + retParams.Add(paramName, cmd.Parameters.Count); + } + if (dcv.Schema != null) + { + var ds = dcv.Schema.Value; + cmd.AddParameter( + _db.GetParameterName(paramName), + isOut ? ParameterDirection.Output : + isRet ? ParameterDirection.ReturnValue : + isBoth ? ParameterDirection.InputOutput : + ParameterDirection.Input, + ds.Type, ds.Size, ds.Precision, ds.Scale, + (isOut || isRet) ? DBNull.Value : dcv.Value); + } + else + cmd.AddParameter( + _db.GetParameterName(paramName), + isOut ? ParameterDirection.Output : + isRet ? ParameterDirection.ReturnValue : + isBoth ? ParameterDirection.InputOutput : + ParameterDirection.Input, + arg == null ? DbType.String : arg.GetType().ToDbType(), + isRet ? 4 : 0, + (isOut || isRet) ? DBNull.Value : dcv.Value); } - paramName = dcv.Alias ?? dcv.ColumnName ?? - (dcv.Schema.HasValue ? dcv.Schema.Value.Name : null) ?? - paramName; - - if (!isOut && !isRet && !isBoth) + else if (arg is DynamicSchemaColumn) { - isOut = dcv.ParameterDirection == ParameterDirection.Output; - isRet = dcv.ParameterDirection == ParameterDirection.ReturnValue; - isBoth = dcv.ParameterDirection == ParameterDirection.InputOutput; - } - if (isRet) - retIsAdded = true; + var dsc = (DynamicSchemaColumn)arg; - if (isOut || isRet || isBoth) - { - if (retParams == null) - retParams = new Dictionary(); - retParams.Add(paramName, cmd.Parameters.Count); - } - if (dcv.Schema != null) - { - var ds = dcv.Schema.Value; - cmd.AddParameter( - _db.GetParameterName(paramName), - isOut ? ParameterDirection.Output : - isRet ? ParameterDirection.ReturnValue : - isBoth ? ParameterDirection.InputOutput : - ParameterDirection.Input, - ds.Type, ds.Size, ds.Precision, ds.Scale, - (isOut || isRet) ? DBNull.Value : dcv.Value); - } - else - cmd.AddParameter( - _db.GetParameterName(paramName), - isOut ? ParameterDirection.Output : - isRet ? ParameterDirection.ReturnValue : - isBoth ? ParameterDirection.InputOutput : - ParameterDirection.Input, - arg == null ? DbType.String : arg.GetType().ToDbType(), - isRet ? 4 : 0, - (isOut || isRet) ? DBNull.Value : dcv.Value); - } - else if (arg is DynamicSchemaColumn) - { - var dsc = (DynamicSchemaColumn)arg; + string paramName = i.ToString(); + bool isOut = false; + bool isRet = false; + bool isBoth = false; - string paramName = i.ToString(); - bool isOut = false; - bool isRet = false; - bool isBoth = false; + if (info.ArgumentNames.Count > i) + { + isOut = info.ArgumentNames[i].StartsWith("out_"); + isRet = info.ArgumentNames[i].StartsWith("ret_"); + isBoth = info.ArgumentNames[i].StartsWith("both_"); - if (info.ArgumentNames.Count > i) - { - isOut = info.ArgumentNames[i].StartsWith("out_"); - isRet = info.ArgumentNames[i].StartsWith("ret_"); - isBoth = info.ArgumentNames[i].StartsWith("both_"); - - paramName = isOut || isRet ? - info.ArgumentNames[i].Substring(4) : - isBoth ? info.ArgumentNames[i].Substring(5) : - info.ArgumentNames[i]; - } - paramName = dsc.Name ?? paramName; - - if (isRet) - retIsAdded = true; - - if (isOut || isRet || isBoth) - { - if (retParams == null) - retParams = new Dictionary(); - retParams.Add(paramName, cmd.Parameters.Count); - } - cmd.AddParameter( - _db.GetParameterName(paramName), - isOut ? ParameterDirection.Output : - isRet ? ParameterDirection.ReturnValue : - isBoth ? ParameterDirection.InputOutput : - ParameterDirection.Input, - dsc.Type, dsc.Size, dsc.Precision, dsc.Scale, - DBNull.Value); - } - else - { - if (info.ArgumentNames.Count > i && !string.IsNullOrEmpty(info.ArgumentNames[i])) - { - bool isOut = info.ArgumentNames[i].StartsWith("out_"); - bool isRet = info.ArgumentNames[i].StartsWith("ret_"); - bool isBoth = info.ArgumentNames[i].StartsWith("both_"); + paramName = isOut || isRet ? + info.ArgumentNames[i].Substring(4) : + isBoth ? info.ArgumentNames[i].Substring(5) : + info.ArgumentNames[i]; + } + paramName = dsc.Name ?? paramName; if (isRet) retIsAdded = true; - string paramName = isOut || isRet ? - info.ArgumentNames[i].Substring(4) : - isBoth ? info.ArgumentNames[i].Substring(5) : - info.ArgumentNames[i]; - - if (isOut || isBoth || isRet) + if (isOut || isRet || isBoth) { if (retParams == null) retParams = new Dictionary(); @@ -5515,12 +5616,44 @@ namespace DynamORM isRet ? ParameterDirection.ReturnValue : isBoth ? ParameterDirection.InputOutput : ParameterDirection.Input, - arg == null ? isRet ? DbType.Int32 : DbType.String : arg.GetType().ToDbType(), - isRet ? 4 : 0, - (isOut || isRet) ? DBNull.Value : arg); + dsc.Type, dsc.Size, dsc.Precision, dsc.Scale, + DBNull.Value); } else - cmd.AddParameter(_db, arg); + { + if (info.ArgumentNames.Count > i && !string.IsNullOrEmpty(info.ArgumentNames[i])) + { + bool isOut = info.ArgumentNames[i].StartsWith("out_"); + bool isRet = info.ArgumentNames[i].StartsWith("ret_"); + bool isBoth = info.ArgumentNames[i].StartsWith("both_"); + + if (isRet) + retIsAdded = true; + + string paramName = isOut || isRet ? + info.ArgumentNames[i].Substring(4) : + isBoth ? info.ArgumentNames[i].Substring(5) : + info.ArgumentNames[i]; + + if (isOut || isBoth || isRet) + { + if (retParams == null) + retParams = new Dictionary(); + retParams.Add(paramName, cmd.Parameters.Count); + } + cmd.AddParameter( + _db.GetParameterName(paramName), + isOut ? ParameterDirection.Output : + isRet ? ParameterDirection.ReturnValue : + isBoth ? ParameterDirection.InputOutput : + ParameterDirection.Input, + arg == null ? isRet ? DbType.Int32 : DbType.String : arg.GetType().ToDbType(), + isRet ? 4 : 0, + (isOut || isRet) ? DBNull.Value : arg); + } + else + cmd.AddParameter(_db, arg); + } } } } @@ -5530,7 +5663,13 @@ namespace DynamORM object mainResult = null; - if (types.Count > 0) + if (types.Count == 0 && DynamicProcedureResultBinder.CanReadResults(declaredResultType)) + { + using (IDataReader rdr = cmd.ExecuteReader()) + using (IDataReader cache = rdr.CachedReader()) + mainResult = DynamicProcedureResultBinder.ReadDeclaredResult(declaredResultType, cache); + } + else if (types.Count > 0) { mainResult = types[0].GetDefaultValue(); @@ -5543,7 +5682,7 @@ namespace DynamORM { using (IDataReader rdr = cmd.ExecuteReader()) using (IDataReader cache = rdr.CachedReader()) - mainResult = cache.ToDataTable(binder.Name); + mainResult = cache.ToDataTable(resultName); } else if (types[0].IsGenericEnumerable()) { @@ -5670,9 +5809,9 @@ namespace DynamORM if (mainResult != null) { if (mainResult == DBNull.Value) - res.Add(binder.Name, null); + res.Add(resultName, null); else - res.Add(binder.Name, mainResult); + res.Add(resultName, mainResult); } foreach (KeyValuePair pos in retParams) res.Add(pos.Key, ((IDbDataParameter)cmd.Parameters[pos.Value]).Value); @@ -5686,15 +5825,35 @@ namespace DynamORM else result = res.ToDynamic(); } + else if (declaredResultType != null) + result = DynamicProcedureResultBinder.BindPayload(declaredResultType, resultName, mainResult, res.Where(x => x.Key != resultName).ToDictionary(x => x.Key, x => x.Value), mainResult != null && declaredResultType.IsInstanceOfType(mainResult) ? mainResult : null); else result = res.ToDynamic(); } + else if (declaredResultType != null && mainResult != null && declaredResultType.IsInstanceOfType(mainResult)) + result = mainResult; + else if (declaredResultType != null) + result = DynamicProcedureResultBinder.BindPayload(declaredResultType, resultName, mainResult, null); else result = mainResult; #endregion Handle out params } - return true; + return result; + } + private static TResult ConvertProcedureResult(object result) + { + if (result == null || result == DBNull.Value) + return default(TResult); + + if (result is TResult) + return (TResult)result; + + DynamicTypeMap mapper = DynamicMapperCache.GetMapper(typeof(TResult)); + if (mapper != null) + return (TResult)DynamicExtensions.Map(result, typeof(TResult)); + + return (TResult)typeof(TResult).CastObject(result); } /// Performs application-defined tasks associated with /// freeing, releasing, or resetting unmanaged resources. @@ -6826,6 +6985,43 @@ namespace DynamORM #endregion IExtendedDisposable Members } + /// Typed stored procedure execution handle. + /// Procedure descriptor type. + public class TypedProcedureCall + where TProcedure : IProcedureDescriptor + { + protected readonly DynamicProcedureInvoker Invoker; + + internal TypedProcedureCall(DynamicProcedureInvoker invoker) + { + Invoker = invoker; + } + /// Execute stored procedure descriptor. + /// Optional procedure arguments contract. + /// Procedure result. + public virtual object Exec(object args = null) + { + return Invoker.Exec(args); + } + } + /// Typed stored procedure execution handle with strong result type. + /// Procedure descriptor type. + /// Procedure result type. + public class TypedProcedureCall : TypedProcedureCall + where TProcedure : IProcedureDescriptor + { + internal TypedProcedureCall(DynamicProcedureInvoker invoker) + : base(invoker) + { + } + /// Execute stored procedure descriptor. + /// Optional procedure arguments contract. + /// Procedure result. + public new virtual TResult Exec(object args = null) + { + return Invoker.Exec(args); + } + } namespace Builders { /// Typed join kind used by typed fluent builder APIs. @@ -14416,6 +14612,567 @@ namespace DynamORM return resultTable; } } + internal sealed class DynamicProcedureDescriptor + { + public Type ProcedureType { get; set; } + public Type ArgumentsType { get; set; } + public Type ResultType { get; set; } + public string ProcedureName { get; set; } + public string ResultName { get; set; } + + internal static DynamicProcedureDescriptor Resolve(Type procedureType) + { + if (procedureType == null) + throw new ArgumentNullException("procedureType"); + + Type current = procedureType; + Type argumentsType = null; + Type resultType = null; + + while (current != null && current != typeof(object)) + { + if (current.IsGenericType) + { + Type genericDefinition = current.GetGenericTypeDefinition(); + Type[] genericArguments = current.GetGenericArguments(); + + if (genericDefinition == typeof(Procedure<>) && genericArguments.Length == 1) + { + argumentsType = genericArguments[0]; + break; + } + if (genericDefinition == typeof(Procedure<,>) && genericArguments.Length == 2) + { + argumentsType = genericArguments[0]; + resultType = genericArguments[1]; + break; + } + } + current = current.BaseType; + } + if (argumentsType == null) + throw new InvalidOperationException(string.Format("Type '{0}' is not a typed procedure descriptor.", procedureType.FullName)); + + if (!typeof(IProcedureParameters).IsAssignableFrom(argumentsType)) + throw new InvalidOperationException(string.Format("Procedure descriptor '{0}' declares argument type '{1}' that does not implement IProcedureParameters.", procedureType.FullName, argumentsType.FullName)); + + ProcedureAttribute attr = procedureType.GetCustomAttributes(typeof(ProcedureAttribute), true) + .Cast() + .FirstOrDefault(); + + string name = attr != null && !string.IsNullOrEmpty(attr.Name) ? attr.Name : procedureType.Name; + string owner = attr != null ? attr.Owner : null; + + return new DynamicProcedureDescriptor + { + ProcedureType = procedureType, + ArgumentsType = argumentsType, + ResultType = resultType, + ProcedureName = string.IsNullOrEmpty(owner) ? name : string.Format("{0}.{1}", owner, name), + ResultName = name + }; + } + } + internal static class DynamicProcedureParameterBinder + { + internal sealed class BindingResult + { + public bool ReturnValueAdded { get; set; } + public Dictionary ReturnParameters { get; set; } + } + internal static bool CanBind(object item) + { + if (!DynamicProcedureResultBinder.IsProcedureContract(item)) + return false; + + return GetBindableProperties(item.GetType()).Any(); + } + internal static BindingResult Bind(DynamicDatabase db, IDbCommand cmd, object item) + { + if (db == null) + throw new ArgumentNullException("db"); + if (cmd == null) + throw new ArgumentNullException("cmd"); + if (item == null) + throw new ArgumentNullException("item"); + + BindingResult result = new BindingResult(); + + foreach (PropertyInfo property in GetBindableProperties(item.GetType())) + { + ProcedureParameterAttribute procAttr = property.GetCustomAttributes(typeof(ProcedureParameterAttribute), true).Cast().FirstOrDefault(); + ColumnAttribute colAttr = property.GetCustomAttributes(typeof(ColumnAttribute), true).Cast().FirstOrDefault(); + + string name = (procAttr != null && !string.IsNullOrEmpty(procAttr.Name) ? procAttr.Name : null) + ?? (colAttr != null && !string.IsNullOrEmpty(colAttr.Name) ? colAttr.Name : null) + ?? property.Name; + + ParameterDirection direction = procAttr == null ? ParameterDirection.Input : procAttr.Direction; + object value = property.GetValue(item, null); + DbType dbType = ResolveDbType(property.PropertyType, value, procAttr, colAttr, direction); + int size = ResolveSize(dbType, value, procAttr, colAttr, direction); + byte precision = (procAttr != null && procAttr.Precision != ProcedureParameterAttribute.UnspecifiedByte ? procAttr.Precision : default(byte)); + byte scale = (procAttr != null && procAttr.Scale != ProcedureParameterAttribute.UnspecifiedByte ? procAttr.Scale : default(byte)); + + if (procAttr == null && colAttr != null) + { + precision = colAttr.Precision ?? precision; + scale = colAttr.Scale ?? scale; + } + if (direction == ParameterDirection.ReturnValue) + result.ReturnValueAdded = true; + + if (direction == ParameterDirection.Output || direction == ParameterDirection.InputOutput || direction == ParameterDirection.ReturnValue) + { + if (result.ReturnParameters == null) + result.ReturnParameters = new Dictionary(); + result.ReturnParameters.Add(name, cmd.Parameters.Count); + } + cmd.AddParameter( + db.GetParameterName(name), + direction, + dbType, + size, + precision, + scale, + direction == ParameterDirection.Output || direction == ParameterDirection.ReturnValue ? DBNull.Value : (value ?? DBNull.Value)); + } + return result; + } + private static IEnumerable GetBindableProperties(Type type) + { + return type.GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(x => x.CanRead && x.GetIndexParameters().Length == 0) + .Where(x => x.GetCustomAttributes(typeof(ProcedureParameterAttribute), true).Any() || x.GetCustomAttributes(typeof(ColumnAttribute), true).Any()) + .OrderBy(x => + { + ProcedureParameterAttribute attr = x.GetCustomAttributes(typeof(ProcedureParameterAttribute), true).Cast().FirstOrDefault(); + return attr == null ? int.MaxValue : attr.Order; + }) + .ThenBy(x => x.MetadataToken); + } + private static DbType ResolveDbType(Type propertyType, object value, ProcedureParameterAttribute procAttr, ColumnAttribute colAttr, ParameterDirection direction) + { + if (procAttr != null && (int)procAttr.DbType != ProcedureParameterAttribute.UnspecifiedDbType) + return procAttr.DbType; + + if (colAttr != null && colAttr.Type.HasValue) + return colAttr.Type.Value; + + Type targetType = Nullable.GetUnderlyingType(propertyType) ?? propertyType; + + if (targetType == typeof(object) && value != null) + targetType = value.GetType(); + + if (value == null && direction == ParameterDirection.ReturnValue) + return DbType.Int32; + + return targetType.ToDbType(); + } + private static int ResolveSize(DbType dbType, object value, ProcedureParameterAttribute procAttr, ColumnAttribute colAttr, ParameterDirection direction) + { + if (procAttr != null && procAttr.Size != ProcedureParameterAttribute.UnspecifiedSize) + return procAttr.Size; + + if (colAttr != null && colAttr.Size.HasValue) + return colAttr.Size.Value; + + if (direction == ParameterDirection.ReturnValue) + return 4; + + if (dbType == DbType.AnsiString || dbType == DbType.AnsiStringFixedLength) + { + if (value != null) + return value.ToString().Length > 8000 ? -1 : 8000; + return 8000; + } + if (dbType == DbType.String || dbType == DbType.StringFixedLength) + { + if (value != null) + return value.ToString().Length > 4000 ? -1 : 4000; + return 4000; + } + return 0; + } + } + internal static class DynamicProcedureResultBinder + { + private sealed class ResultMemberBinding + { + public ProcedureResultAttribute Attribute { get; set; } + public MemberInfo Member { get; set; } + public Type MemberType { get; set; } + public int SortOrder { get; set; } + + public void SetValue(object instance, object value) + { + PropertyInfo property = Member as PropertyInfo; + if (property != null) + { + property.SetValue(instance, value, null); + return; + } + FieldInfo field = Member as FieldInfo; + if (field != null) + field.SetValue(instance, value); + } + } + internal static bool IsProcedureContract(object item) + { + return item is IProcedureParameters; + } + internal static Type GetDeclaredResultType(object item) + { + if (item == null) + return null; + + Type iface = item.GetType().GetInterfaces() + .FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IProcedureParameters<>)); + + return iface == null ? null : iface.GetGenericArguments()[0]; + } + internal static bool CanReadResults(Type resultType) + { + return resultType != null && + (typeof(IProcedureResultReader).IsAssignableFrom(resultType) || GetResultMemberBindings(resultType).Count > 0); + } + internal static bool HasDeclaredResultBinding(Type resultType) + { + return CanReadResults(resultType); + } + internal static object CreateDeclaredResult(Type resultType) + { + if (resultType == null) + return null; + + DynamicTypeMap mapper = DynamicMapperCache.GetMapper(resultType); + if (mapper != null) + return mapper.Creator(); + + return Activator.CreateInstance(resultType); + } + internal static object ReadDeclaredResult(Type resultType, IDataReader reader) + { + if (!CanReadResults(resultType)) + throw new InvalidOperationException(string.Format("Type '{0}' does not declare a supported procedure result binding.", resultType == null ? "" : resultType.FullName)); + + object instance = CreateDeclaredResult(resultType); + + IList bindings = GetResultMemberBindings(resultType); + if (bindings.Count > 0) + BindResultMembers(instance, reader, bindings); + else + ((IProcedureResultReader)instance).ReadResults(reader); + + return instance; + } + internal static object BindPayload(Type resultType, string mainResultName, object mainResult, IDictionary returnValues, object existing = null) + { + if (resultType == null) + return existing ?? returnValues.ToDynamic(); + + Dictionary payload = new Dictionary(); + + if (mainResultName != null) + payload[mainResultName] = mainResult == DBNull.Value ? null : mainResult; + + if (returnValues != null) + foreach (KeyValuePair item in returnValues) + payload[item.Key] = item.Value == DBNull.Value ? null : item.Value; + + DynamicTypeMap mapper = DynamicMapperCache.GetMapper(resultType); + object instance = existing; + + if (mapper != null) + instance = mapper.Map(payload.ToDynamic(), existing ?? mapper.Creator()); + else if (instance == null) + instance = payload.ToDynamic(); + + if (instance != null && resultType.IsInstanceOfType(instance)) + BindMainResultMembers(instance, mainResult); + + return instance; + } + private static IList GetResultMemberBindings(Type resultType) + { + var properties = resultType.GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(x => x.CanWrite && x.GetIndexParameters().Length == 0) + .Select(x => new ResultMemberBinding + { + Member = x, + MemberType = x.PropertyType, + SortOrder = x.MetadataToken, + Attribute = x.GetCustomAttributes(typeof(ProcedureResultAttribute), true).Cast().FirstOrDefault() + }); + + var fields = resultType.GetFields(BindingFlags.Instance | BindingFlags.Public) + .Where(x => !x.IsInitOnly && !x.IsLiteral) + .Select(x => new ResultMemberBinding + { + Member = x, + MemberType = x.FieldType, + SortOrder = x.MetadataToken, + Attribute = x.GetCustomAttributes(typeof(ProcedureResultAttribute), true).Cast().FirstOrDefault() + }); + + return properties.Concat(fields) + .Where(x => x.Attribute != null) + .Where(x => !IsMainResultBinding(x.Attribute)) + .OrderBy(x => x.Attribute.ResultIndex) + .ThenBy(x => x.SortOrder) + .ToList(); + } + private static IList GetMainResultBindings(Type resultType) + { + var properties = resultType.GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(x => x.CanWrite && x.GetIndexParameters().Length == 0) + .Select(x => new ResultMemberBinding + { + Member = x, + MemberType = x.PropertyType, + SortOrder = x.MetadataToken, + Attribute = x.GetCustomAttributes(typeof(ProcedureResultAttribute), true).Cast().FirstOrDefault() + }); + + var fields = resultType.GetFields(BindingFlags.Instance | BindingFlags.Public) + .Where(x => !x.IsInitOnly && !x.IsLiteral) + .Select(x => new ResultMemberBinding + { + Member = x, + MemberType = x.FieldType, + SortOrder = x.MetadataToken, + Attribute = x.GetCustomAttributes(typeof(ProcedureResultAttribute), true).Cast().FirstOrDefault() + }); + + return properties.Concat(fields) + .Where(x => x.Attribute != null) + .Where(x => IsMainResultBinding(x.Attribute)) + .OrderBy(x => x.SortOrder) + .ToList(); + } + private static void BindResultMembers(object instance, IDataReader reader, IList bindings) + { + ValidateBindings(instance.GetType(), bindings); + + int currentIndex = 0; + bool hasCurrent = true; + + for (int i = 0; i < bindings.Count; i++) + { + ResultMemberBinding binding = bindings[i]; + while (hasCurrent && currentIndex < binding.Attribute.ResultIndex) + { + hasCurrent = reader.NextResult(); + currentIndex++; + } + if (!hasCurrent || currentIndex != binding.Attribute.ResultIndex) + break; + + object value = ReadResultValue(binding.MemberType, binding.Attribute, reader); + binding.SetValue(instance, value); + + if (i + 1 < bindings.Count) + { + hasCurrent = reader.NextResult(); + currentIndex++; + } + } + } + private static void ValidateBindings(Type resultType, IList bindings) + { + var duplicates = bindings.GroupBy(x => x.Attribute.ResultIndex).FirstOrDefault(x => x.Count() > 1); + if (duplicates != null) + throw new InvalidOperationException(string.Format("Type '{0}' defines multiple ProcedureResultAttribute bindings for result index {1}.", resultType.FullName, duplicates.Key)); + } + private static void BindMainResultMembers(object instance, object mainResult) + { + if (instance == null) + return; + + IList bindings = GetMainResultBindings(instance.GetType()); + if (bindings.Count == 0) + return; + if (bindings.Count > 1) + throw new InvalidOperationException(string.Format("Type '{0}' defines multiple ProcedureResultAttribute bindings for the main procedure result.", instance.GetType().FullName)); + + ResultMemberBinding binding = bindings[0]; + object value = ConvertScalarValue(binding.MemberType, mainResult == DBNull.Value ? null : mainResult); + binding.SetValue(instance, value); + } + private static object ReadResultValue(Type propertyType, ProcedureResultAttribute attr, IDataReader reader) + { + Type elementType; + + if (propertyType == typeof(DataTable)) + return reader.ToDataTable(string.IsNullOrEmpty(attr.Name) ? null : attr.Name); + + if (TryGetEnumerableElementType(propertyType, out elementType)) + { + if (elementType == typeof(object)) + return reader.EnumerateReader().ToList(); + + if (elementType.IsValueType || elementType == typeof(string) || elementType == typeof(Guid)) + return ReadSimpleList(propertyType, elementType, attr, reader); + + return ReadComplexList(propertyType, elementType, reader); + } + if (propertyType.IsValueType || propertyType == typeof(string) || propertyType == typeof(Guid)) + return ReadSimpleValue(propertyType, attr, reader); + + return ReadComplexValue(propertyType, reader); + } + private static object ReadSimpleValue(Type propertyType, ProcedureResultAttribute attr, IDataReader reader) + { + object value = null; + int ordinal = -1; + bool haveRow = false; + + while (reader.Read()) + { + if (!haveRow) + { + ordinal = GetOrdinal(reader, attr); + value = reader.IsDBNull(ordinal) ? null : reader[ordinal]; + haveRow = true; + } + } + if (!haveRow || value == null) + return propertyType.GetDefaultValue(); + + return ConvertScalarValue(propertyType, value); + } + private static object ReadSimpleList(Type propertyType, Type elementType, ProcedureResultAttribute attr, IDataReader reader) + { + Type targetElementType = Nullable.GetUnderlyingType(elementType) ?? elementType; + Type listType = typeof(List<>).MakeGenericType(elementType); + var list = (System.Collections.IList)Activator.CreateInstance(listType); + int ordinal = -1; + bool initialized = false; + + while (reader.Read()) + { + if (!initialized) + { + ordinal = GetOrdinal(reader, attr); + initialized = true; + } + if (reader.IsDBNull(ordinal)) + { + list.Add(elementType.GetDefaultValue()); + continue; + } + object value = reader[ordinal]; + if (targetElementType == typeof(Guid)) + { + Guid g; + if (Guid.TryParse(value.ToString(), out g)) + list.Add(elementType == typeof(Guid) ? (object)g : new Guid?(g)); + else + list.Add(elementType.GetDefaultValue()); + } + else if (targetElementType.IsEnum) + list.Add(Enum.ToObject(targetElementType, value)); + else + list.Add(targetElementType == elementType ? elementType.CastObject(value) : targetElementType.CastObject(value)); + } + if (propertyType.IsArray) + { + Array array = Array.CreateInstance(elementType, list.Count); + list.CopyTo(array, 0); + return array; + } + return list; + } + private static object ReadComplexValue(Type propertyType, IDataReader reader) + { + object value = null; + bool haveRow = false; + + while (reader.Read()) + { + if (!haveRow) + { + value = (reader.RowToDynamic() as object).Map(propertyType); + haveRow = true; + } + } + return value; + } + private static object ReadComplexList(Type propertyType, Type elementType, IDataReader reader) + { + Type listType = typeof(List<>).MakeGenericType(elementType); + var list = (System.Collections.IList)Activator.CreateInstance(listType); + + while (reader.Read()) + list.Add((reader.RowToDynamic() as object).Map(elementType)); + + if (propertyType.IsArray) + { + Array array = Array.CreateInstance(elementType, list.Count); + list.CopyTo(array, 0); + return array; + } + return list; + } + private static bool TryGetEnumerableElementType(Type type, out Type elementType) + { + elementType = null; + + if (type == typeof(string) || type == typeof(byte[])) + return false; + + if (type.IsArray) + { + elementType = type.GetElementType(); + return true; + } + if (type.IsGenericType) + { + Type generic = type.GetGenericTypeDefinition(); + if (generic == typeof(List<>) || generic == typeof(IList<>) || generic == typeof(IEnumerable<>)) + { + elementType = type.GetGenericArguments()[0]; + return true; + } + } + Type enumerableInterface = type.GetInterfaces().FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IEnumerable<>)); + if (enumerableInterface != null) + { + elementType = enumerableInterface.GetGenericArguments()[0]; + return true; + } + return false; + } + private static int GetOrdinal(IDataReader reader, ProcedureResultAttribute attr) + { + if (attr == null || string.IsNullOrEmpty(attr.ColumnName)) + return 0; + return reader.GetOrdinal(attr.ColumnName); + } + private static bool IsMainResultBinding(ProcedureResultAttribute attribute) + { + return attribute != null && attribute.ResultIndex < 0; + } + private static object ConvertScalarValue(Type targetType, object value) + { + if (value == null) + return targetType.GetDefaultValue(); + + Type underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType; + + if (underlyingType == typeof(Guid)) + { + Guid g; + if (Guid.TryParse(value.ToString(), out g)) + return targetType == typeof(Guid) ? (object)g : new Guid?(g); + return targetType.GetDefaultValue(); + } + if (underlyingType.IsEnum) + return Enum.ToObject(underlyingType, value); + + return underlyingType == targetType ? targetType.CastObject(value) : underlyingType.CastObject(value); + } + } /// Framework detection and specific implementations. public static class FrameworkTools { @@ -17411,6 +18168,93 @@ namespace DynamORM public class IgnoreAttribute : Attribute { } + /// Allows to add stored procedure metadata to class. + [AttributeUsage(AttributeTargets.Class)] + public class ProcedureAttribute : Attribute + { + /// Gets or sets procedure owner name. + public string Owner { get; set; } + + /// Gets or sets procedure name. + public string Name { get; set; } + + /// Gets or sets a value indicating whether metadata overrides other defaults. + public bool Override { get; set; } + } + /// Declares metadata for object-based stored procedure parameters. + [AttributeUsage(AttributeTargets.Property)] + public class ProcedureParameterAttribute : ColumnAttribute + { + /// Sentinel used when database type was not provided. + public const int UnspecifiedDbType = -1; + + /// Sentinel used when size was not provided. + public const int UnspecifiedSize = -1; + + /// Sentinel used when precision or scale was not provided. + public const byte UnspecifiedByte = byte.MaxValue; + + /// Gets or sets parameter direction. Defaults to input. + public ParameterDirection Direction { get; set; } + + /// Gets or sets explicit parameter order. Lower values are emitted first. + public int Order { get; set; } + + /// Gets or sets parameter database type. + public DbType DbType { get; set; } + + /// Gets or sets parameter size. + public new int Size { get; set; } + + /// Gets or sets parameter precision. + public new byte Precision { get; set; } + + /// Gets or sets parameter scale. + public new byte Scale { get; set; } + + /// Initializes a new instance of the class. + public ProcedureParameterAttribute() + { + Direction = ParameterDirection.Input; + Order = int.MaxValue; + DbType = (DbType)UnspecifiedDbType; + Size = UnspecifiedSize; + Precision = UnspecifiedByte; + Scale = UnspecifiedByte; + } + /// Initializes a new instance of the class. + public ProcedureParameterAttribute(string name) + : this() + { + Name = name; + } + } + /// Declares mapping of a typed procedure result property to a specific result set. + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] + public class ProcedureResultAttribute : Attribute + { + /// Main procedure result marker. + public const int MainResultIndex = -1; + + /// Initializes a new instance of the class. + public ProcedureResultAttribute() + : this(MainResultIndex) + { + } + /// Initializes a new instance of the class. + public ProcedureResultAttribute(int resultIndex) + { + ResultIndex = resultIndex; + } + /// Gets result-set index in reader order, zero based. + public int ResultIndex { get; private set; } + + /// Gets or sets optional column name for scalar/simple list extraction. + public string ColumnName { get; set; } + + /// Gets or sets optional name used for DataTable. + public string Name { get; set; } + } /// Allows to add table name to class. [AttributeUsage(AttributeTargets.Class)] public class TableAttribute : Attribute @@ -17858,6 +18702,45 @@ namespace DynamORM _database = null; } } + /// Exposes typed stored procedure descriptor metadata. + public interface IProcedureDescriptor + { + } + /// Exposes typed stored procedure descriptor metadata with explicit result type. + /// Procedure result type. + public interface IProcedureDescriptor : IProcedureDescriptor + { + } + /// Base class for typed stored procedure descriptors. + /// Procedure arguments contract. + public abstract class Procedure + : IProcedureDescriptor + where TArgs : IProcedureParameters + { + } + /// Base class for typed stored procedure descriptors with explicit result model. + /// Procedure arguments contract. + /// Procedure result model. + public abstract class Procedure : Procedure, IProcedureDescriptor + where TArgs : IProcedureParameters + { + } + /// Marks an object as an explicit stored procedure parameter contract. + public interface IProcedureParameters + { + } + /// Marks an object as a stored procedure parameter contract with a declared typed result model. + /// Typed result model. + public interface IProcedureParameters : IProcedureParameters + { + } + /// Allows typed procedure result models to consume multiple result sets directly. + public interface IProcedureResultReader + { + /// Reads all required result sets from the procedure reader. + /// Procedure result reader, usually a cached reader. + void ReadResults(IDataReader reader); + } } namespace TypedSql { diff --git a/DynamORM.Tests/Helpers/FakeDbCommand.cs b/DynamORM.Tests/Helpers/FakeDbCommand.cs new file mode 100644 index 0000000..4e2f312 --- /dev/null +++ b/DynamORM.Tests/Helpers/FakeDbCommand.cs @@ -0,0 +1,135 @@ +/* + * DynamORM - Dynamic Object-Relational Mapping library. + * Copyright (c) 2012-2026, Grzegorz Russek (grzegorz.russek@gmail.com) + * All rights reserved. + */ + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Data; + +namespace DynamORM.Tests.Helpers +{ + internal sealed class FakeDbCommand : IDbCommand + { + private readonly FakeParameterCollection _parameters = new FakeParameterCollection(); + + public string CommandText { get; set; } + public int CommandTimeout { get; set; } + public CommandType CommandType { get; set; } + public IDbConnection Connection { get; set; } + public IDataParameterCollection Parameters { get { return _parameters; } } + public IDbTransaction Transaction { get; set; } + public UpdateRowSource UpdatedRowSource { get; set; } + + public void Cancel() { } + public IDbDataParameter CreateParameter() { return new FakeDbParameter(); } + public void Dispose() { } + public int ExecuteNonQuery() { throw new NotSupportedException(); } + public IDataReader ExecuteReader() { throw new NotSupportedException(); } + public IDataReader ExecuteReader(CommandBehavior behavior) { throw new NotSupportedException(); } + public object ExecuteScalar() { throw new NotSupportedException(); } + public void Prepare() { } + } + + internal sealed class FakeDbParameter : IDbDataParameter + { + public byte Precision { get; set; } + public byte Scale { get; set; } + public int Size { get; set; } + public DbType DbType { get; set; } + public ParameterDirection Direction { get; set; } + public bool IsNullable { get { return true; } } + public string ParameterName { get; set; } + public string SourceColumn { get; set; } + public DataRowVersion SourceVersion { get; set; } + public object Value { get; set; } + } + + internal sealed class FakeParameterCollection : IDataParameterCollection + { + private readonly List _items = new List(); + + public object this[string parameterName] + { + get { return _items.Find(x => string.Equals(((IDbDataParameter)x).ParameterName, parameterName, StringComparison.Ordinal)); } + set { throw new NotSupportedException(); } + } + + public object this[int index] + { + get { return _items[index]; } + set { _items[index] = value; } + } + + public bool IsFixedSize { get { return false; } } + public bool IsReadOnly { get { return false; } } + public int Count { get { return _items.Count; } } + public bool IsSynchronized { get { return false; } } + public object SyncRoot { get { return this; } } + + public int Add(object value) + { + _items.Add(value); + return _items.Count - 1; + } + + public void Clear() + { + _items.Clear(); + } + + public bool Contains(string parameterName) + { + return _items.Exists(x => string.Equals(((IDbDataParameter)x).ParameterName, parameterName, StringComparison.Ordinal)); + } + + public bool Contains(object value) + { + return _items.Contains(value); + } + + public void CopyTo(Array array, int index) + { + _items.ToArray().CopyTo(array, index); + } + + public IEnumerator GetEnumerator() + { + return _items.GetEnumerator(); + } + + public int IndexOf(string parameterName) + { + return _items.FindIndex(x => string.Equals(((IDbDataParameter)x).ParameterName, parameterName, StringComparison.Ordinal)); + } + + public int IndexOf(object value) + { + return _items.IndexOf(value); + } + + public void Insert(int index, object value) + { + _items.Insert(index, value); + } + + public void Remove(object value) + { + _items.Remove(value); + } + + public void RemoveAt(string parameterName) + { + int index = IndexOf(parameterName); + if (index >= 0) + _items.RemoveAt(index); + } + + public void RemoveAt(int index) + { + _items.RemoveAt(index); + } + } +} diff --git a/DynamORM.Tests/Helpers/FakeMultiResultDataReader.cs b/DynamORM.Tests/Helpers/FakeMultiResultDataReader.cs new file mode 100644 index 0000000..f94b5a8 --- /dev/null +++ b/DynamORM.Tests/Helpers/FakeMultiResultDataReader.cs @@ -0,0 +1,135 @@ +/* + * DynamORM - Dynamic Object-Relational Mapping library. + * Copyright (c) 2012-2026, Grzegorz Russek (grzegorz.russek@gmail.com) + * All rights reserved. + */ + +using System; +using System.Collections.Generic; +using System.Data; + +namespace DynamORM.Tests.Helpers +{ + internal sealed class FakeMultiResultDataReader : IDataReader + { + private sealed class ResultSet + { + public string[] Names; + public Type[] Types; + public object[][] Rows; + } + + private readonly List _sets = new List(); + private int _setIndex; + private int _rowIndex = -1; + + public FakeMultiResultDataReader(params Tuple[] sets) + { + foreach (var set in sets) + _sets.Add(new ResultSet + { + Names = set.Item1, + Types = set.Item2, + Rows = set.Item3 + }); + } + + private ResultSet Current { get { return _sets[_setIndex]; } } + + public object this[string name] { get { return GetValue(GetOrdinal(name)); } } + public object this[int i] { get { return GetValue(i); } } + public int Depth { get { return 0; } } + public bool IsClosed { get; private set; } + public int RecordsAffected { get { return 0; } } + public int FieldCount { get { return Current.Names.Length; } } + + public void Close() { IsClosed = true; } + public void Dispose() { Close(); } + public bool GetBoolean(int i) { return (bool)GetValue(i); } + public byte GetByte(int i) { return (byte)GetValue(i); } + public long GetBytes(int i, long fieldOffset, byte[] buffer, int bufferoffset, int length) { throw new NotSupportedException(); } + public char GetChar(int i) { return (char)GetValue(i); } + public long GetChars(int i, long fieldoffset, char[] buffer, int bufferoffset, int length) { throw new NotSupportedException(); } + public IDataReader GetData(int i) { throw new NotSupportedException(); } + public string GetDataTypeName(int i) { return GetFieldType(i).Name; } + public DateTime GetDateTime(int i) { return (DateTime)GetValue(i); } + public decimal GetDecimal(int i) { return (decimal)GetValue(i); } + public double GetDouble(int i) { return (double)GetValue(i); } + public Type GetFieldType(int i) { return Current.Types[i]; } + public float GetFloat(int i) { return (float)GetValue(i); } + public Guid GetGuid(int i) { return (Guid)GetValue(i); } + public short GetInt16(int i) { return Convert.ToInt16(GetValue(i)); } + public int GetInt32(int i) { return Convert.ToInt32(GetValue(i)); } + public long GetInt64(int i) { return Convert.ToInt64(GetValue(i)); } + public string GetName(int i) { return Current.Names[i]; } + public int GetOrdinal(string name) + { + for (int i = 0; i < Current.Names.Length; i++) + if (string.Equals(Current.Names[i], name, StringComparison.OrdinalIgnoreCase)) + return i; + return -1; + } + public DataTable GetSchemaTable() + { + DataTable schema = new DataTable(); + schema.Columns.Add("ColumnName", typeof(string)); + schema.Columns.Add("ColumnOrdinal", typeof(int)); + schema.Columns.Add("ColumnSize", typeof(int)); + schema.Columns.Add("NumericPrecision", typeof(short)); + schema.Columns.Add("NumericScale", typeof(short)); + schema.Columns.Add("DataType", typeof(Type)); + schema.Columns.Add("ProviderType", typeof(int)); + schema.Columns.Add("NativeType", typeof(int)); + schema.Columns.Add("AllowDBNull", typeof(bool)); + schema.Columns.Add("IsUnique", typeof(bool)); + schema.Columns.Add("IsKey", typeof(bool)); + schema.Columns.Add("IsAutoIncrement", typeof(bool)); + + for (int i = 0; i < Current.Names.Length; i++) + { + DataRow row = schema.NewRow(); + row[0] = Current.Names[i]; + row[1] = i; + row[2] = 0; + row[3] = 0; + row[4] = 0; + row[5] = Current.Types[i]; + row[6] = 0; + row[7] = 0; + row[8] = true; + row[9] = false; + row[10] = false; + row[11] = false; + schema.Rows.Add(row); + } + + return schema; + } + public string GetString(int i) { return (string)GetValue(i); } + public object GetValue(int i) { return Current.Rows[_rowIndex][i]; } + public int GetValues(object[] values) + { + int count = Math.Min(values.Length, FieldCount); + for (int i = 0; i < count; i++) + values[i] = GetValue(i); + return count; + } + public bool IsDBNull(int i) { return GetValue(i) == null || GetValue(i) == DBNull.Value; } + public bool NextResult() + { + if (_setIndex + 1 >= _sets.Count) + return false; + + _setIndex++; + _rowIndex = -1; + return true; + } + public bool Read() + { + if (_rowIndex + 1 >= Current.Rows.Length) + return false; + _rowIndex++; + return true; + } + } +} diff --git a/DynamORM.Tests/Helpers/ProcedureParameterModels.cs b/DynamORM.Tests/Helpers/ProcedureParameterModels.cs new file mode 100644 index 0000000..093a2be --- /dev/null +++ b/DynamORM.Tests/Helpers/ProcedureParameterModels.cs @@ -0,0 +1,156 @@ +/* + * DynamORM - Dynamic Object-Relational Mapping library. + * Copyright (c) 2012-2026, Grzegorz Russek (grzegorz.russek@gmail.com) + * All rights reserved. + */ + +using System.Data; +using DynamORM.Mapper; +using DynamORM.Objects; + +namespace DynamORM.Tests.Helpers +{ + public class ProcedureParameterObject : IProcedureParameters + { + [ProcedureParameter("code", Order = 2, DbType = DbType.String, Size = 32)] + public string Code { get; set; } + + [ProcedureParameter("result", Direction = ParameterDirection.Output, Order = 3, DbType = DbType.Int32)] + public int Result { get; set; } + + [ProcedureParameter("description", Direction = ParameterDirection.InputOutput, Order = 4, DbType = DbType.String, Size = 256)] + public string Description { get; set; } + + [ProcedureParameter("status", Direction = ParameterDirection.ReturnValue, Order = 1)] + public int Status { get; set; } + } + + public class ProcedureParameterColumnFallbackObject : IProcedureParameters + { + [DynamORM.Mapper.Column("code", false, DbType.String, 64)] + public string Code { get; set; } + } + + public class ProcedureParameterResult + { + [DynamORM.Mapper.Column("sp_Test")] + public int MainResult { get; set; } + + [DynamORM.Mapper.Column("result")] + public int Result { get; set; } + + [DynamORM.Mapper.Column("description")] + public string Description { get; set; } + + [DynamORM.Mapper.Column("status")] + public int Status { get; set; } + } + + public class ProcedureParameterAttributeMainResult + { + [ProcedureResult] + public int MainResult { get; set; } + + [DynamORM.Mapper.Column("status")] + public int Status { get; set; } + } + + public class ProcedureParameterAttributeMainResultField + { + [ProcedureResult(-1)] + public int MainResult; + } + + public class ProcedureMultiResult : IProcedureResultReader + { + [DynamORM.Mapper.Column("sp_Multi")] + public int MainResult { get; set; } + + [DynamORM.Mapper.Column("status")] + public int Status { get; set; } + + public System.Collections.Generic.List Codes { get; private set; } = new System.Collections.Generic.List(); + public System.Collections.Generic.List States { get; private set; } = new System.Collections.Generic.List(); + + public void ReadResults(IDataReader reader) + { + while (reader.Read()) + Codes.Add(reader.GetString(0)); + + if (reader.NextResult()) + while (reader.Read()) + States.Add(reader.GetInt32(0)); + } + } + + public class ProcedureAttributedResult + { + [DynamORM.Mapper.Column("sp_Test")] + public int MainResult { get; set; } + + [DynamORM.Mapper.Column("status")] + public int Status { get; set; } + + [ProcedureResult(0, ColumnName = "Code")] + public string FirstCode { get; set; } + + [ProcedureResult(1, ColumnName = "Code")] + public System.Collections.Generic.List Codes { get; set; } + + [ProcedureResult(2, ColumnName = "State")] + public int[] States { get; set; } + + [ProcedureResult(3)] + public Users User { get; set; } + + [ProcedureResult(4)] + public System.Collections.Generic.List AllUsers { get; set; } + + [ProcedureResult(5, Name = "codes_table")] + public DataTable CodesTable { get; set; } + } + + public class ProcedureAttributedResultArgs : IProcedureParameters + { + [ProcedureParameter("status", Direction = ParameterDirection.Output, Order = 1, DbType = DbType.Int32)] + public int Status { get; set; } + } + + public class ProcedureAttributedFieldResult + { + [ProcedureResult(0)] + public string FirstCode; + + [ProcedureResult(1)] + public System.Collections.Generic.List Codes; + + [ProcedureResult(2, ColumnName = "State")] + public System.Collections.Generic.IEnumerable States; + + [ProcedureResult(3)] + public Users User; + + [ProcedureResult(4, Name = "users_table")] + public DataTable UsersTable; + } + + public class ProcedureMultiResultArgs : IProcedureParameters + { + [ProcedureParameter("status", Direction = ParameterDirection.Output, Order = 1, DbType = DbType.Int32)] + public int Status { get; set; } + } + + [Procedure(Name = "sp_exec_test", Owner = "dbo")] + public class ExecProcedureDescriptor : Procedure + { + } + + public class ExecProcedureDefaultDescriptor : Procedure + { + } + + [Procedure(Name = "sp_exec_result")] + public class ExecProcedureDescriptorWithExplicitResult : Procedure + { + } +} diff --git a/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs b/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs new file mode 100644 index 0000000..1b099f6 --- /dev/null +++ b/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs @@ -0,0 +1,383 @@ +/* + * DynamORM - Dynamic Object-Relational Mapping library. + * Copyright (c) 2012-2026, Grzegorz Russek (grzegorz.russek@gmail.com) + * All rights reserved. + */ + +using System.Data; +using System.Dynamic; +using DynamORM.Helpers; +using DynamORM.Tests.Helpers; +using NUnit.Framework; + +namespace DynamORM.Tests.Procedure +{ + [TestFixture] + public class ProcedureParameterBinderTests : TestsBase + { + [SetUp] + public void SetUp() + { + CreateTestDatabase(); + CreateDynamicDatabase( + DynamicDatabaseOptions.SingleConnection | + DynamicDatabaseOptions.SingleTransaction | + DynamicDatabaseOptions.SupportStoredProcedures | + DynamicDatabaseOptions.SupportStoredProceduresResult | + DynamicDatabaseOptions.SupportSchema); + } + + [TearDown] + public void TearDown() + { + DestroyDynamicDatabase(); + DestroyTestDatabase(); + } + + [Test] + public void TestCanBindProcedureParameterObject() + { + Assert.IsTrue(DynamicProcedureParameterBinder.CanBind(new ProcedureParameterObject())); + Assert.IsTrue(DynamicProcedureParameterBinder.CanBind(new ProcedureParameterColumnFallbackObject())); + Assert.IsFalse(DynamicProcedureParameterBinder.CanBind("x")); + Assert.IsFalse(DynamicProcedureParameterBinder.CanBind(5)); + } + + [Test] + public void TestBindUsesAttributeMetadataAndOrder() + { + using (IDbCommand cmd = new FakeDbCommand()) + { + var result = DynamicProcedureParameterBinder.Bind(Database, cmd, new ProcedureParameterObject + { + Code = "ABC", + Description = "seed" + }); + + Assert.IsTrue(result.ReturnValueAdded); + Assert.NotNull(result.ReturnParameters); + Assert.AreEqual(3, result.ReturnParameters.Count); + Assert.AreEqual(4, cmd.Parameters.Count); + + var p0 = (IDbDataParameter)cmd.Parameters[0]; + var p1 = (IDbDataParameter)cmd.Parameters[1]; + var p2 = (IDbDataParameter)cmd.Parameters[2]; + var p3 = (IDbDataParameter)cmd.Parameters[3]; + + Assert.AreEqual(Database.GetParameterName("status"), p0.ParameterName); + Assert.AreEqual(ParameterDirection.ReturnValue, p0.Direction); + Assert.AreEqual(DbType.Int32, p0.DbType); + Assert.AreEqual(4, p0.Size); + + Assert.AreEqual(Database.GetParameterName("code"), p1.ParameterName); + Assert.AreEqual(ParameterDirection.Input, p1.Direction); + Assert.AreEqual(DbType.String, p1.DbType); + Assert.AreEqual(32, p1.Size); + Assert.AreEqual("ABC", p1.Value); + + Assert.AreEqual(Database.GetParameterName("result"), p2.ParameterName); + Assert.AreEqual(ParameterDirection.Output, p2.Direction); + Assert.AreEqual(DbType.Int32, p2.DbType); + Assert.AreEqual(DBNull.Value, p2.Value); + + Assert.AreEqual(Database.GetParameterName("description"), p3.ParameterName); + Assert.AreEqual(ParameterDirection.InputOutput, p3.Direction); + Assert.AreEqual(DbType.String, p3.DbType); + Assert.AreEqual(256, p3.Size); + Assert.AreEqual("seed", p3.Value); + + Assert.AreEqual(0, result.ReturnParameters["status"]); + Assert.AreEqual(2, result.ReturnParameters["result"]); + Assert.AreEqual(3, result.ReturnParameters["description"]); + } + } + + [Test] + public void TestBindFallsBackToColumnAttributeMetadata() + { + using (IDbCommand cmd = new FakeDbCommand()) + { + var result = DynamicProcedureParameterBinder.Bind(Database, cmd, new ProcedureParameterColumnFallbackObject + { + Code = "XYZ" + }); + + Assert.IsFalse(result.ReturnValueAdded); + Assert.IsNull(result.ReturnParameters); + Assert.AreEqual(1, cmd.Parameters.Count); + + var p0 = (IDbDataParameter)cmd.Parameters[0]; + Assert.AreEqual(Database.GetParameterName("code"), p0.ParameterName); + Assert.AreEqual(ParameterDirection.Input, p0.Direction); + Assert.AreEqual(DbType.String, p0.DbType); + Assert.AreEqual(64, p0.Size); + Assert.AreEqual("XYZ", p0.Value); + } + } + + [Test] + public void TestDeclaredResultTypeComesFromContractInterface() + { + Assert.AreEqual(typeof(ProcedureParameterResult), DynamicProcedureResultBinder.GetDeclaredResultType(new ProcedureParameterObject())); + Assert.AreEqual(typeof(ProcedureMultiResult), DynamicProcedureResultBinder.GetDeclaredResultType(new ProcedureMultiResultArgs())); + Assert.IsNull(DynamicProcedureResultBinder.GetDeclaredResultType(new object())); + } + + [Test] + public void TestProcedureDescriptorResolvesAttributeNameAndArguments() + { + var descriptor = DynamicProcedureDescriptor.Resolve(typeof(ExecProcedureDescriptor)); + + Assert.AreEqual(typeof(ExecProcedureDescriptor), descriptor.ProcedureType); + Assert.AreEqual(typeof(ProcedureParameterObject), descriptor.ArgumentsType); + Assert.IsNull(descriptor.ResultType); + Assert.AreEqual("dbo.sp_exec_test", descriptor.ProcedureName); + Assert.AreEqual("sp_exec_test", descriptor.ResultName); + } + + [Test] + public void TestProcedureDescriptorResolvesDefaultNameAndExplicitResult() + { + var defaultDescriptor = DynamicProcedureDescriptor.Resolve(typeof(ExecProcedureDefaultDescriptor)); + var explicitDescriptor = DynamicProcedureDescriptor.Resolve(typeof(ExecProcedureDescriptorWithExplicitResult)); + + Assert.AreEqual("ExecProcedureDefaultDescriptor", defaultDescriptor.ProcedureName); + Assert.AreEqual(typeof(ProcedureParameterObject), defaultDescriptor.ArgumentsType); + Assert.IsNull(defaultDescriptor.ResultType); + + Assert.AreEqual("sp_exec_result", explicitDescriptor.ProcedureName); + Assert.AreEqual(typeof(ProcedureParameterColumnFallbackObject), explicitDescriptor.ArgumentsType); + Assert.AreEqual(typeof(ProcedureAttributedResult), explicitDescriptor.ResultType); + } + + [Test] + public void TestExecMethodRejectsWrongArgumentsType() + { + var procedures = new DynamicProcedureInvoker(null); + + Assert.Throws(() => + { + var ignored = procedures.Exec(new ProcedureParameterColumnFallbackObject()); + }); + } + + [Test] + public void TestDynamicDatabaseTypedProcedureRejectsWrongArgumentsType() + { + Assert.Throws(() => + { + var ignored = Database.Procedure(new ProcedureParameterColumnFallbackObject()); + }); + } + + [Test] + public void TestExecTypedOverloadRejectsWrongArgumentsType() + { + var procedures = new DynamicProcedureInvoker(null); + + Assert.Throws(() => + { + var ignored = procedures.Exec(new ProcedureParameterObject()); + }); + } + + [Test] + public void TestTypedProcedureHandleRejectsWrongArgumentsType() + { + var procedures = new DynamicProcedureInvoker(null); + + Assert.Throws(() => + { + var ignored = procedures.Typed() + .Exec(new ProcedureParameterObject()); + }); + } + + [Test] + public void TestDynamicDatabaseTypedProcedureHandleRejectsWrongArgumentsType() + { + Assert.Throws(() => + { + var ignored = Database.TypedProcedure() + .Exec(new ProcedureParameterObject()); + }); + } + + [Test] + public void TestDeclaredResultPayloadBindingMapsMainAndOutValues() + { + var result = DynamicProcedureResultBinder.BindPayload( + typeof(ProcedureParameterResult), + "sp_Test", + 15, + new System.Collections.Generic.Dictionary + { + { "result", 7 }, + { "description", "done" }, + { "status", 3 } + }) as ProcedureParameterResult; + + Assert.NotNull(result); + Assert.AreEqual(15, result.MainResult); + Assert.AreEqual(7, result.Result); + Assert.AreEqual("done", result.Description); + Assert.AreEqual(3, result.Status); + } + + [Test] + public void TestDeclaredResultPayloadBindingSupportsProcedureResultMainResultProperty() + { + var result = DynamicProcedureResultBinder.BindPayload( + typeof(ProcedureParameterAttributeMainResult), + "sp_Test", + 27, + new System.Collections.Generic.Dictionary + { + { "status", 6 } + }) as ProcedureParameterAttributeMainResult; + + Assert.NotNull(result); + Assert.AreEqual(27, result.MainResult); + Assert.AreEqual(6, result.Status); + } + + [Test] + public void TestDeclaredResultPayloadBindingSupportsProcedureResultMainResultField() + { + var result = DynamicProcedureResultBinder.BindPayload( + typeof(ProcedureParameterAttributeMainResultField), + "sp_Test", + 33, + null) as ProcedureParameterAttributeMainResultField; + + Assert.NotNull(result); + Assert.AreEqual(33, result.MainResult); + } + + [Test] + public void TestDeclaredResultReaderCanConsumeMultipleResultSets() + { + using (var reader = new FakeMultiResultDataReader( + Tuple.Create( + new[] { "Code" }, + new[] { typeof(string) }, + new[] + { + new object[] { "A" }, + new object[] { "B" } + }), + Tuple.Create( + new[] { "State" }, + new[] { typeof(int) }, + new[] + { + new object[] { 10 }, + new object[] { 20 } + }))) + { + var result = DynamicProcedureResultBinder.ReadDeclaredResult(typeof(ProcedureMultiResult), reader) as ProcedureMultiResult; + + Assert.NotNull(result); + CollectionAssert.AreEqual(new[] { "A", "B" }, result.Codes); + CollectionAssert.AreEqual(new[] { 10, 20 }, result.States); + } + } + + [Test] + public void TestDeclaredResultCanReadAttributedResultSets() + { + using (var reader = new FakeMultiResultDataReader( + Tuple.Create(new[] { "Code" }, new[] { typeof(string) }, new[] { new object[] { "FIRST" }, new object[] { "SECOND" } }), + Tuple.Create(new[] { "Code" }, new[] { typeof(string) }, new[] { new object[] { "A" }, new object[] { "B" } }), + Tuple.Create(new[] { "State" }, new[] { typeof(int) }, new[] { new object[] { 10 }, new object[] { 20 } }), + Tuple.Create( + new[] { "id", "code", "first" }, + new[] { typeof(long), typeof(string), typeof(string) }, + new[] { new object[] { 1L, "U1", "One" } }), + Tuple.Create( + new[] { "id", "code", "first" }, + new[] { typeof(long), typeof(string), typeof(string) }, + new[] { new object[] { 2L, "U2", "Two" }, new object[] { 3L, "U3", "Three" } }), + Tuple.Create(new[] { "Code" }, new[] { typeof(string) }, new[] { new object[] { "X" }, new object[] { "Y" } }))) + { + var result = DynamicProcedureResultBinder.ReadDeclaredResult(typeof(ProcedureAttributedResult), reader) as ProcedureAttributedResult; + + Assert.NotNull(result); + Assert.AreEqual("FIRST", result.FirstCode); + CollectionAssert.AreEqual(new[] { "A", "B" }, result.Codes); + CollectionAssert.AreEqual(new[] { 10, 20 }, result.States); + Assert.NotNull(result.User); + Assert.AreEqual(1L, result.User.Id); + Assert.AreEqual("U1", result.User.Code); + Assert.NotNull(result.AllUsers); + Assert.AreEqual(2, result.AllUsers.Count); + Assert.NotNull(result.CodesTable); + Assert.AreEqual("codes_table", result.CodesTable.TableName); + Assert.AreEqual(2, result.CodesTable.Rows.Count); + } + } + + [Test] + public void TestDeclaredResultCanReadAttributedFieldResultSets() + { + using (var reader = new FakeMultiResultDataReader( + Tuple.Create(new[] { "Code" }, new[] { typeof(string) }, new[] { new object[] { "FIELD-FIRST" }, new object[] { "FIELD-SECOND" } }), + Tuple.Create(new[] { "Code" }, new[] { typeof(string) }, new[] { new object[] { "C" }, new object[] { "D" } }), + Tuple.Create(new[] { "State" }, new[] { typeof(int) }, new[] { new object[] { 30 }, new object[] { 40 } }), + Tuple.Create( + new[] { "id", "code", "first" }, + new[] { typeof(long), typeof(string), typeof(string) }, + new[] { new object[] { 4L, "U4", "Four" } }), + Tuple.Create( + new[] { "id", "code", "first" }, + new[] { typeof(long), typeof(string), typeof(string) }, + new[] { new object[] { 5L, "U5", "Five" }, new object[] { 6L, "U6", "Six" } }))) + { + var result = DynamicProcedureResultBinder.ReadDeclaredResult(typeof(ProcedureAttributedFieldResult), reader) as ProcedureAttributedFieldResult; + + Assert.NotNull(result); + Assert.AreEqual("FIELD-FIRST", result.FirstCode); + CollectionAssert.AreEqual(new[] { "C", "D" }, result.Codes); + CollectionAssert.AreEqual(new[] { 30, 40 }, result.States); + Assert.NotNull(result.User); + Assert.AreEqual(4L, result.User.Id); + Assert.NotNull(result.UsersTable); + Assert.AreEqual("users_table", result.UsersTable.TableName); + Assert.AreEqual(2, result.UsersTable.Rows.Count); + } + } + + [Test] + public void TestDeclaredResultPayloadCanAugmentReaderResult() + { + var existing = new ProcedureMultiResult(); + existing.Codes.Add("A"); + existing.States.Add(10); + + var result = DynamicProcedureResultBinder.BindPayload( + typeof(ProcedureMultiResult), + "sp_Multi", + 99, + new System.Collections.Generic.Dictionary + { + { "status", 5 } + }, + existing) as ProcedureMultiResult; + + Assert.AreSame(existing, result); + Assert.AreEqual(99, result.MainResult); + Assert.AreEqual(5, result.Status); + CollectionAssert.AreEqual(new[] { "A" }, result.Codes); + CollectionAssert.AreEqual(new[] { 10 }, result.States); + } + + [Test] + public void TestDeclaredResultBindingDetectionSupportsAttributedResults() + { + Assert.IsTrue(DynamicProcedureResultBinder.CanReadResults(typeof(ProcedureMultiResult))); + Assert.IsTrue(DynamicProcedureResultBinder.CanReadResults(typeof(ProcedureAttributedResult))); + Assert.IsTrue(DynamicProcedureResultBinder.CanReadResults(typeof(ProcedureAttributedFieldResult))); + Assert.IsFalse(DynamicProcedureResultBinder.CanReadResults(typeof(ProcedureParameterResult))); + } + } +} diff --git a/DynamORM/DynamicDatabase.cs b/DynamORM/DynamicDatabase.cs index 0735cc5..4e61f39 100644 --- a/DynamORM/DynamicDatabase.cs +++ b/DynamORM/DynamicDatabase.cs @@ -38,8 +38,9 @@ using System.Text; using DynamORM.Builders; using DynamORM.Builders.Extensions; using DynamORM.Builders.Implementation; -using DynamORM.Helpers; -using DynamORM.Mapper; +using DynamORM.Helpers; +using DynamORM.Mapper; +using DynamORM.Objects; namespace DynamORM { @@ -1232,10 +1233,10 @@ namespace DynamORM /// Name of stored procedure to execute. /// Arguments (parameters) in form of expando object. /// Number of affected rows. - public virtual int Procedure(string procName, ExpandoObject args) - { - if ((Options & DynamicDatabaseOptions.SupportStoredProcedures) != DynamicDatabaseOptions.SupportStoredProcedures) - throw new InvalidOperationException("Database connection desn't support stored procedures."); + public virtual int Procedure(string procName, ExpandoObject args) + { + if ((Options & DynamicDatabaseOptions.SupportStoredProcedures) != DynamicDatabaseOptions.SupportStoredProcedures) + throw new InvalidOperationException("Database connection desn't support stored procedures."); using (IDbConnection con = Open()) using (IDbCommand cmd = con.CreateCommand()) @@ -1243,11 +1244,80 @@ namespace DynamORM return cmd .SetCommand(CommandType.StoredProcedure, procName) .AddParameters(this, args) - .ExecuteNonQuery(); - } - } - - #endregion Procedure + .ExecuteNonQuery(); + } + } + + /// Execute typed stored procedure descriptor. + /// Procedure descriptor type. + /// Procedure result. + public virtual object Procedure() + { + return Procedure(null); + } + + /// Execute typed stored procedure descriptor. + /// Procedure descriptor type. + /// Procedure arguments contract. + /// Procedure result. + public virtual object Procedure(object args) + { + if ((Options & DynamicDatabaseOptions.SupportStoredProcedures) != DynamicDatabaseOptions.SupportStoredProcedures) + throw new InvalidOperationException("Database connection desn't support stored procedures."); + + return new DynamicProcedureInvoker(this).Exec(args); + } + + /// Execute typed stored procedure descriptor with strong result type. + /// Procedure descriptor type. + /// Procedure result type. + /// Procedure result. + public virtual TResult Procedure() + where TProcedure : IProcedureDescriptor + { + return Procedure(null); + } + + /// Execute typed stored procedure descriptor with strong result type. + /// Procedure descriptor type. + /// Procedure result type. + /// Procedure arguments contract. + /// Procedure result. + public virtual TResult Procedure(object args) + where TProcedure : IProcedureDescriptor + { + if ((Options & DynamicDatabaseOptions.SupportStoredProcedures) != DynamicDatabaseOptions.SupportStoredProcedures) + throw new InvalidOperationException("Database connection desn't support stored procedures."); + + return new DynamicProcedureInvoker(this).Exec(args); + } + + /// Create typed stored procedure execution handle. + /// Procedure descriptor type. + /// Typed execution handle. + public virtual TypedProcedureCall TypedProcedure() + where TProcedure : IProcedureDescriptor + { + if ((Options & DynamicDatabaseOptions.SupportStoredProcedures) != DynamicDatabaseOptions.SupportStoredProcedures) + throw new InvalidOperationException("Database connection desn't support stored procedures."); + + return new TypedProcedureCall(new DynamicProcedureInvoker(this)); + } + + /// Create typed stored procedure execution handle. + /// Procedure descriptor type. + /// Procedure result type. + /// Typed execution handle. + public virtual TypedProcedureCall TypedProcedure() + where TProcedure : IProcedureDescriptor + { + if ((Options & DynamicDatabaseOptions.SupportStoredProcedures) != DynamicDatabaseOptions.SupportStoredProcedures) + throw new InvalidOperationException("Database connection desn't support stored procedures."); + + return new TypedProcedureCall(new DynamicProcedureInvoker(this)); + } + + #endregion Procedure #region Execute diff --git a/DynamORM/DynamicProcedureInvoker.cs b/DynamORM/DynamicProcedureInvoker.cs index 7758da2..640253a 100644 --- a/DynamORM/DynamicProcedureInvoker.cs +++ b/DynamORM/DynamicProcedureInvoker.cs @@ -31,13 +31,14 @@ using System.Collections; using System.Collections.Generic; using System.ComponentModel; using System.Data; -using System.Dynamic; -using System.Linq; -using DynamORM.Helpers; -using DynamORM.Mapper; - -namespace DynamORM -{ +using System.Dynamic; +using System.Linq; +using DynamORM.Helpers; +using DynamORM.Mapper; +using DynamORM.Objects; + +namespace DynamORM +{ /// Dynamic procedure invoker. /// Unfortunately I can use out and ref to /// return parameters, . @@ -57,11 +58,59 @@ namespace DynamORM private List _prefixes; private bool _isDisposed; - internal DynamicProcedureInvoker(DynamicDatabase db, List prefixes = null) - { - _prefixes = prefixes; - _db = db; - } + internal DynamicProcedureInvoker(DynamicDatabase db, List prefixes = null) + { + _prefixes = prefixes; + _db = db; + } + + /// Execute typed stored procedure descriptor. + /// Procedure descriptor type. + /// Optional procedure arguments contract. + /// Procedure result. + public virtual object Exec(object args = null) + { + DynamicProcedureDescriptor descriptor = DynamicProcedureDescriptor.Resolve(typeof(TProcedure)); + return InvokeProcedure( + descriptor.ProcedureName, + descriptor.ResultName, + new List(), + args == null ? new object[0] : new[] { args }, + new CallInfo(args == null ? 0 : 1), + descriptor.ResultType, + descriptor.ArgumentsType, + descriptor.ProcedureType); + } + + /// Execute typed stored procedure descriptor with strong result type. + /// Procedure descriptor type. + /// Procedure result type. + /// Optional procedure arguments contract. + /// Procedure result. + public virtual TResult Exec(object args = null) + where TProcedure : IProcedureDescriptor + { + return ConvertProcedureResult(Exec(args)); + } + + /// Create typed stored procedure execution handle. + /// Procedure descriptor type. + /// Typed execution handle. + public virtual TypedProcedureCall Typed() + where TProcedure : IProcedureDescriptor + { + return new TypedProcedureCall(this); + } + + /// Create typed stored procedure execution handle. + /// Procedure descriptor type. + /// Procedure result type. + /// Typed execution handle. + public virtual TypedProcedureCall Typed() + where TProcedure : IProcedureDescriptor + { + return new TypedProcedureCall(this); + } /// This is where the magic begins. /// Binder to create owner. @@ -86,194 +135,227 @@ namespace DynamORM /// Binder arguments. /// Binder invoke result. /// Returns true if invoke was performed. - public override bool TryInvokeMember(InvokeMemberBinder binder, object[] args, out object result) - { - // parse the method - CallInfo info = binder.CallInfo; - - // Get generic types - IList types = binder.GetGenericTypeArguments(); - - Dictionary retParams = null; - - using (IDbConnection con = _db.Open()) - using (IDbCommand cmd = con.CreateCommand()) - { - if (_prefixes == null || _prefixes.Count == 0) - cmd.SetCommand(CommandType.StoredProcedure, binder.Name); - else - cmd.SetCommand(CommandType.StoredProcedure, string.Format("{0}.{1}", string.Join(".", _prefixes), binder.Name)); + public override bool TryInvokeMember(InvokeMemberBinder binder, object[] args, out object result) + { + // parse the method + CallInfo info = binder.CallInfo; + + // Get generic types + IList types = binder.GetGenericTypeArguments() ?? new List(); + + result = InvokeProcedure(binder.Name, binder.Name, types, args, info, null, null, null); + return true; + } + + internal object InvokeProcedure(string procedureName, string resultName, IList types, object[] args, CallInfo info, Type declaredResultType, Type expectedArgumentsType, Type procedureType) + { + object result; + Dictionary retParams = null; + + if (expectedArgumentsType != null) + { + if (args.Length > 1) + throw new InvalidOperationException("Exec(args) accepts at most one arguments contract instance."); + + if (args.Length == 1 && args[0] != null && !expectedArgumentsType.IsAssignableFrom(args[0].GetType())) + throw new InvalidOperationException(string.Format("Exec<{0}>(args) expects argument of type '{1}', received '{2}'.", procedureType == null ? expectedArgumentsType.FullName : procedureType.FullName, expectedArgumentsType.FullName, args[0].GetType().FullName)); + } + + using (IDbConnection con = _db.Open()) + using (IDbCommand cmd = con.CreateCommand()) + { + if (_prefixes == null || _prefixes.Count == 0) + cmd.SetCommand(CommandType.StoredProcedure, procedureName); + else + cmd.SetCommand(CommandType.StoredProcedure, string.Format("{0}.{1}", string.Join(".", _prefixes), procedureName)); #region Prepare arguments - int alen = args.Length; - bool retIsAdded = false; - - if (alen > 0) - { - for (int i = 0; i < alen; i++) - { - object arg = args[i]; - - if (arg is DynamicExpando) - cmd.AddParameters(_db, (DynamicExpando)arg); - else if (arg is ExpandoObject) - cmd.AddParameters(_db, (ExpandoObject)arg); - else if (arg is DynamicColumn) - { - var dcv = (DynamicColumn)arg; - - string paramName = i.ToString(); - bool isOut = false; - bool isRet = false; - bool isBoth = false; - - if (info.ArgumentNames.Count > i) - { - isOut = info.ArgumentNames[i].StartsWith("out_"); - isRet = info.ArgumentNames[i].StartsWith("ret_"); - isBoth = info.ArgumentNames[i].StartsWith("both_"); - - paramName = isOut || isRet ? - info.ArgumentNames[i].Substring(4) : - isBoth ? info.ArgumentNames[i].Substring(5) : - info.ArgumentNames[i]; - } - - paramName = dcv.Alias ?? dcv.ColumnName ?? - (dcv.Schema.HasValue ? dcv.Schema.Value.Name : null) ?? - paramName; - - if (!isOut && !isRet && !isBoth) - { - isOut = dcv.ParameterDirection == ParameterDirection.Output; - isRet = dcv.ParameterDirection == ParameterDirection.ReturnValue; - isBoth = dcv.ParameterDirection == ParameterDirection.InputOutput; - } - - if (isRet) - retIsAdded = true; - - if (isOut || isRet || isBoth) - { - if (retParams == null) - retParams = new Dictionary(); - retParams.Add(paramName, cmd.Parameters.Count); - } - - if (dcv.Schema != null) - { - var ds = dcv.Schema.Value; - cmd.AddParameter( - _db.GetParameterName(paramName), - isOut ? ParameterDirection.Output : - isRet ? ParameterDirection.ReturnValue : - isBoth ? ParameterDirection.InputOutput : - ParameterDirection.Input, - ds.Type, ds.Size, ds.Precision, ds.Scale, - (isOut || isRet) ? DBNull.Value : dcv.Value); - } - else - cmd.AddParameter( - _db.GetParameterName(paramName), - isOut ? ParameterDirection.Output : - isRet ? ParameterDirection.ReturnValue : - isBoth ? ParameterDirection.InputOutput : - ParameterDirection.Input, - arg == null ? DbType.String : arg.GetType().ToDbType(), - isRet ? 4 : 0, - (isOut || isRet) ? DBNull.Value : dcv.Value); - } - else if (arg is DynamicSchemaColumn) - { - var dsc = (DynamicSchemaColumn)arg; - - string paramName = i.ToString(); - bool isOut = false; - bool isRet = false; - bool isBoth = false; - - if (info.ArgumentNames.Count > i) - { - isOut = info.ArgumentNames[i].StartsWith("out_"); - isRet = info.ArgumentNames[i].StartsWith("ret_"); - isBoth = info.ArgumentNames[i].StartsWith("both_"); - - paramName = isOut || isRet ? - info.ArgumentNames[i].Substring(4) : - isBoth ? info.ArgumentNames[i].Substring(5) : - info.ArgumentNames[i]; - } - - paramName = dsc.Name ?? paramName; - - if (isRet) - retIsAdded = true; - - if (isOut || isRet || isBoth) - { - if (retParams == null) - retParams = new Dictionary(); - retParams.Add(paramName, cmd.Parameters.Count); - } - - cmd.AddParameter( - _db.GetParameterName(paramName), - isOut ? ParameterDirection.Output : - isRet ? ParameterDirection.ReturnValue : - isBoth ? ParameterDirection.InputOutput : - ParameterDirection.Input, - dsc.Type, dsc.Size, dsc.Precision, dsc.Scale, - DBNull.Value); - } - else - { - if (info.ArgumentNames.Count > i && !string.IsNullOrEmpty(info.ArgumentNames[i])) - { - bool isOut = info.ArgumentNames[i].StartsWith("out_"); - bool isRet = info.ArgumentNames[i].StartsWith("ret_"); - bool isBoth = info.ArgumentNames[i].StartsWith("both_"); - - if (isRet) - retIsAdded = true; - - string paramName = isOut || isRet ? - info.ArgumentNames[i].Substring(4) : - isBoth ? info.ArgumentNames[i].Substring(5) : - info.ArgumentNames[i]; - - if (isOut || isBoth || isRet) - { - if (retParams == null) - retParams = new Dictionary(); - retParams.Add(paramName, cmd.Parameters.Count); - } - - cmd.AddParameter( - _db.GetParameterName(paramName), - isOut ? ParameterDirection.Output : - isRet ? ParameterDirection.ReturnValue : - isBoth ? ParameterDirection.InputOutput : - ParameterDirection.Input, - arg == null ? isRet ? DbType.Int32 : DbType.String : arg.GetType().ToDbType(), - isRet ? 4 : 0, - (isOut || isRet) ? DBNull.Value : arg); - } - else - cmd.AddParameter(_db, arg); - } - } - } + int alen = args.Length; + bool retIsAdded = false; + if (declaredResultType == null) + declaredResultType = alen == 1 ? DynamicProcedureResultBinder.GetDeclaredResultType(args[0]) : null; + + if (alen > 0) + { + if (alen == 1 && DynamicProcedureParameterBinder.CanBind(args[0])) + { + DynamicProcedureParameterBinder.BindingResult bindingResult = DynamicProcedureParameterBinder.Bind(_db, cmd, args[0]); + retParams = bindingResult.ReturnParameters; + retIsAdded = bindingResult.ReturnValueAdded; + } + else + { + for (int i = 0; i < alen; i++) + { + object arg = args[i]; + + if (arg is DynamicExpando) + cmd.AddParameters(_db, (DynamicExpando)arg); + else if (arg is ExpandoObject) + cmd.AddParameters(_db, (ExpandoObject)arg); + else if (arg is DynamicColumn) + { + var dcv = (DynamicColumn)arg; + + string paramName = i.ToString(); + bool isOut = false; + bool isRet = false; + bool isBoth = false; + + if (info.ArgumentNames.Count > i) + { + isOut = info.ArgumentNames[i].StartsWith("out_"); + isRet = info.ArgumentNames[i].StartsWith("ret_"); + isBoth = info.ArgumentNames[i].StartsWith("both_"); + + paramName = isOut || isRet ? + info.ArgumentNames[i].Substring(4) : + isBoth ? info.ArgumentNames[i].Substring(5) : + info.ArgumentNames[i]; + } + + paramName = dcv.Alias ?? dcv.ColumnName ?? + (dcv.Schema.HasValue ? dcv.Schema.Value.Name : null) ?? + paramName; + + if (!isOut && !isRet && !isBoth) + { + isOut = dcv.ParameterDirection == ParameterDirection.Output; + isRet = dcv.ParameterDirection == ParameterDirection.ReturnValue; + isBoth = dcv.ParameterDirection == ParameterDirection.InputOutput; + } + + if (isRet) + retIsAdded = true; + + if (isOut || isRet || isBoth) + { + if (retParams == null) + retParams = new Dictionary(); + retParams.Add(paramName, cmd.Parameters.Count); + } + + if (dcv.Schema != null) + { + var ds = dcv.Schema.Value; + cmd.AddParameter( + _db.GetParameterName(paramName), + isOut ? ParameterDirection.Output : + isRet ? ParameterDirection.ReturnValue : + isBoth ? ParameterDirection.InputOutput : + ParameterDirection.Input, + ds.Type, ds.Size, ds.Precision, ds.Scale, + (isOut || isRet) ? DBNull.Value : dcv.Value); + } + else + cmd.AddParameter( + _db.GetParameterName(paramName), + isOut ? ParameterDirection.Output : + isRet ? ParameterDirection.ReturnValue : + isBoth ? ParameterDirection.InputOutput : + ParameterDirection.Input, + arg == null ? DbType.String : arg.GetType().ToDbType(), + isRet ? 4 : 0, + (isOut || isRet) ? DBNull.Value : dcv.Value); + } + else if (arg is DynamicSchemaColumn) + { + var dsc = (DynamicSchemaColumn)arg; + + string paramName = i.ToString(); + bool isOut = false; + bool isRet = false; + bool isBoth = false; + + if (info.ArgumentNames.Count > i) + { + isOut = info.ArgumentNames[i].StartsWith("out_"); + isRet = info.ArgumentNames[i].StartsWith("ret_"); + isBoth = info.ArgumentNames[i].StartsWith("both_"); + + paramName = isOut || isRet ? + info.ArgumentNames[i].Substring(4) : + isBoth ? info.ArgumentNames[i].Substring(5) : + info.ArgumentNames[i]; + } + + paramName = dsc.Name ?? paramName; + + if (isRet) + retIsAdded = true; + + if (isOut || isRet || isBoth) + { + if (retParams == null) + retParams = new Dictionary(); + retParams.Add(paramName, cmd.Parameters.Count); + } + + cmd.AddParameter( + _db.GetParameterName(paramName), + isOut ? ParameterDirection.Output : + isRet ? ParameterDirection.ReturnValue : + isBoth ? ParameterDirection.InputOutput : + ParameterDirection.Input, + dsc.Type, dsc.Size, dsc.Precision, dsc.Scale, + DBNull.Value); + } + else + { + if (info.ArgumentNames.Count > i && !string.IsNullOrEmpty(info.ArgumentNames[i])) + { + bool isOut = info.ArgumentNames[i].StartsWith("out_"); + bool isRet = info.ArgumentNames[i].StartsWith("ret_"); + bool isBoth = info.ArgumentNames[i].StartsWith("both_"); + + if (isRet) + retIsAdded = true; + + string paramName = isOut || isRet ? + info.ArgumentNames[i].Substring(4) : + isBoth ? info.ArgumentNames[i].Substring(5) : + info.ArgumentNames[i]; + + if (isOut || isBoth || isRet) + { + if (retParams == null) + retParams = new Dictionary(); + retParams.Add(paramName, cmd.Parameters.Count); + } + + cmd.AddParameter( + _db.GetParameterName(paramName), + isOut ? ParameterDirection.Output : + isRet ? ParameterDirection.ReturnValue : + isBoth ? ParameterDirection.InputOutput : + ParameterDirection.Input, + arg == null ? isRet ? DbType.Int32 : DbType.String : arg.GetType().ToDbType(), + isRet ? 4 : 0, + (isOut || isRet) ? DBNull.Value : arg); + } + else + cmd.AddParameter(_db, arg); + } + } + } + } #endregion Prepare arguments #region Get main result - object mainResult = null; - - if (types.Count > 0) - { - mainResult = types[0].GetDefaultValue(); + object mainResult = null; + + if (types.Count == 0 && DynamicProcedureResultBinder.CanReadResults(declaredResultType)) + { + using (IDataReader rdr = cmd.ExecuteReader()) + using (IDataReader cache = rdr.CachedReader()) + mainResult = DynamicProcedureResultBinder.ReadDeclaredResult(declaredResultType, cache); + } + else if (types.Count > 0) + { + mainResult = types[0].GetDefaultValue(); if (types[0] == typeof(IDataReader)) { @@ -284,7 +366,7 @@ namespace DynamORM { using (IDataReader rdr = cmd.ExecuteReader()) using (IDataReader cache = rdr.CachedReader()) - mainResult = cache.ToDataTable(binder.Name); + mainResult = cache.ToDataTable(resultName); } else if (types[0].IsGenericEnumerable()) { @@ -409,41 +491,62 @@ namespace DynamORM #region Handle out params - if (retParams != null) - { - Dictionary res = new Dictionary(); + if (retParams != null) + { + Dictionary res = new Dictionary(); if (mainResult != null) { if (mainResult == DBNull.Value) - res.Add(binder.Name, null); - else - res.Add(binder.Name, mainResult); - } + res.Add(resultName, null); + else + res.Add(resultName, mainResult); + } - foreach (KeyValuePair pos in retParams) - res.Add(pos.Key, ((IDbDataParameter)cmd.Parameters[pos.Value]).Value); - - if (types.Count > 1) - { + foreach (KeyValuePair pos in retParams) + res.Add(pos.Key, ((IDbDataParameter)cmd.Parameters[pos.Value]).Value); + + if (types.Count > 1) + { DynamicTypeMap mapper = DynamicMapperCache.GetMapper(types[1]); if (mapper != null) - result = mapper.Create(res.ToDynamic()); - else - result = res.ToDynamic(); - } - else - result = res.ToDynamic(); - } - else - result = mainResult; - - #endregion Handle out params - } - - return true; - } + result = mapper.Create(res.ToDynamic()); + else + result = res.ToDynamic(); + } + else if (declaredResultType != null) + result = DynamicProcedureResultBinder.BindPayload(declaredResultType, resultName, mainResult, res.Where(x => x.Key != resultName).ToDictionary(x => x.Key, x => x.Value), mainResult != null && declaredResultType.IsInstanceOfType(mainResult) ? mainResult : null); + else + result = res.ToDynamic(); + } + else if (declaredResultType != null && mainResult != null && declaredResultType.IsInstanceOfType(mainResult)) + result = mainResult; + else if (declaredResultType != null) + result = DynamicProcedureResultBinder.BindPayload(declaredResultType, resultName, mainResult, null); + else + result = mainResult; + + #endregion Handle out params + } + + return result; + } + + private static TResult ConvertProcedureResult(object result) + { + if (result == null || result == DBNull.Value) + return default(TResult); + + if (result is TResult) + return (TResult)result; + + DynamicTypeMap mapper = DynamicMapperCache.GetMapper(typeof(TResult)); + if (mapper != null) + return (TResult)DynamicExtensions.Map(result, typeof(TResult)); + + return (TResult)typeof(TResult).CastObject(result); + } /// Performs application-defined tasks associated with /// freeing, releasing, or resetting unmanaged resources. diff --git a/DynamORM/Helpers/DynamicProcedureDescriptor.cs b/DynamORM/Helpers/DynamicProcedureDescriptor.cs new file mode 100644 index 0000000..4cefb15 --- /dev/null +++ b/DynamORM/Helpers/DynamicProcedureDescriptor.cs @@ -0,0 +1,100 @@ +/* + * DynamORM - Dynamic Object-Relational Mapping library. + * Copyright (c) 2012-2026, Grzegorz Russek (grzegorz.russek@gmail.com) + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + * THE POSSIBILITY OF SUCH DAMAGE. +*/ + +using System; +using System.Linq; +using DynamORM.Mapper; +using DynamORM.Objects; + +namespace DynamORM.Helpers +{ + internal sealed class DynamicProcedureDescriptor + { + public Type ProcedureType { get; set; } + public Type ArgumentsType { get; set; } + public Type ResultType { get; set; } + public string ProcedureName { get; set; } + public string ResultName { get; set; } + + internal static DynamicProcedureDescriptor Resolve(Type procedureType) + { + if (procedureType == null) + throw new ArgumentNullException("procedureType"); + + Type current = procedureType; + Type argumentsType = null; + Type resultType = null; + + while (current != null && current != typeof(object)) + { + if (current.IsGenericType) + { + Type genericDefinition = current.GetGenericTypeDefinition(); + Type[] genericArguments = current.GetGenericArguments(); + + if (genericDefinition == typeof(Procedure<>) && genericArguments.Length == 1) + { + argumentsType = genericArguments[0]; + break; + } + + if (genericDefinition == typeof(Procedure<,>) && genericArguments.Length == 2) + { + argumentsType = genericArguments[0]; + resultType = genericArguments[1]; + break; + } + } + + current = current.BaseType; + } + + if (argumentsType == null) + throw new InvalidOperationException(string.Format("Type '{0}' is not a typed procedure descriptor.", procedureType.FullName)); + + if (!typeof(IProcedureParameters).IsAssignableFrom(argumentsType)) + throw new InvalidOperationException(string.Format("Procedure descriptor '{0}' declares argument type '{1}' that does not implement IProcedureParameters.", procedureType.FullName, argumentsType.FullName)); + + ProcedureAttribute attr = procedureType.GetCustomAttributes(typeof(ProcedureAttribute), true) + .Cast() + .FirstOrDefault(); + + string name = attr != null && !string.IsNullOrEmpty(attr.Name) ? attr.Name : procedureType.Name; + string owner = attr != null ? attr.Owner : null; + + return new DynamicProcedureDescriptor + { + ProcedureType = procedureType, + ArgumentsType = argumentsType, + ResultType = resultType, + ProcedureName = string.IsNullOrEmpty(owner) ? name : string.Format("{0}.{1}", owner, name), + ResultName = name + }; + } + } +} diff --git a/DynamORM/Helpers/DynamicProcedureParameterBinder.cs b/DynamORM/Helpers/DynamicProcedureParameterBinder.cs new file mode 100644 index 0000000..30cd4d6 --- /dev/null +++ b/DynamORM/Helpers/DynamicProcedureParameterBinder.cs @@ -0,0 +1,170 @@ +/* + * DynamORM - Dynamic Object-Relational Mapping library. + * Copyright (c) 2012-2026, Grzegorz Russek (grzegorz.russek@gmail.com) + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + * THE POSSIBILITY OF SUCH DAMAGE. +*/ + +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Reflection; +using DynamORM.Mapper; + +namespace DynamORM.Helpers +{ + internal static class DynamicProcedureParameterBinder + { + internal sealed class BindingResult + { + public bool ReturnValueAdded { get; set; } + public Dictionary ReturnParameters { get; set; } + } + + internal static bool CanBind(object item) + { + if (!DynamicProcedureResultBinder.IsProcedureContract(item)) + return false; + + return GetBindableProperties(item.GetType()).Any(); + } + + internal static BindingResult Bind(DynamicDatabase db, IDbCommand cmd, object item) + { + if (db == null) + throw new ArgumentNullException("db"); + if (cmd == null) + throw new ArgumentNullException("cmd"); + if (item == null) + throw new ArgumentNullException("item"); + + BindingResult result = new BindingResult(); + + foreach (PropertyInfo property in GetBindableProperties(item.GetType())) + { + ProcedureParameterAttribute procAttr = property.GetCustomAttributes(typeof(ProcedureParameterAttribute), true).Cast().FirstOrDefault(); + ColumnAttribute colAttr = property.GetCustomAttributes(typeof(ColumnAttribute), true).Cast().FirstOrDefault(); + + string name = (procAttr != null && !string.IsNullOrEmpty(procAttr.Name) ? procAttr.Name : null) + ?? (colAttr != null && !string.IsNullOrEmpty(colAttr.Name) ? colAttr.Name : null) + ?? property.Name; + + ParameterDirection direction = procAttr == null ? ParameterDirection.Input : procAttr.Direction; + object value = property.GetValue(item, null); + DbType dbType = ResolveDbType(property.PropertyType, value, procAttr, colAttr, direction); + int size = ResolveSize(dbType, value, procAttr, colAttr, direction); + byte precision = (procAttr != null && procAttr.Precision != ProcedureParameterAttribute.UnspecifiedByte ? procAttr.Precision : default(byte)); + byte scale = (procAttr != null && procAttr.Scale != ProcedureParameterAttribute.UnspecifiedByte ? procAttr.Scale : default(byte)); + + if (procAttr == null && colAttr != null) + { + precision = colAttr.Precision ?? precision; + scale = colAttr.Scale ?? scale; + } + + if (direction == ParameterDirection.ReturnValue) + result.ReturnValueAdded = true; + + if (direction == ParameterDirection.Output || direction == ParameterDirection.InputOutput || direction == ParameterDirection.ReturnValue) + { + if (result.ReturnParameters == null) + result.ReturnParameters = new Dictionary(); + result.ReturnParameters.Add(name, cmd.Parameters.Count); + } + + cmd.AddParameter( + db.GetParameterName(name), + direction, + dbType, + size, + precision, + scale, + direction == ParameterDirection.Output || direction == ParameterDirection.ReturnValue ? DBNull.Value : (value ?? DBNull.Value)); + } + + return result; + } + + private static IEnumerable GetBindableProperties(Type type) + { + return type.GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(x => x.CanRead && x.GetIndexParameters().Length == 0) + .Where(x => x.GetCustomAttributes(typeof(ProcedureParameterAttribute), true).Any() || x.GetCustomAttributes(typeof(ColumnAttribute), true).Any()) + .OrderBy(x => + { + ProcedureParameterAttribute attr = x.GetCustomAttributes(typeof(ProcedureParameterAttribute), true).Cast().FirstOrDefault(); + return attr == null ? int.MaxValue : attr.Order; + }) + .ThenBy(x => x.MetadataToken); + } + + private static DbType ResolveDbType(Type propertyType, object value, ProcedureParameterAttribute procAttr, ColumnAttribute colAttr, ParameterDirection direction) + { + if (procAttr != null && (int)procAttr.DbType != ProcedureParameterAttribute.UnspecifiedDbType) + return procAttr.DbType; + + if (colAttr != null && colAttr.Type.HasValue) + return colAttr.Type.Value; + + Type targetType = Nullable.GetUnderlyingType(propertyType) ?? propertyType; + + if (targetType == typeof(object) && value != null) + targetType = value.GetType(); + + if (value == null && direction == ParameterDirection.ReturnValue) + return DbType.Int32; + + return targetType.ToDbType(); + } + + private static int ResolveSize(DbType dbType, object value, ProcedureParameterAttribute procAttr, ColumnAttribute colAttr, ParameterDirection direction) + { + if (procAttr != null && procAttr.Size != ProcedureParameterAttribute.UnspecifiedSize) + return procAttr.Size; + + if (colAttr != null && colAttr.Size.HasValue) + return colAttr.Size.Value; + + if (direction == ParameterDirection.ReturnValue) + return 4; + + if (dbType == DbType.AnsiString || dbType == DbType.AnsiStringFixedLength) + { + if (value != null) + return value.ToString().Length > 8000 ? -1 : 8000; + return 8000; + } + + if (dbType == DbType.String || dbType == DbType.StringFixedLength) + { + if (value != null) + return value.ToString().Length > 4000 ? -1 : 4000; + return 4000; + } + + return 0; + } + } +} diff --git a/DynamORM/Helpers/DynamicProcedureResultBinder.cs b/DynamORM/Helpers/DynamicProcedureResultBinder.cs new file mode 100644 index 0000000..96e415c --- /dev/null +++ b/DynamORM/Helpers/DynamicProcedureResultBinder.cs @@ -0,0 +1,452 @@ +/* + * DynamORM - Dynamic Object-Relational Mapping library. + * Copyright (c) 2012-2026, Grzegorz Russek (grzegorz.russek@gmail.com) + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + * THE POSSIBILITY OF SUCH DAMAGE. +*/ + +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Reflection; +using DynamORM.Mapper; +using DynamORM.Objects; + +namespace DynamORM.Helpers +{ + internal static class DynamicProcedureResultBinder + { + private sealed class ResultMemberBinding + { + public ProcedureResultAttribute Attribute { get; set; } + public MemberInfo Member { get; set; } + public Type MemberType { get; set; } + public int SortOrder { get; set; } + + public void SetValue(object instance, object value) + { + PropertyInfo property = Member as PropertyInfo; + if (property != null) + { + property.SetValue(instance, value, null); + return; + } + + FieldInfo field = Member as FieldInfo; + if (field != null) + field.SetValue(instance, value); + } + } + + internal static bool IsProcedureContract(object item) + { + return item is IProcedureParameters; + } + + internal static Type GetDeclaredResultType(object item) + { + if (item == null) + return null; + + Type iface = item.GetType().GetInterfaces() + .FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IProcedureParameters<>)); + + return iface == null ? null : iface.GetGenericArguments()[0]; + } + + internal static bool CanReadResults(Type resultType) + { + return resultType != null && + (typeof(IProcedureResultReader).IsAssignableFrom(resultType) || GetResultMemberBindings(resultType).Count > 0); + } + + internal static bool HasDeclaredResultBinding(Type resultType) + { + return CanReadResults(resultType); + } + + internal static object CreateDeclaredResult(Type resultType) + { + if (resultType == null) + return null; + + DynamicTypeMap mapper = DynamicMapperCache.GetMapper(resultType); + if (mapper != null) + return mapper.Creator(); + + return Activator.CreateInstance(resultType); + } + + internal static object ReadDeclaredResult(Type resultType, IDataReader reader) + { + if (!CanReadResults(resultType)) + throw new InvalidOperationException(string.Format("Type '{0}' does not declare a supported procedure result binding.", resultType == null ? "" : resultType.FullName)); + + object instance = CreateDeclaredResult(resultType); + + IList bindings = GetResultMemberBindings(resultType); + if (bindings.Count > 0) + BindResultMembers(instance, reader, bindings); + else + ((IProcedureResultReader)instance).ReadResults(reader); + + return instance; + } + + internal static object BindPayload(Type resultType, string mainResultName, object mainResult, IDictionary returnValues, object existing = null) + { + if (resultType == null) + return existing ?? returnValues.ToDynamic(); + + Dictionary payload = new Dictionary(); + + if (mainResultName != null) + payload[mainResultName] = mainResult == DBNull.Value ? null : mainResult; + + if (returnValues != null) + foreach (KeyValuePair item in returnValues) + payload[item.Key] = item.Value == DBNull.Value ? null : item.Value; + + DynamicTypeMap mapper = DynamicMapperCache.GetMapper(resultType); + object instance = existing; + + if (mapper != null) + instance = mapper.Map(payload.ToDynamic(), existing ?? mapper.Creator()); + else if (instance == null) + instance = payload.ToDynamic(); + + if (instance != null && resultType.IsInstanceOfType(instance)) + BindMainResultMembers(instance, mainResult); + + return instance; + } + + private static IList GetResultMemberBindings(Type resultType) + { + var properties = resultType.GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(x => x.CanWrite && x.GetIndexParameters().Length == 0) + .Select(x => new ResultMemberBinding + { + Member = x, + MemberType = x.PropertyType, + SortOrder = x.MetadataToken, + Attribute = x.GetCustomAttributes(typeof(ProcedureResultAttribute), true).Cast().FirstOrDefault() + }); + + var fields = resultType.GetFields(BindingFlags.Instance | BindingFlags.Public) + .Where(x => !x.IsInitOnly && !x.IsLiteral) + .Select(x => new ResultMemberBinding + { + Member = x, + MemberType = x.FieldType, + SortOrder = x.MetadataToken, + Attribute = x.GetCustomAttributes(typeof(ProcedureResultAttribute), true).Cast().FirstOrDefault() + }); + + return properties.Concat(fields) + .Where(x => x.Attribute != null) + .Where(x => !IsMainResultBinding(x.Attribute)) + .OrderBy(x => x.Attribute.ResultIndex) + .ThenBy(x => x.SortOrder) + .ToList(); + } + + private static IList GetMainResultBindings(Type resultType) + { + var properties = resultType.GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(x => x.CanWrite && x.GetIndexParameters().Length == 0) + .Select(x => new ResultMemberBinding + { + Member = x, + MemberType = x.PropertyType, + SortOrder = x.MetadataToken, + Attribute = x.GetCustomAttributes(typeof(ProcedureResultAttribute), true).Cast().FirstOrDefault() + }); + + var fields = resultType.GetFields(BindingFlags.Instance | BindingFlags.Public) + .Where(x => !x.IsInitOnly && !x.IsLiteral) + .Select(x => new ResultMemberBinding + { + Member = x, + MemberType = x.FieldType, + SortOrder = x.MetadataToken, + Attribute = x.GetCustomAttributes(typeof(ProcedureResultAttribute), true).Cast().FirstOrDefault() + }); + + return properties.Concat(fields) + .Where(x => x.Attribute != null) + .Where(x => IsMainResultBinding(x.Attribute)) + .OrderBy(x => x.SortOrder) + .ToList(); + } + + private static void BindResultMembers(object instance, IDataReader reader, IList bindings) + { + ValidateBindings(instance.GetType(), bindings); + + int currentIndex = 0; + bool hasCurrent = true; + + for (int i = 0; i < bindings.Count; i++) + { + ResultMemberBinding binding = bindings[i]; + while (hasCurrent && currentIndex < binding.Attribute.ResultIndex) + { + hasCurrent = reader.NextResult(); + currentIndex++; + } + + if (!hasCurrent || currentIndex != binding.Attribute.ResultIndex) + break; + + object value = ReadResultValue(binding.MemberType, binding.Attribute, reader); + binding.SetValue(instance, value); + + if (i + 1 < bindings.Count) + { + hasCurrent = reader.NextResult(); + currentIndex++; + } + } + } + + private static void ValidateBindings(Type resultType, IList bindings) + { + var duplicates = bindings.GroupBy(x => x.Attribute.ResultIndex).FirstOrDefault(x => x.Count() > 1); + if (duplicates != null) + throw new InvalidOperationException(string.Format("Type '{0}' defines multiple ProcedureResultAttribute bindings for result index {1}.", resultType.FullName, duplicates.Key)); + } + + private static void BindMainResultMembers(object instance, object mainResult) + { + if (instance == null) + return; + + IList bindings = GetMainResultBindings(instance.GetType()); + if (bindings.Count == 0) + return; + if (bindings.Count > 1) + throw new InvalidOperationException(string.Format("Type '{0}' defines multiple ProcedureResultAttribute bindings for the main procedure result.", instance.GetType().FullName)); + + ResultMemberBinding binding = bindings[0]; + object value = ConvertScalarValue(binding.MemberType, mainResult == DBNull.Value ? null : mainResult); + binding.SetValue(instance, value); + } + + private static object ReadResultValue(Type propertyType, ProcedureResultAttribute attr, IDataReader reader) + { + Type elementType; + + if (propertyType == typeof(DataTable)) + return reader.ToDataTable(string.IsNullOrEmpty(attr.Name) ? null : attr.Name); + + if (TryGetEnumerableElementType(propertyType, out elementType)) + { + if (elementType == typeof(object)) + return reader.EnumerateReader().ToList(); + + if (elementType.IsValueType || elementType == typeof(string) || elementType == typeof(Guid)) + return ReadSimpleList(propertyType, elementType, attr, reader); + + return ReadComplexList(propertyType, elementType, reader); + } + + if (propertyType.IsValueType || propertyType == typeof(string) || propertyType == typeof(Guid)) + return ReadSimpleValue(propertyType, attr, reader); + + return ReadComplexValue(propertyType, reader); + } + + private static object ReadSimpleValue(Type propertyType, ProcedureResultAttribute attr, IDataReader reader) + { + object value = null; + int ordinal = -1; + bool haveRow = false; + + while (reader.Read()) + { + if (!haveRow) + { + ordinal = GetOrdinal(reader, attr); + value = reader.IsDBNull(ordinal) ? null : reader[ordinal]; + haveRow = true; + } + } + + if (!haveRow || value == null) + return propertyType.GetDefaultValue(); + + return ConvertScalarValue(propertyType, value); + } + + private static object ReadSimpleList(Type propertyType, Type elementType, ProcedureResultAttribute attr, IDataReader reader) + { + Type targetElementType = Nullable.GetUnderlyingType(elementType) ?? elementType; + Type listType = typeof(List<>).MakeGenericType(elementType); + var list = (System.Collections.IList)Activator.CreateInstance(listType); + int ordinal = -1; + bool initialized = false; + + while (reader.Read()) + { + if (!initialized) + { + ordinal = GetOrdinal(reader, attr); + initialized = true; + } + + if (reader.IsDBNull(ordinal)) + { + list.Add(elementType.GetDefaultValue()); + continue; + } + + object value = reader[ordinal]; + if (targetElementType == typeof(Guid)) + { + Guid g; + if (Guid.TryParse(value.ToString(), out g)) + list.Add(elementType == typeof(Guid) ? (object)g : new Guid?(g)); + else + list.Add(elementType.GetDefaultValue()); + } + else if (targetElementType.IsEnum) + list.Add(Enum.ToObject(targetElementType, value)); + else + list.Add(targetElementType == elementType ? elementType.CastObject(value) : targetElementType.CastObject(value)); + } + + if (propertyType.IsArray) + { + Array array = Array.CreateInstance(elementType, list.Count); + list.CopyTo(array, 0); + return array; + } + + return list; + } + + private static object ReadComplexValue(Type propertyType, IDataReader reader) + { + object value = null; + bool haveRow = false; + + while (reader.Read()) + { + if (!haveRow) + { + value = (reader.RowToDynamic() as object).Map(propertyType); + haveRow = true; + } + } + + return value; + } + + private static object ReadComplexList(Type propertyType, Type elementType, IDataReader reader) + { + Type listType = typeof(List<>).MakeGenericType(elementType); + var list = (System.Collections.IList)Activator.CreateInstance(listType); + + while (reader.Read()) + list.Add((reader.RowToDynamic() as object).Map(elementType)); + + if (propertyType.IsArray) + { + Array array = Array.CreateInstance(elementType, list.Count); + list.CopyTo(array, 0); + return array; + } + + return list; + } + + private static bool TryGetEnumerableElementType(Type type, out Type elementType) + { + elementType = null; + + if (type == typeof(string) || type == typeof(byte[])) + return false; + + if (type.IsArray) + { + elementType = type.GetElementType(); + return true; + } + + if (type.IsGenericType) + { + Type generic = type.GetGenericTypeDefinition(); + if (generic == typeof(List<>) || generic == typeof(IList<>) || generic == typeof(IEnumerable<>)) + { + elementType = type.GetGenericArguments()[0]; + return true; + } + } + + Type enumerableInterface = type.GetInterfaces().FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IEnumerable<>)); + if (enumerableInterface != null) + { + elementType = enumerableInterface.GetGenericArguments()[0]; + return true; + } + + return false; + } + + private static int GetOrdinal(IDataReader reader, ProcedureResultAttribute attr) + { + if (attr == null || string.IsNullOrEmpty(attr.ColumnName)) + return 0; + return reader.GetOrdinal(attr.ColumnName); + } + + private static bool IsMainResultBinding(ProcedureResultAttribute attribute) + { + return attribute != null && attribute.ResultIndex < 0; + } + + private static object ConvertScalarValue(Type targetType, object value) + { + if (value == null) + return targetType.GetDefaultValue(); + + Type underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType; + + if (underlyingType == typeof(Guid)) + { + Guid g; + if (Guid.TryParse(value.ToString(), out g)) + return targetType == typeof(Guid) ? (object)g : new Guid?(g); + return targetType.GetDefaultValue(); + } + + if (underlyingType.IsEnum) + return Enum.ToObject(underlyingType, value); + + return underlyingType == targetType ? targetType.CastObject(value) : underlyingType.CastObject(value); + } + } +} diff --git a/DynamORM/Mapper/ProcedureAttribute.cs b/DynamORM/Mapper/ProcedureAttribute.cs new file mode 100644 index 0000000..06ea9a5 --- /dev/null +++ b/DynamORM/Mapper/ProcedureAttribute.cs @@ -0,0 +1,46 @@ +/* + * DynamORM - Dynamic Object-Relational Mapping library. + * Copyright (c) 2012-2026, Grzegorz Russek (grzegorz.russek@gmail.com) + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + * THE POSSIBILITY OF SUCH DAMAGE. +*/ + +using System; + +namespace DynamORM.Mapper +{ + /// Allows to add stored procedure metadata to class. + [AttributeUsage(AttributeTargets.Class)] + public class ProcedureAttribute : Attribute + { + /// Gets or sets procedure owner name. + public string Owner { get; set; } + + /// Gets or sets procedure name. + public string Name { get; set; } + + /// Gets or sets a value indicating whether metadata overrides other defaults. + public bool Override { get; set; } + } +} diff --git a/DynamORM/Mapper/ProcedureParameterAttribute.cs b/DynamORM/Mapper/ProcedureParameterAttribute.cs new file mode 100644 index 0000000..96474b8 --- /dev/null +++ b/DynamORM/Mapper/ProcedureParameterAttribute.cs @@ -0,0 +1,83 @@ +/* + * DynamORM - Dynamic Object-Relational Mapping library. + * Copyright (c) 2012-2026, Grzegorz Russek (grzegorz.russek@gmail.com) + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + * THE POSSIBILITY OF SUCH DAMAGE. +*/ + +using System; +using System.Data; + +namespace DynamORM.Mapper +{ + /// Declares metadata for object-based stored procedure parameters. + [AttributeUsage(AttributeTargets.Property)] + public class ProcedureParameterAttribute : ColumnAttribute + { + /// Sentinel used when database type was not provided. + public const int UnspecifiedDbType = -1; + + /// Sentinel used when size was not provided. + public const int UnspecifiedSize = -1; + + /// Sentinel used when precision or scale was not provided. + public const byte UnspecifiedByte = byte.MaxValue; + + /// Gets or sets parameter direction. Defaults to input. + public ParameterDirection Direction { get; set; } + + /// Gets or sets explicit parameter order. Lower values are emitted first. + public int Order { get; set; } + + /// Gets or sets parameter database type. + public DbType DbType { get; set; } + + /// Gets or sets parameter size. + public new int Size { get; set; } + + /// Gets or sets parameter precision. + public new byte Precision { get; set; } + + /// Gets or sets parameter scale. + public new byte Scale { get; set; } + + /// Initializes a new instance of the class. + public ProcedureParameterAttribute() + { + Direction = ParameterDirection.Input; + Order = int.MaxValue; + DbType = (DbType)UnspecifiedDbType; + Size = UnspecifiedSize; + Precision = UnspecifiedByte; + Scale = UnspecifiedByte; + } + + /// Initializes a new instance of the class. + public ProcedureParameterAttribute(string name) + : this() + { + Name = name; + } + } +} diff --git a/DynamORM/Mapper/ProcedureResultAttribute.cs b/DynamORM/Mapper/ProcedureResultAttribute.cs new file mode 100644 index 0000000..b90a338 --- /dev/null +++ b/DynamORM/Mapper/ProcedureResultAttribute.cs @@ -0,0 +1,61 @@ +/* + * DynamORM - Dynamic Object-Relational Mapping library. + * Copyright (c) 2012-2026, Grzegorz Russek (grzegorz.russek@gmail.com) + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + * THE POSSIBILITY OF SUCH DAMAGE. +*/ + +using System; + +namespace DynamORM.Mapper +{ + /// Declares mapping of a typed procedure result property to a specific result set. + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] + public class ProcedureResultAttribute : Attribute + { + /// Main procedure result marker. + public const int MainResultIndex = -1; + + /// Initializes a new instance of the class. + public ProcedureResultAttribute() + : this(MainResultIndex) + { + } + + /// Initializes a new instance of the class. + public ProcedureResultAttribute(int resultIndex) + { + ResultIndex = resultIndex; + } + + /// Gets result-set index in reader order, zero based. + public int ResultIndex { get; private set; } + + /// Gets or sets optional column name for scalar/simple list extraction. + public string ColumnName { get; set; } + + /// Gets or sets optional name used for DataTable. + public string Name { get; set; } + } +} diff --git a/DynamORM/Objects/Procedure.cs b/DynamORM/Objects/Procedure.cs new file mode 100644 index 0000000..99c1025 --- /dev/null +++ b/DynamORM/Objects/Procedure.cs @@ -0,0 +1,57 @@ +/* + * DynamORM - Dynamic Object-Relational Mapping library. + * Copyright (c) 2012-2026, Grzegorz Russek (grzegorz.russek@gmail.com) + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + * THE POSSIBILITY OF SUCH DAMAGE. +*/ + +namespace DynamORM.Objects +{ + /// Exposes typed stored procedure descriptor metadata. + public interface IProcedureDescriptor + { + } + + /// Exposes typed stored procedure descriptor metadata with explicit result type. + /// Procedure result type. + public interface IProcedureDescriptor : IProcedureDescriptor + { + } + + /// Base class for typed stored procedure descriptors. + /// Procedure arguments contract. + public abstract class Procedure + : IProcedureDescriptor + where TArgs : IProcedureParameters + { + } + + /// Base class for typed stored procedure descriptors with explicit result model. + /// Procedure arguments contract. + /// Procedure result model. + public abstract class Procedure : Procedure, IProcedureDescriptor + where TArgs : IProcedureParameters + { + } +} diff --git a/DynamORM/Objects/ProcedureContracts.cs b/DynamORM/Objects/ProcedureContracts.cs new file mode 100644 index 0000000..93eed9b --- /dev/null +++ b/DynamORM/Objects/ProcedureContracts.cs @@ -0,0 +1,51 @@ +/* + * DynamORM - Dynamic Object-Relational Mapping library. + * Copyright (c) 2012-2026, Grzegorz Russek (grzegorz.russek@gmail.com) + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + * THE POSSIBILITY OF SUCH DAMAGE. +*/ + +using System.Data; + +namespace DynamORM.Objects +{ + /// Marks an object as an explicit stored procedure parameter contract. + public interface IProcedureParameters + { + } + + /// Marks an object as a stored procedure parameter contract with a declared typed result model. + /// Typed result model. + public interface IProcedureParameters : IProcedureParameters + { + } + + /// Allows typed procedure result models to consume multiple result sets directly. + public interface IProcedureResultReader + { + /// Reads all required result sets from the procedure reader. + /// Procedure result reader, usually a cached reader. + void ReadResults(IDataReader reader); + } +} diff --git a/DynamORM/TypedProcedureCall.cs b/DynamORM/TypedProcedureCall.cs new file mode 100644 index 0000000..7d09d54 --- /dev/null +++ b/DynamORM/TypedProcedureCall.cs @@ -0,0 +1,73 @@ +/* + * DynamORM - Dynamic Object-Relational Mapping library. + * Copyright (c) 2012-2026, Grzegorz Russek (grzegorz.russek@gmail.com) + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + * THE POSSIBILITY OF SUCH DAMAGE. +*/ + +using DynamORM.Objects; + +namespace DynamORM +{ + /// Typed stored procedure execution handle. + /// Procedure descriptor type. + public class TypedProcedureCall + where TProcedure : IProcedureDescriptor + { + protected readonly DynamicProcedureInvoker Invoker; + + internal TypedProcedureCall(DynamicProcedureInvoker invoker) + { + Invoker = invoker; + } + + /// Execute stored procedure descriptor. + /// Optional procedure arguments contract. + /// Procedure result. + public virtual object Exec(object args = null) + { + return Invoker.Exec(args); + } + } + + /// Typed stored procedure execution handle with strong result type. + /// Procedure descriptor type. + /// Procedure result type. + public class TypedProcedureCall : TypedProcedureCall + where TProcedure : IProcedureDescriptor + { + internal TypedProcedureCall(DynamicProcedureInvoker invoker) + : base(invoker) + { + } + + /// Execute stored procedure descriptor. + /// Optional procedure arguments contract. + /// Procedure result. + public new virtual TResult Exec(object args = null) + { + return Invoker.Exec(args); + } + } +} diff --git a/docs/quick-start.md b/docs/quick-start.md index 9f3b3d4..1600931 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -79,3 +79,15 @@ table.Delete(code: "201"); ``` These forms are validated in `DynamORM.Tests/Modify/DynamicModificationTests.cs`. + +## Stored Procedures + +Three common entry points: + +```csharp +var legacy = db.Procedures.sp_DoWork(id: 10, ret_code: 0); +var typed = db.Procedure(new MyProcedureArgs()); +var typedResult = db.TypedProcedure().Exec(new MyProcedureArgs()); +``` + +Full details are documented in [Stored Procedures](stored-procedures.md). diff --git a/docs/stored-procedures.md b/docs/stored-procedures.md index c4fd6d0..d898b46 100644 --- a/docs/stored-procedures.md +++ b/docs/stored-procedures.md @@ -1,30 +1,40 @@ # Stored Procedures -Stored procedure support is available through `db.Procedures` when `DynamicDatabaseOptions.SupportStoredProcedures` is enabled. +Stored procedure support is available when `DynamicDatabaseOptions.SupportStoredProcedures` is enabled. -This page documents actual runtime behavior from `DynamicProcedureInvoker`. +There are now five practical ways to call procedures: + +1. old dynamic invocation through `db.Procedures.SomeProc(...)` +2. parameter-contract object invocation through `db.Procedures.SomeProc(new Args())` +3. typed descriptor invocation through `db.Procedures.Exec(args)` or `db.Procedure(args)` +4. strongly typed direct calls through `db.Procedures.Exec(args)` or `db.Procedure(args)` +5. typed handle calls through `db.Procedures.Typed().Exec(args)` or `db.TypedProcedure().Exec(args)` + +This page documents current runtime behavior from `DynamicProcedureInvoker`, `DynamicProcedureParameterBinder`, `DynamicProcedureResultBinder`, and the typed procedure descriptor APIs. ## `SupportStoredProceduresResult` and Provider Differences `DynamicProcedureInvoker` can treat the procedure "main result" as either: - affected rows from `ExecuteNonQuery()` -- provider return value parameter +- provider return-value parameter This behavior is controlled by `DynamicDatabaseOptions.SupportStoredProceduresResult`. - `true`: if a return-value parameter is present, invoker uses that value as main result -- `false`: invoker keeps `ExecuteNonQuery()` result (safer for providers that do not expose SQL Server-like return value behavior) +- `false`: invoker keeps `ExecuteNonQuery()` result -Why this matters: +Why this exists: - SQL Server commonly supports procedure return values in this style -- some providers (for example Oracle setups) do not behave the same way -- forcing return-value extraction on such providers can cause runtime errors or invalid result handling +- some providers, including Oracle-style setups, do not behave the same way +- forcing SQL Server-like return-value handling can cause runtime errors or invalid result handling on those providers -If procedures fail on non-SQL Server providers, first disable `SupportStoredProceduresResult` and rely on explicit `out_` parameters for status/result codes. +If procedures fail on non-SQL Server providers, first disable `SupportStoredProceduresResult` and rely on explicit output parameters for status/result codes. -## Invocation Basics +## 1. Old Dynamic Invocation + +This is the original API: ```csharp var scalar = db.Procedures.sp_Exp_Scalar(); @@ -41,7 +51,7 @@ Final command name is `dbo.reporting.MyProc`. ## Parameter Direction Prefixes -Because dynamic `out/ref` is limited, parameter direction is encoded by argument name prefix: +Because C# dynamic invocation cannot use normal `out`/`ref` in this surface, parameter direction is encoded by argument name prefix: - `out_` => `ParameterDirection.Output` - `ret_` => `ParameterDirection.ReturnValue` @@ -57,15 +67,15 @@ dynamic res = db.Procedures.sp_Message_SetState( ret_code: 0); ``` -Prefix is removed from the exposed output key (`out_message` -> `message`). +Prefix is removed from the exposed output key, so `out_message` becomes `message` in the returned payload. -## How to Specify Type/Length for Out Parameters +## How to Specify Type, Length, Precision, or Scale for Output Parameters -This is the most common pain point. You have 2 primary options. +This is the most common issue with the old dynamic API. -## Option A: `DynamicSchemaColumn` (recommended for output-only) +### Option A: `DynamicSchemaColumn` -Use this when you need explicit type, length, precision, or scale. +Use this for output-only parameters when schema must be explicit. ```csharp dynamic res = db.Procedures.sp_Message_SetState( @@ -79,9 +89,9 @@ dynamic res = db.Procedures.sp_Message_SetState( ret_code: 0); ``` -## Option B: `DynamicColumn` (recommended for input/output with value) +### Option B: `DynamicColumn` -Use this when you need direction + value + schema in one object. +Use this when the parameter is input/output and you need both value and schema. ```csharp dynamic res = db.Procedures.sp_Message_SetState( @@ -99,9 +109,9 @@ dynamic res = db.Procedures.sp_Message_SetState( }); ``` -## Option C: Plain value + name prefix +### Option C: plain value with prefixed argument name -Quickest form, but type/size inference is automatic. +Fastest form, but type/size inference is automatic. ```csharp dynamic res = db.Procedures.sp_Message_SetState( @@ -110,62 +120,66 @@ dynamic res = db.Procedures.sp_Message_SetState( ret_code: 0); ``` -Use Option A/B whenever output size/type must be controlled. +Use `DynamicSchemaColumn` or `DynamicColumn` whenever output schema must be controlled. -## Return Shape Matrix (What You Actually Get) +## Result Shape Matrix for Old Dynamic Invocation -`DynamicProcedureInvoker` chooses result shape from generic type arguments. +### No generic type arguments -## No generic type arguments (`db.Procedures.MyProc(...)`) +`db.Procedures.MyProc(...)` Main result path: - procedure executes via `ExecuteNonQuery()` -- if return-value support is enabled and return param is present, that value replaces affected-row count +- if return-value support is enabled and a return-value parameter is present, that value replaces affected-row count Final returned object: -- if no out/ret params were requested: scalar main result (`int` or return value) +- if no out/ret/both params were requested: scalar main result - if any out/ret/both params were requested: dynamic object with keys -## One generic type argument (`MyProc(...)`) +### One generic type argument -Main result type resolution: +`db.Procedures.MyProc(...)` -- `TMain == IDataReader` => returns cached reader (`DynamicCachedReader`) -- `TMain == DataTable` => returns `DataTable` -- `TMain == List` or `IEnumerable` => list of dynamic rows -- `TMain == List` => converted first-column list -- `TMain == List` => mapped list -- `TMain == primitive/string` => scalar converted value -- `TMain == complex class` => first row mapped to class (or `null`) +Main result resolution: + +- `TMain == IDataReader` => cached reader (`DynamicCachedReader`) +- `TMain == DataTable` => `DataTable` +- `TMain == IEnumerable` / `List` => list of dynamic rows +- `TMain == IEnumerable` / `List` => first-column converted list +- `TMain == IEnumerable` / `List` => mapped list +- `TMain == primitive/string/Guid` => converted scalar +- `TMain == complex class` => first row mapped to class or `null` Final returned object: -- if no out/ret params were requested: `TMain` result directly -- if out/ret params exist: dynamic object containing main result + out params +- if no out/ret params were requested: `TMain` +- if out/ret params exist: dynamic payload containing main result plus out params Important nuance: -- when out params exist and only one generic type is provided, the result is a dynamic object, not bare `TMain`. +- with out params and only one generic type, the final result is payload-shaped, not raw `TMain` -## Two generic type arguments (`MyProc(...)`) +### Two generic type arguments -This is the preferred pattern when out params are involved. +`db.Procedures.MyProc(...)` + +This is the original preferred pattern when out params are involved. - `TMain` is resolved as above -- out/main values are packed into a dictionary-like dynamic payload -- if mapper for `TOutModel` exists, payload is mapped to `TOutModel` -- otherwise fallback is dynamic object +- main result and out params are packed into one payload +- if `TOutModel` is mappable, payload is mapped to `TOutModel` +- otherwise the result falls back to dynamic payload -## Result Key Names in Out Payload +### Out payload key names When out payload is used: -- main result is stored under procedure method name key (`binder.Name`) -- each out/both/ret param is stored under normalized parameter name (without prefix) +- main result is stored under procedure method name key +- each out/both/ret param is stored under normalized parameter name -Example call: +Example: ```csharp var res = db.Procedures.sp_Message_SetState( @@ -176,89 +190,314 @@ var res = db.Procedures.sp_Message_SetState( Expected payload keys before mapping: -- `sp_Message_SetState` (main result) +- `sp_Message_SetState` - `message` - `code` -## Preparing Result Classes Correctly +## 2. Parameter Contract Object Invocation -Use `ColumnAttribute` to map returned payload keys. +This is the new object-contract path. + +Instead of encoding output metadata into prefixed dynamic arguments, you pass a single object implementing `IProcedureParameters`. ```csharp -public class MessageSetStateResult +public class SetMessageStatusArgs : IProcedureParameters { - [Column("sp_Message_SetState")] - public int MainResult { get; set; } + [ProcedureParameter("status", Direction = ParameterDirection.ReturnValue, Order = 1)] + public int Status { get; set; } - [Column("message")] - public string Message { get; set; } + [ProcedureParameter("id", Order = 2, DbType = DbType.String, Size = 64)] + public string Id { get; set; } - [Column("code")] - public int ReturnCode { get; set; } + [ProcedureParameter("result", Direction = ParameterDirection.Output, Order = 3, DbType = DbType.Int32)] + public int Result { get; set; } + + [ProcedureParameter("description", Direction = ParameterDirection.InputOutput, Order = 4, DbType = DbType.String, Size = 1024)] + public string Description { get; set; } } ``` -If key names and property names already match, attributes are optional. - -## Common Patterns - -## Pattern 1: Main scalar only +Then: ```csharp -int count = db.Procedures.sp_CountMessages(); +var res = db.Procedures.sp_SetMessageStatus(new SetMessageStatusArgs +{ + Id = "A-100", + Description = "seed" +}); ``` -## Pattern 2: Output params + mapped output model +Rules: + +- the object must implement `IProcedureParameters` +- object mode activates only when exactly one argument is passed +- `ProcedureParameterAttribute` controls name, direction, order, type, size, precision, and scale +- `ColumnAttribute` can still provide fallback name/type/size metadata for compatibility + +## Why use parameter contracts + +Advantages: + +- one argument object instead of prefixed parameter names +- output and return-value schema are explicit on the contract +- order is stable and documented in one place +- result type can be declared through `IProcedureParameters` + +## 3. Typed Procedure Descriptors + +You can now describe a procedure with a class-level descriptor. ```csharp -var res = db.Procedures.sp_Message_SetState( - id: "abc-001", - status: 2, - out_message: new DynamicSchemaColumn { Name = "message", Type = DbType.String, Size = 1024 }, - ret_code: 0); +[Procedure(Name = "sp_set_message_status", Owner = "dbo")] +public class SetMessageStatusProcedure : Procedure +{ +} ``` -## Pattern 3: Procedure returning dataset as table +This enables: ```csharp -DataTable dt = db.Procedures.sp_Message_GetBatch(batchId: 10); +var res1 = db.Procedures.Exec(args); +var res2 = db.Procedure(args); ``` -## Pattern 4: Procedure returning mapped collection +Descriptor rules: + +- `ProcedureAttribute` is similar to `TableAttribute` +- `Name` controls procedure name +- `Owner` adds schema/owner prefix +- if `ProcedureAttribute` is omitted, descriptor class name is used as procedure name +- descriptor must inherit from `Procedure` or `Procedure` +- `TArgs` must implement `IProcedureParameters` + +## 4. Strongly Typed Direct Calls + +If the descriptor declares a result type, you can use the explicit strongly typed overloads. + +Descriptor: ```csharp -List rows = db.Procedures.sp_Message_List>(status: 1); +[Procedure(Name = "sp_set_message_status")] +public class SetMessageStatusProcedure : Procedure +{ +} ``` -## Pattern 5: Read output dynamically +Calls: ```csharp -dynamic res = db.Procedures.sp_Message_SetState( - id: "abc-001", - out_message: new DynamicSchemaColumn { Name = "message", Type = DbType.String, Size = 1024 }, - ret_code: 0); - -var message = (string)res.message; -var code = (int)res.code; +var res1 = db.Procedures.Exec(args); +var res2 = db.Procedure(args); ``` +Why this needs two generic arguments: + +- C# cannot infer a method return type from `TProcedure : IProcedureDescriptor` alone +- the descriptor constraint is enough for validation, but not enough for single-generic return typing + +So the two-generic overload is the direct strongly typed API. + +## 5. Typed Handle Calls + +To avoid repeating the result generic on the `Exec(...)` step, you can create a typed execution handle. + +```csharp +var res1 = db.Procedures.Typed().Exec(args); +var res2 = db.TypedProcedure().Exec(args); +``` + +There is also a non-result variant: + +```csharp +var res = db.TypedProcedure().Exec(args); +``` + +This API exists because it gives one setup step with descriptor/result typing, then a normal strongly typed `Exec(...)` call. + +## Declaring Typed Procedure Results + +There are three supported result declaration patterns. + +### Pattern A: payload mapping with `ColumnAttribute` + +This is the classic approach. + +```csharp +public class SetMessageStatusResult +{ + [Column("sp_set_message_status")] + public int MainResult { get; set; } + + [Column("result")] + public int Result { get; set; } + + [Column("description")] + public string Description { get; set; } + + [Column("status")] + public int Status { get; set; } +} +``` + +### Pattern B: main result through `ProcedureResultAttribute` + +This is now also supported. + +```csharp +public class SetMessageStatusResult +{ + [ProcedureResult] + public int MainResult { get; set; } + + [Column("result")] + public int Result { get; set; } + + [Column("description")] + public string Description { get; set; } +} +``` + +Equivalent explicit form: + +```csharp +[ProcedureResult(-1)] +public int MainResult { get; set; } +``` + +Notes: + +- `[Column("ProcedureName")]` is still supported and unchanged +- `[ProcedureResult]` / `[ProcedureResult(-1)]` is additive, not a replacement +- only one main-result member is allowed on a result type + +### Pattern C: multiple reader result sets through `ProcedureResultAttribute` + +A typed result can bind individual reader result sets to specific members. + +```csharp +public class BatchResult +{ + [ProcedureResult] + public int MainResult { get; set; } + + [Column("status")] + public int Status { get; set; } + + [ProcedureResult(0, ColumnName = "Code")] + public string FirstCode { get; set; } + + [ProcedureResult(1, ColumnName = "Code")] + public List Codes { get; set; } + + [ProcedureResult(2, ColumnName = "State")] + public int[] States { get; set; } + + [ProcedureResult(3)] + public UserRow User { get; set; } + + [ProcedureResult(4)] + public List Users { get; set; } + + [ProcedureResult(5, Name = "codes_table")] + public DataTable CodesTable { get; set; } +} +``` + +Supported member shapes: + +- scalar/simple value +- list or array of simple values +- mapped complex object +- list or array of mapped complex objects +- `DataTable` +- public property or public field + +For scalar and simple-list members: + +- first column is used by default +- or `ColumnName` can select a specific column + +For `DataTable` members: + +- `Name` becomes the table name + +## Custom Multi-Result Reader Logic + +If the declarative `ProcedureResultAttribute` model is not enough, the result type can implement `IProcedureResultReader`. + +```csharp +public class BatchResult : IProcedureResultReader +{ + public List Codes { get; } = new List(); + public List States { get; } = new List(); + + public void ReadResults(IDataReader reader) + { + while (reader.Read()) + Codes.Add(reader.GetString(0)); + + if (reader.NextResult()) + while (reader.Read()) + States.Add(reader.GetInt32(0)); + } +} +``` + +This remains the escape hatch for complex provider-specific or custom reading logic. + +## Which Result Type Wins + +Result type precedence for the new APIs: + +1. explicit descriptor result from `Procedure` +2. declared result from `IProcedureParameters` on the argument contract +3. old generic result arguments from dynamic invocation +4. fallback dynamic payload/scalar behavior + +That allows descriptor-level result typing to override argument-level declarations when needed. + +## Recommended Usage Patterns + +### Keep using old dynamic invocation when + +- the procedure is simple +- you want minimal ceremony +- outputs are few and dynamic payload is acceptable + +### Use parameter contracts when + +- output metadata is important +- procedures have many parameters +- you want stable parameter ordering and schema definition + +### Use typed descriptors when + +- the procedure is reused across the codebase +- you want one named procedure contract per stored procedure +- you want static procedure-name metadata instead of string-based calls + +### Use strongly typed direct calls when + +- you want a single call expression returning `TResult` +- you are fine with `Exec(...)` or `Procedure(...)` + +### Use typed handles when + +- you want the strongest typing without relying on dynamic dispatch +- you want to configure the descriptor/result type once and then call `.Exec(...)` + ## Troubleshooting Checklist -- Out value is truncated or null: - - define output schema explicitly with `DynamicSchemaColumn` (type + size) -- Unexpected return object shape: - - check whether any out/ret/both parameter was passed - - if yes, expect out payload object unless you used 2-generic mapping variant -- Mapping to class fails silently (dynamic fallback): - - ensure output model is mappable and keys match columns/properties -- Return value not appearing: - - ensure `ret_` parameter is supplied - - ensure provider option `SupportStoredProceduresResult` matches your DB behavior -- Procedure call errors on non-SQL Server providers: - - set `SupportStoredProceduresResult = false` - - return status via explicit `out_` parameters instead of return-value semantics - -## Notes - -- Behavior is implemented in `DynamORM/DynamicProcedureInvoker.cs`. -- XML examples also appear in `DynamORM/DynamicDatabase.cs`. +- output value is truncated or null: + - define explicit schema with `ProcedureParameterAttribute`, `DynamicSchemaColumn`, or `DynamicColumn` +- unexpected return object shape: + - check whether out/ret/inputoutput parameters are present + - old dynamic invocation changes shape when out params are present +- typed descriptor rejects arguments: + - verify the argument object type matches the descriptor's `TArgs` +- mapped result is incomplete: + - verify payload keys match `ColumnAttribute` names + - verify `ProcedureResultAttribute` indexes match actual reader result-set order +- procedure fails on non-SQL Server provider: + - disable `SupportStoredProceduresResult` + - return status through explicit output parameters instead of SQL Server-style return-value semantics