From 79cce3d1f4e063a0bf385daba1d7f2cebb420c20 Mon Sep 17 00:00:00 2001 From: Grzegorz Russek Date: Fri, 27 Feb 2026 14:26:16 +0100 Subject: [PATCH 01/10] Add object-based stored procedure parameter binding --- AmalgamationTool/DynamORM.Amalgamation.cs | 418 +++++++++++++----- DynamORM.Tests/Helpers/FakeDbCommand.cs | 135 ++++++ .../Helpers/ProcedureParameterModels.cs | 31 ++ .../ProcedureParameterBinderTests.cs | 117 +++++ DynamORM/DynamicProcedureInvoker.cs | 339 +++++++------- .../DynamicProcedureParameterBinder.cs | 175 ++++++++ DynamORM/ProcedureParameterAttribute.cs | 84 ++++ 7 files changed, 1017 insertions(+), 282 deletions(-) create mode 100644 DynamORM.Tests/Helpers/FakeDbCommand.cs create mode 100644 DynamORM.Tests/Helpers/ProcedureParameterModels.cs create mode 100644 DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs create mode 100644 DynamORM/Helpers/DynamicProcedureParameterBinder.cs create mode 100644 DynamORM/ProcedureParameterAttribute.cs diff --git a/AmalgamationTool/DynamORM.Amalgamation.cs b/AmalgamationTool/DynamORM.Amalgamation.cs index 7fb9fe2..ab08dbc 100644 --- a/AmalgamationTool/DynamORM.Amalgamation.cs +++ b/AmalgamationTool/DynamORM.Amalgamation.cs @@ -5377,133 +5377,110 @@ namespace DynamORM 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 +5492,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); + } } } } @@ -6826,6 +6835,54 @@ namespace DynamORM #endregion IExtendedDisposable Members } + /// 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; + } + } namespace Builders { /// Typed join kind used by typed fluent builder APIs. @@ -14416,6 +14473,133 @@ namespace DynamORM return resultTable; } } + 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 (item == null) + return false; + + Type type = item.GetType(); + + if (type.IsPrimitive || type.IsEnum || type == typeof(string) || type == typeof(decimal) || type == typeof(DateTime) || type == typeof(Guid) || type == typeof(TimeSpan)) + return false; + + return GetBindableProperties(type).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; + } + } /// Framework detection and specific implementations. public static class FrameworkTools { 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/ProcedureParameterModels.cs b/DynamORM.Tests/Helpers/ProcedureParameterModels.cs new file mode 100644 index 0000000..1581645 --- /dev/null +++ b/DynamORM.Tests/Helpers/ProcedureParameterModels.cs @@ -0,0 +1,31 @@ +/* + * DynamORM - Dynamic Object-Relational Mapping library. + * Copyright (c) 2012-2026, Grzegorz Russek (grzegorz.russek@gmail.com) + * All rights reserved. + */ + +using System.Data; + +namespace DynamORM.Tests.Helpers +{ + public class ProcedureParameterObject + { + [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 + { + [DynamORM.Mapper.Column("code", false, DbType.String, 64)] + public string Code { get; set; } + } +} diff --git a/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs b/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs new file mode 100644 index 0000000..dc43bab --- /dev/null +++ b/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs @@ -0,0 +1,117 @@ +/* + * DynamORM - Dynamic Object-Relational Mapping library. + * Copyright (c) 2012-2026, Grzegorz Russek (grzegorz.russek@gmail.com) + * All rights reserved. + */ + +using System.Data; +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); + } + } + } +} diff --git a/DynamORM/DynamicProcedureInvoker.cs b/DynamORM/DynamicProcedureInvoker.cs index 7758da2..dff15de 100644 --- a/DynamORM/DynamicProcedureInvoker.cs +++ b/DynamORM/DynamicProcedureInvoker.cs @@ -31,13 +31,13 @@ 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; + +namespace DynamORM +{ /// Dynamic procedure invoker. /// Unfortunately I can use out and ref to /// return parameters, . @@ -106,164 +106,173 @@ namespace DynamORM #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 (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 diff --git a/DynamORM/Helpers/DynamicProcedureParameterBinder.cs b/DynamORM/Helpers/DynamicProcedureParameterBinder.cs new file mode 100644 index 0000000..65960f6 --- /dev/null +++ b/DynamORM/Helpers/DynamicProcedureParameterBinder.cs @@ -0,0 +1,175 @@ +/* + * 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 (item == null) + return false; + + Type type = item.GetType(); + + if (type.IsPrimitive || type.IsEnum || type == typeof(string) || type == typeof(decimal) || type == typeof(DateTime) || type == typeof(Guid) || type == typeof(TimeSpan)) + return false; + + return GetBindableProperties(type).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/ProcedureParameterAttribute.cs b/DynamORM/ProcedureParameterAttribute.cs new file mode 100644 index 0000000..c45586b --- /dev/null +++ b/DynamORM/ProcedureParameterAttribute.cs @@ -0,0 +1,84 @@ +/* + * 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; +using DynamORM.Mapper; + +namespace DynamORM +{ + /// 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; + } + } +} From c9a41adef309d1aa5c59e12c256a5d457df4ae36 Mon Sep 17 00:00:00 2001 From: Grzegorz Russek Date: Fri, 27 Feb 2026 15:18:11 +0100 Subject: [PATCH 02/10] Add typed procedure contract result handling --- AmalgamationTool/DynamORM.Amalgamation.cs | 107 ++++++++++++-- .../Helpers/FakeMultiResultDataReader.cs | 135 ++++++++++++++++++ .../Helpers/ProcedureParameterModels.cs | 47 +++++- .../ProcedureParameterBinderTests.cs | 82 +++++++++++ DynamORM/DynamicProcedureInvoker.cs | 76 ++++++---- .../DynamicProcedureParameterBinder.cs | 9 +- .../Helpers/DynamicProcedureResultBinder.cs | 106 ++++++++++++++ DynamORM/ProcedureContracts.cs | 51 +++++++ 8 files changed, 564 insertions(+), 49 deletions(-) create mode 100644 DynamORM.Tests/Helpers/FakeMultiResultDataReader.cs create mode 100644 DynamORM/Helpers/DynamicProcedureResultBinder.cs create mode 100644 DynamORM/ProcedureContracts.cs diff --git a/AmalgamationTool/DynamORM.Amalgamation.cs b/AmalgamationTool/DynamORM.Amalgamation.cs index ab08dbc..696a5ec 100644 --- a/AmalgamationTool/DynamORM.Amalgamation.cs +++ b/AmalgamationTool/DynamORM.Amalgamation.cs @@ -5358,7 +5358,8 @@ namespace DynamORM CallInfo info = binder.CallInfo; // Get generic types - IList types = binder.GetGenericTypeArguments(); + IList types = binder.GetGenericTypeArguments() ?? new List(); + Type declaredResultType = null; Dictionary retParams = null; @@ -5374,6 +5375,7 @@ namespace DynamORM int alen = args.Length; bool retIsAdded = false; + declaredResultType = alen == 1 ? DynamicProcedureResultBinder.GetDeclaredResultType(args[0]) : null; if (alen > 0) { @@ -5539,7 +5541,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(); @@ -5695,9 +5703,15 @@ namespace DynamORM else result = res.ToDynamic(); } + else if (declaredResultType != null) + result = DynamicProcedureResultBinder.BindPayload(declaredResultType, binder.Name, mainResult, res.Where(x => x.Key != binder.Name).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, binder.Name, mainResult, null); else result = mainResult; @@ -6835,6 +6849,22 @@ namespace DynamORM #endregion IExtendedDisposable Members } + /// 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); + } /// Declares metadata for object-based stored procedure parameters. [AttributeUsage(AttributeTargets.Property)] public class ProcedureParameterAttribute : ColumnAttribute @@ -14482,15 +14512,10 @@ namespace DynamORM } internal static bool CanBind(object item) { - if (item == null) + if (!DynamicProcedureResultBinder.IsProcedureContract(item)) return false; - Type type = item.GetType(); - - if (type.IsPrimitive || type.IsEnum || type == typeof(string) || type == typeof(decimal) || type == typeof(DateTime) || type == typeof(Guid) || type == typeof(TimeSpan)) - return false; - - return GetBindableProperties(type).Any(); + return GetBindableProperties(item.GetType()).Any(); } internal static BindingResult Bind(DynamicDatabase db, IDbCommand cmd, object item) { @@ -14600,6 +14625,70 @@ namespace DynamORM return 0; } } + internal static class DynamicProcedureResultBinder + { + 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); + } + 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 implement IProcedureResultReader.", resultType == null ? "" : resultType.FullName)); + + object instance = CreateDeclaredResult(resultType); + ((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); + if (mapper != null) + return mapper.Map(payload.ToDynamic(), existing ?? mapper.Creator()); + + if (existing != null) + return existing; + + return payload.ToDynamic(); + } + } /// Framework detection and specific implementations. public static class FrameworkTools { 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 index 1581645..d753425 100644 --- a/DynamORM.Tests/Helpers/ProcedureParameterModels.cs +++ b/DynamORM.Tests/Helpers/ProcedureParameterModels.cs @@ -8,7 +8,7 @@ using System.Data; namespace DynamORM.Tests.Helpers { - public class ProcedureParameterObject + public class ProcedureParameterObject : IProcedureParameters { [ProcedureParameter("code", Order = 2, DbType = DbType.String, Size = 32)] public string Code { get; set; } @@ -23,9 +23,52 @@ namespace DynamORM.Tests.Helpers public int Status { get; set; } } - public class ProcedureParameterColumnFallbackObject + 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 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 ProcedureMultiResultArgs : IProcedureParameters + { + [ProcedureParameter("status", Direction = ParameterDirection.Output, Order = 1, DbType = DbType.Int32)] + public int Status { get; set; } + } } diff --git a/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs b/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs index dc43bab..e9392bc 100644 --- a/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs +++ b/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs @@ -113,5 +113,87 @@ namespace DynamORM.Tests.Procedure 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 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 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 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); + } } } diff --git a/DynamORM/DynamicProcedureInvoker.cs b/DynamORM/DynamicProcedureInvoker.cs index dff15de..329ca72 100644 --- a/DynamORM/DynamicProcedureInvoker.cs +++ b/DynamORM/DynamicProcedureInvoker.cs @@ -89,15 +89,16 @@ namespace DynamORM 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()) + CallInfo info = binder.CallInfo; + + // Get generic types + IList types = binder.GetGenericTypeArguments() ?? new List(); + Type declaredResultType = null; + + 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); @@ -108,6 +109,7 @@ namespace DynamORM int alen = args.Length; bool retIsAdded = false; + declaredResultType = alen == 1 ? DynamicProcedureResultBinder.GetDeclaredResultType(args[0]) : null; if (alen > 0) { @@ -278,11 +280,17 @@ namespace DynamORM #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)) { @@ -418,9 +426,9 @@ namespace DynamORM #region Handle out params - if (retParams != null) - { - Dictionary res = new Dictionary(); + if (retParams != null) + { + Dictionary res = new Dictionary(); if (mainResult != null) { @@ -430,23 +438,29 @@ namespace DynamORM res.Add(binder.Name, 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; + result = mapper.Create(res.ToDynamic()); + else + result = res.ToDynamic(); + } + else if (declaredResultType != null) + result = DynamicProcedureResultBinder.BindPayload(declaredResultType, binder.Name, mainResult, res.Where(x => x.Key != binder.Name).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, binder.Name, mainResult, null); + else + result = mainResult; #endregion Handle out params } diff --git a/DynamORM/Helpers/DynamicProcedureParameterBinder.cs b/DynamORM/Helpers/DynamicProcedureParameterBinder.cs index 65960f6..30cd4d6 100644 --- a/DynamORM/Helpers/DynamicProcedureParameterBinder.cs +++ b/DynamORM/Helpers/DynamicProcedureParameterBinder.cs @@ -45,15 +45,10 @@ namespace DynamORM.Helpers internal static bool CanBind(object item) { - if (item == null) + if (!DynamicProcedureResultBinder.IsProcedureContract(item)) return false; - Type type = item.GetType(); - - if (type.IsPrimitive || type.IsEnum || type == typeof(string) || type == typeof(decimal) || type == typeof(DateTime) || type == typeof(Guid) || type == typeof(TimeSpan)) - return false; - - return GetBindableProperties(type).Any(); + return GetBindableProperties(item.GetType()).Any(); } internal static BindingResult Bind(DynamicDatabase db, IDbCommand cmd, object item) diff --git a/DynamORM/Helpers/DynamicProcedureResultBinder.cs b/DynamORM/Helpers/DynamicProcedureResultBinder.cs new file mode 100644 index 0000000..1845dff --- /dev/null +++ b/DynamORM/Helpers/DynamicProcedureResultBinder.cs @@ -0,0 +1,106 @@ +/* + * 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 DynamORM.Mapper; + +namespace DynamORM.Helpers +{ + internal static class DynamicProcedureResultBinder + { + 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); + } + + 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 implement IProcedureResultReader.", resultType == null ? "" : resultType.FullName)); + + object instance = CreateDeclaredResult(resultType); + ((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); + if (mapper != null) + return mapper.Map(payload.ToDynamic(), existing ?? mapper.Creator()); + + if (existing != null) + return existing; + + return payload.ToDynamic(); + } + } +} diff --git a/DynamORM/ProcedureContracts.cs b/DynamORM/ProcedureContracts.cs new file mode 100644 index 0000000..b2c32c7 --- /dev/null +++ b/DynamORM/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 +{ + /// 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); + } +} From cb6437ee9dfbed0d6db6638d1bbb11d2cb8a4a03 Mon Sep 17 00:00:00 2001 From: Grzegorz Russek Date: Fri, 27 Feb 2026 15:23:40 +0100 Subject: [PATCH 03/10] Move new classes to proper namespaces --- DynamORM.Tests/Helpers/ProcedureParameterModels.cs | 2 ++ DynamORM/Helpers/DynamicProcedureResultBinder.cs | 1 + DynamORM/{ => Mapper}/ProcedureParameterAttribute.cs | 3 +-- DynamORM/{ => Objects}/ProcedureContracts.cs | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) rename DynamORM/{ => Mapper}/ProcedureParameterAttribute.cs (98%) rename DynamORM/{ => Objects}/ProcedureContracts.cs (98%) diff --git a/DynamORM.Tests/Helpers/ProcedureParameterModels.cs b/DynamORM.Tests/Helpers/ProcedureParameterModels.cs index d753425..2aa2328 100644 --- a/DynamORM.Tests/Helpers/ProcedureParameterModels.cs +++ b/DynamORM.Tests/Helpers/ProcedureParameterModels.cs @@ -5,6 +5,8 @@ */ using System.Data; +using DynamORM.Mapper; +using DynamORM.Objects; namespace DynamORM.Tests.Helpers { diff --git a/DynamORM/Helpers/DynamicProcedureResultBinder.cs b/DynamORM/Helpers/DynamicProcedureResultBinder.cs index 1845dff..fa283e9 100644 --- a/DynamORM/Helpers/DynamicProcedureResultBinder.cs +++ b/DynamORM/Helpers/DynamicProcedureResultBinder.cs @@ -31,6 +31,7 @@ using System.Collections.Generic; using System.Data; using System.Linq; using DynamORM.Mapper; +using DynamORM.Objects; namespace DynamORM.Helpers { diff --git a/DynamORM/ProcedureParameterAttribute.cs b/DynamORM/Mapper/ProcedureParameterAttribute.cs similarity index 98% rename from DynamORM/ProcedureParameterAttribute.cs rename to DynamORM/Mapper/ProcedureParameterAttribute.cs index c45586b..96474b8 100644 --- a/DynamORM/ProcedureParameterAttribute.cs +++ b/DynamORM/Mapper/ProcedureParameterAttribute.cs @@ -28,9 +28,8 @@ using System; using System.Data; -using DynamORM.Mapper; -namespace DynamORM +namespace DynamORM.Mapper { /// Declares metadata for object-based stored procedure parameters. [AttributeUsage(AttributeTargets.Property)] diff --git a/DynamORM/ProcedureContracts.cs b/DynamORM/Objects/ProcedureContracts.cs similarity index 98% rename from DynamORM/ProcedureContracts.cs rename to DynamORM/Objects/ProcedureContracts.cs index b2c32c7..93eed9b 100644 --- a/DynamORM/ProcedureContracts.cs +++ b/DynamORM/Objects/ProcedureContracts.cs @@ -28,7 +28,7 @@ using System.Data; -namespace DynamORM +namespace DynamORM.Objects { /// Marks an object as an explicit stored procedure parameter contract. public interface IProcedureParameters From 99ff6b3d298f5ee975d8875cec08c94a4d2ee85b Mon Sep 17 00:00:00 2001 From: Grzegorz Russek Date: Fri, 27 Feb 2026 15:52:42 +0100 Subject: [PATCH 04/10] Add declarative procedure result-set binding --- AmalgamationTool/DynamORM.Amalgamation.cs | 375 ++++++++++++++---- .../Helpers/ProcedureParameterModels.cs | 33 ++ .../ProcedureParameterBinderTests.cs | 42 ++ DynamORM/DynamicProcedureInvoker.cs | 2 +- .../Helpers/DynamicProcedureResultBinder.cs | 251 +++++++++++- DynamORM/Mapper/ProcedureResultAttribute.cs | 52 +++ 6 files changed, 679 insertions(+), 76 deletions(-) create mode 100644 DynamORM/Mapper/ProcedureResultAttribute.cs diff --git a/AmalgamationTool/DynamORM.Amalgamation.cs b/AmalgamationTool/DynamORM.Amalgamation.cs index 696a5ec..4b7b544 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; @@ -5541,7 +5542,7 @@ namespace DynamORM object mainResult = null; - if (types.Count == 0 && DynamicProcedureResultBinder.CanReadResults(declaredResultType)) + if (types.Count == 0 && DynamicProcedureResultBinder.HasDeclaredResultBinding(declaredResultType)) { using (IDataReader rdr = cmd.ExecuteReader()) using (IDataReader cache = rdr.CachedReader()) @@ -6849,70 +6850,6 @@ namespace DynamORM #endregion IExtendedDisposable Members } - /// 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); - } - /// 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; - } - } namespace Builders { /// Typed join kind used by typed fluent builder APIs. @@ -14627,6 +14564,11 @@ namespace DynamORM } internal static class DynamicProcedureResultBinder { + private sealed class ResultPropertyBinding + { + public ProcedureResultAttribute Attribute { get; set; } + public PropertyInfo Property { get; set; } + } internal static bool IsProcedureContract(object item) { return item is IProcedureParameters; @@ -14641,9 +14583,10 @@ namespace DynamORM return iface == null ? null : iface.GetGenericArguments()[0]; } - internal static bool CanReadResults(Type resultType) + internal static bool HasDeclaredResultBinding(Type resultType) { - return resultType != null && typeof(IProcedureResultReader).IsAssignableFrom(resultType); + return resultType != null && + (typeof(IProcedureResultReader).IsAssignableFrom(resultType) || GetResultPropertyBindings(resultType).Count > 0); } internal static object CreateDeclaredResult(Type resultType) { @@ -14658,11 +14601,17 @@ namespace DynamORM } internal static object ReadDeclaredResult(Type resultType, IDataReader reader) { - if (!CanReadResults(resultType)) - throw new InvalidOperationException(string.Format("Type '{0}' does not implement IProcedureResultReader.", resultType == null ? "" : resultType.FullName)); + if (!HasDeclaredResultBinding(resultType)) + throw new InvalidOperationException(string.Format("Type '{0}' does not declare a supported procedure result binding.", resultType == null ? "" : resultType.FullName)); object instance = CreateDeclaredResult(resultType); - ((IProcedureResultReader)instance).ReadResults(reader); + + IList bindings = GetResultPropertyBindings(resultType); + if (bindings.Count > 0) + BindResultProperties(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) @@ -14688,6 +14637,210 @@ namespace DynamORM return payload.ToDynamic(); } + private static IList GetResultPropertyBindings(Type resultType) + { + return resultType.GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(x => x.CanWrite && x.GetIndexParameters().Length == 0) + .Select(x => new ResultPropertyBinding + { + Property = x, + Attribute = x.GetCustomAttributes(typeof(ProcedureResultAttribute), true).Cast().FirstOrDefault() + }) + .Where(x => x.Attribute != null) + .OrderBy(x => x.Attribute.ResultIndex) + .ThenBy(x => x.Property.MetadataToken) + .ToList(); + } + private static void BindResultProperties(object instance, IDataReader reader, IList bindings) + { + ValidateBindings(instance.GetType(), bindings); + + int currentIndex = 0; + bool hasCurrent = true; + + for (int i = 0; i < bindings.Count; i++) + { + ResultPropertyBinding binding = bindings[i]; + while (hasCurrent && currentIndex < binding.Attribute.ResultIndex) + { + hasCurrent = reader.NextResult(); + currentIndex++; + } + if (!hasCurrent || currentIndex != binding.Attribute.ResultIndex) + break; + + object value = ReadResultValue(binding.Property.PropertyType, binding.Attribute, reader); + binding.Property.SetValue(instance, value, null); + + 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 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(); + + if (propertyType == typeof(Guid)) + { + Guid g; + if (Guid.TryParse(value.ToString(), out g)) + return g; + return propertyType.GetDefaultValue(); + } + return propertyType.CastObject(value); + } + private static object ReadSimpleList(Type propertyType, Type elementType, ProcedureResultAttribute attr, IDataReader reader) + { + 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 (elementType == typeof(Guid)) + { + Guid g; + list.Add(Guid.TryParse(value.ToString(), out g) ? (object)g : elementType.GetDefaultValue()); + } + else + list.Add(elementType.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; + + int ordinal = reader.GetOrdinal(attr.ColumnName); + if (ordinal < 0) + throw new IndexOutOfRangeException(attr.ColumnName); + + return ordinal; + } } /// Framework detection and specific implementations. public static class FrameworkTools @@ -17684,6 +17837,72 @@ namespace DynamORM public class IgnoreAttribute : Attribute { } + /// 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)] + public class ProcedureResultAttribute : Attribute + { + /// 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 @@ -18131,6 +18350,22 @@ namespace DynamORM _database = null; } } + /// 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/ProcedureParameterModels.cs b/DynamORM.Tests/Helpers/ProcedureParameterModels.cs index 2aa2328..70c3077 100644 --- a/DynamORM.Tests/Helpers/ProcedureParameterModels.cs +++ b/DynamORM.Tests/Helpers/ProcedureParameterModels.cs @@ -68,6 +68,39 @@ namespace DynamORM.Tests.Helpers } } + 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 ProcedureMultiResultArgs : IProcedureParameters { [ProcedureParameter("status", Direction = ParameterDirection.Output, Order = 1, DbType = DbType.Int32)] diff --git a/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs b/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs index e9392bc..70a1c22 100644 --- a/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs +++ b/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs @@ -172,6 +172,40 @@ namespace DynamORM.Tests.Procedure } } + [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 TestDeclaredResultPayloadCanAugmentReaderResult() { @@ -195,5 +229,13 @@ namespace DynamORM.Tests.Procedure CollectionAssert.AreEqual(new[] { "A" }, result.Codes); CollectionAssert.AreEqual(new[] { 10 }, result.States); } + + [Test] + public void TestDeclaredResultBindingDetectionSupportsAttributedResults() + { + Assert.IsTrue(DynamicProcedureResultBinder.HasDeclaredResultBinding(typeof(ProcedureMultiResult))); + Assert.IsTrue(DynamicProcedureResultBinder.HasDeclaredResultBinding(typeof(ProcedureAttributedResult))); + Assert.IsFalse(DynamicProcedureResultBinder.HasDeclaredResultBinding(typeof(ProcedureParameterResult))); + } } } diff --git a/DynamORM/DynamicProcedureInvoker.cs b/DynamORM/DynamicProcedureInvoker.cs index 329ca72..c56ed29 100644 --- a/DynamORM/DynamicProcedureInvoker.cs +++ b/DynamORM/DynamicProcedureInvoker.cs @@ -282,7 +282,7 @@ namespace DynamORM object mainResult = null; - if (types.Count == 0 && DynamicProcedureResultBinder.CanReadResults(declaredResultType)) + if (types.Count == 0 && DynamicProcedureResultBinder.HasDeclaredResultBinding(declaredResultType)) { using (IDataReader rdr = cmd.ExecuteReader()) using (IDataReader cache = rdr.CachedReader()) diff --git a/DynamORM/Helpers/DynamicProcedureResultBinder.cs b/DynamORM/Helpers/DynamicProcedureResultBinder.cs index fa283e9..eb00a8b 100644 --- a/DynamORM/Helpers/DynamicProcedureResultBinder.cs +++ b/DynamORM/Helpers/DynamicProcedureResultBinder.cs @@ -30,6 +30,7 @@ using System; using System.Collections.Generic; using System.Data; using System.Linq; +using System.Reflection; using DynamORM.Mapper; using DynamORM.Objects; @@ -37,6 +38,12 @@ namespace DynamORM.Helpers { internal static class DynamicProcedureResultBinder { + private sealed class ResultPropertyBinding + { + public ProcedureResultAttribute Attribute { get; set; } + public PropertyInfo Property { get; set; } + } + internal static bool IsProcedureContract(object item) { return item is IProcedureParameters; @@ -53,9 +60,10 @@ namespace DynamORM.Helpers return iface == null ? null : iface.GetGenericArguments()[0]; } - internal static bool CanReadResults(Type resultType) + internal static bool HasDeclaredResultBinding(Type resultType) { - return resultType != null && typeof(IProcedureResultReader).IsAssignableFrom(resultType); + return resultType != null && + (typeof(IProcedureResultReader).IsAssignableFrom(resultType) || GetResultPropertyBindings(resultType).Count > 0); } internal static object CreateDeclaredResult(Type resultType) @@ -72,11 +80,17 @@ namespace DynamORM.Helpers internal static object ReadDeclaredResult(Type resultType, IDataReader reader) { - if (!CanReadResults(resultType)) - throw new InvalidOperationException(string.Format("Type '{0}' does not implement IProcedureResultReader.", resultType == null ? "" : resultType.FullName)); + if (!HasDeclaredResultBinding(resultType)) + throw new InvalidOperationException(string.Format("Type '{0}' does not declare a supported procedure result binding.", resultType == null ? "" : resultType.FullName)); object instance = CreateDeclaredResult(resultType); - ((IProcedureResultReader)instance).ReadResults(reader); + + IList bindings = GetResultPropertyBindings(resultType); + if (bindings.Count > 0) + BindResultProperties(instance, reader, bindings); + else + ((IProcedureResultReader)instance).ReadResults(reader); + return instance; } @@ -103,5 +117,232 @@ namespace DynamORM.Helpers return payload.ToDynamic(); } + + private static IList GetResultPropertyBindings(Type resultType) + { + return resultType.GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(x => x.CanWrite && x.GetIndexParameters().Length == 0) + .Select(x => new ResultPropertyBinding + { + Property = x, + Attribute = x.GetCustomAttributes(typeof(ProcedureResultAttribute), true).Cast().FirstOrDefault() + }) + .Where(x => x.Attribute != null) + .OrderBy(x => x.Attribute.ResultIndex) + .ThenBy(x => x.Property.MetadataToken) + .ToList(); + } + + private static void BindResultProperties(object instance, IDataReader reader, IList bindings) + { + ValidateBindings(instance.GetType(), bindings); + + int currentIndex = 0; + bool hasCurrent = true; + + for (int i = 0; i < bindings.Count; i++) + { + ResultPropertyBinding binding = bindings[i]; + while (hasCurrent && currentIndex < binding.Attribute.ResultIndex) + { + hasCurrent = reader.NextResult(); + currentIndex++; + } + + if (!hasCurrent || currentIndex != binding.Attribute.ResultIndex) + break; + + object value = ReadResultValue(binding.Property.PropertyType, binding.Attribute, reader); + binding.Property.SetValue(instance, value, null); + + 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 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(); + + if (propertyType == typeof(Guid)) + { + Guid g; + if (Guid.TryParse(value.ToString(), out g)) + return g; + return propertyType.GetDefaultValue(); + } + + return propertyType.CastObject(value); + } + + private static object ReadSimpleList(Type propertyType, Type elementType, ProcedureResultAttribute attr, IDataReader reader) + { + 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 (elementType == typeof(Guid)) + { + Guid g; + list.Add(Guid.TryParse(value.ToString(), out g) ? (object)g : elementType.GetDefaultValue()); + } + else + list.Add(elementType.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; + + int ordinal = reader.GetOrdinal(attr.ColumnName); + if (ordinal < 0) + throw new IndexOutOfRangeException(attr.ColumnName); + + return ordinal; + } } } diff --git a/DynamORM/Mapper/ProcedureResultAttribute.cs b/DynamORM/Mapper/ProcedureResultAttribute.cs new file mode 100644 index 0000000..a99ec3f --- /dev/null +++ b/DynamORM/Mapper/ProcedureResultAttribute.cs @@ -0,0 +1,52 @@ +/* + * 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)] + public class ProcedureResultAttribute : Attribute + { + /// 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; } + } +} From 6ebda34a04a40bccd5392b67b7b68e6f82b6fe10 Mon Sep 17 00:00:00 2001 From: Grzegorz Russek Date: Fri, 27 Feb 2026 15:57:06 +0100 Subject: [PATCH 05/10] Improve declarative procedure result-set binding --- AmalgamationTool/DynamORM.Amalgamation.cs | 102 ++++++++++++------ .../Helpers/ProcedureParameterModels.cs | 18 ++++ .../ProcedureParameterBinderTests.cs | 37 ++++++- DynamORM/DynamicProcedureInvoker.cs | 2 +- .../Helpers/DynamicProcedureResultBinder.cs | 100 +++++++++++------ DynamORM/Mapper/ProcedureResultAttribute.cs | 2 +- 6 files changed, 194 insertions(+), 67 deletions(-) diff --git a/AmalgamationTool/DynamORM.Amalgamation.cs b/AmalgamationTool/DynamORM.Amalgamation.cs index 4b7b544..7784c0c 100644 --- a/AmalgamationTool/DynamORM.Amalgamation.cs +++ b/AmalgamationTool/DynamORM.Amalgamation.cs @@ -5542,7 +5542,7 @@ namespace DynamORM object mainResult = null; - if (types.Count == 0 && DynamicProcedureResultBinder.HasDeclaredResultBinding(declaredResultType)) + if (types.Count == 0 && DynamicProcedureResultBinder.CanReadResults(declaredResultType)) { using (IDataReader rdr = cmd.ExecuteReader()) using (IDataReader cache = rdr.CachedReader()) @@ -14564,10 +14564,25 @@ namespace DynamORM } internal static class DynamicProcedureResultBinder { - private sealed class ResultPropertyBinding + private sealed class ResultMemberBinding { public ProcedureResultAttribute Attribute { get; set; } - public PropertyInfo Property { 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) { @@ -14583,10 +14598,14 @@ namespace DynamORM return iface == null ? null : iface.GetGenericArguments()[0]; } - internal static bool HasDeclaredResultBinding(Type resultType) + internal static bool CanReadResults(Type resultType) { return resultType != null && - (typeof(IProcedureResultReader).IsAssignableFrom(resultType) || GetResultPropertyBindings(resultType).Count > 0); + (typeof(IProcedureResultReader).IsAssignableFrom(resultType) || GetResultMemberBindings(resultType).Count > 0); + } + internal static bool HasDeclaredResultBinding(Type resultType) + { + return CanReadResults(resultType); } internal static object CreateDeclaredResult(Type resultType) { @@ -14601,14 +14620,14 @@ namespace DynamORM } internal static object ReadDeclaredResult(Type resultType, IDataReader reader) { - if (!HasDeclaredResultBinding(resultType)) + 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 = GetResultPropertyBindings(resultType); + IList bindings = GetResultMemberBindings(resultType); if (bindings.Count > 0) - BindResultProperties(instance, reader, bindings); + BindResultMembers(instance, reader, bindings); else ((IProcedureResultReader)instance).ReadResults(reader); @@ -14637,21 +14656,35 @@ namespace DynamORM return payload.ToDynamic(); } - private static IList GetResultPropertyBindings(Type resultType) + private static IList GetResultMemberBindings(Type resultType) { - return resultType.GetProperties(BindingFlags.Instance | BindingFlags.Public) + var properties = resultType.GetProperties(BindingFlags.Instance | BindingFlags.Public) .Where(x => x.CanWrite && x.GetIndexParameters().Length == 0) - .Select(x => new ResultPropertyBinding + .Select(x => new ResultMemberBinding { - Property = x, + 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) .OrderBy(x => x.Attribute.ResultIndex) - .ThenBy(x => x.Property.MetadataToken) + .ThenBy(x => x.SortOrder) .ToList(); } - private static void BindResultProperties(object instance, IDataReader reader, IList bindings) + private static void BindResultMembers(object instance, IDataReader reader, IList bindings) { ValidateBindings(instance.GetType(), bindings); @@ -14660,7 +14693,7 @@ namespace DynamORM for (int i = 0; i < bindings.Count; i++) { - ResultPropertyBinding binding = bindings[i]; + ResultMemberBinding binding = bindings[i]; while (hasCurrent && currentIndex < binding.Attribute.ResultIndex) { hasCurrent = reader.NextResult(); @@ -14669,8 +14702,8 @@ namespace DynamORM if (!hasCurrent || currentIndex != binding.Attribute.ResultIndex) break; - object value = ReadResultValue(binding.Property.PropertyType, binding.Attribute, reader); - binding.Property.SetValue(instance, value, null); + object value = ReadResultValue(binding.MemberType, binding.Attribute, reader); + binding.SetValue(instance, value); if (i + 1 < bindings.Count) { @@ -14679,7 +14712,7 @@ namespace DynamORM } } } - private static void ValidateBindings(Type resultType, IList bindings) + private static void ValidateBindings(Type resultType, IList bindings) { var duplicates = bindings.GroupBy(x => x.Attribute.ResultIndex).FirstOrDefault(x => x.Count() > 1); if (duplicates != null) @@ -14709,6 +14742,7 @@ namespace DynamORM } private static object ReadSimpleValue(Type propertyType, ProcedureResultAttribute attr, IDataReader reader) { + Type targetType = Nullable.GetUnderlyingType(propertyType) ?? propertyType; object value = null; int ordinal = -1; bool haveRow = false; @@ -14725,17 +14759,21 @@ namespace DynamORM if (!haveRow || value == null) return propertyType.GetDefaultValue(); - if (propertyType == typeof(Guid)) + if (targetType == typeof(Guid)) { Guid g; if (Guid.TryParse(value.ToString(), out g)) - return g; + return propertyType == typeof(Guid) ? (object)g : new Guid?(g); return propertyType.GetDefaultValue(); } - return propertyType.CastObject(value); + if (targetType.IsEnum) + return Enum.ToObject(targetType, value); + + return targetType == propertyType ? propertyType.CastObject(value) : targetType.CastObject(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; @@ -14754,13 +14792,18 @@ namespace DynamORM continue; } object value = reader[ordinal]; - if (elementType == typeof(Guid)) + if (targetElementType == typeof(Guid)) { Guid g; - list.Add(Guid.TryParse(value.ToString(), out g) ? (object)g : elementType.GetDefaultValue()); + 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(elementType.CastObject(value)); + list.Add(targetElementType == elementType ? elementType.CastObject(value) : targetElementType.CastObject(value)); } if (propertyType.IsArray) { @@ -14834,12 +14877,7 @@ namespace DynamORM { if (attr == null || string.IsNullOrEmpty(attr.ColumnName)) return 0; - - int ordinal = reader.GetOrdinal(attr.ColumnName); - if (ordinal < 0) - throw new IndexOutOfRangeException(attr.ColumnName); - - return ordinal; + return reader.GetOrdinal(attr.ColumnName); } } /// Framework detection and specific implementations. @@ -17886,7 +17924,7 @@ namespace DynamORM } } /// Declares mapping of a typed procedure result property to a specific result set. - [AttributeUsage(AttributeTargets.Property)] + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] public class ProcedureResultAttribute : Attribute { /// Initializes a new instance of the class. diff --git a/DynamORM.Tests/Helpers/ProcedureParameterModels.cs b/DynamORM.Tests/Helpers/ProcedureParameterModels.cs index 70c3077..e86e8e6 100644 --- a/DynamORM.Tests/Helpers/ProcedureParameterModels.cs +++ b/DynamORM.Tests/Helpers/ProcedureParameterModels.cs @@ -101,6 +101,24 @@ namespace DynamORM.Tests.Helpers 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)] diff --git a/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs b/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs index 70a1c22..648abe5 100644 --- a/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs +++ b/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs @@ -206,6 +206,36 @@ namespace DynamORM.Tests.Procedure } } + [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() { @@ -233,9 +263,10 @@ namespace DynamORM.Tests.Procedure [Test] public void TestDeclaredResultBindingDetectionSupportsAttributedResults() { - Assert.IsTrue(DynamicProcedureResultBinder.HasDeclaredResultBinding(typeof(ProcedureMultiResult))); - Assert.IsTrue(DynamicProcedureResultBinder.HasDeclaredResultBinding(typeof(ProcedureAttributedResult))); - Assert.IsFalse(DynamicProcedureResultBinder.HasDeclaredResultBinding(typeof(ProcedureParameterResult))); + 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/DynamicProcedureInvoker.cs b/DynamORM/DynamicProcedureInvoker.cs index c56ed29..329ca72 100644 --- a/DynamORM/DynamicProcedureInvoker.cs +++ b/DynamORM/DynamicProcedureInvoker.cs @@ -282,7 +282,7 @@ namespace DynamORM object mainResult = null; - if (types.Count == 0 && DynamicProcedureResultBinder.HasDeclaredResultBinding(declaredResultType)) + if (types.Count == 0 && DynamicProcedureResultBinder.CanReadResults(declaredResultType)) { using (IDataReader rdr = cmd.ExecuteReader()) using (IDataReader cache = rdr.CachedReader()) diff --git a/DynamORM/Helpers/DynamicProcedureResultBinder.cs b/DynamORM/Helpers/DynamicProcedureResultBinder.cs index eb00a8b..4759935 100644 --- a/DynamORM/Helpers/DynamicProcedureResultBinder.cs +++ b/DynamORM/Helpers/DynamicProcedureResultBinder.cs @@ -38,10 +38,26 @@ namespace DynamORM.Helpers { internal static class DynamicProcedureResultBinder { - private sealed class ResultPropertyBinding + private sealed class ResultMemberBinding { public ProcedureResultAttribute Attribute { get; set; } - public PropertyInfo Property { 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) @@ -60,10 +76,15 @@ namespace DynamORM.Helpers return iface == null ? null : iface.GetGenericArguments()[0]; } - internal static bool HasDeclaredResultBinding(Type resultType) + internal static bool CanReadResults(Type resultType) { return resultType != null && - (typeof(IProcedureResultReader).IsAssignableFrom(resultType) || GetResultPropertyBindings(resultType).Count > 0); + (typeof(IProcedureResultReader).IsAssignableFrom(resultType) || GetResultMemberBindings(resultType).Count > 0); + } + + internal static bool HasDeclaredResultBinding(Type resultType) + { + return CanReadResults(resultType); } internal static object CreateDeclaredResult(Type resultType) @@ -80,14 +101,14 @@ namespace DynamORM.Helpers internal static object ReadDeclaredResult(Type resultType, IDataReader reader) { - if (!HasDeclaredResultBinding(resultType)) + 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 = GetResultPropertyBindings(resultType); + IList bindings = GetResultMemberBindings(resultType); if (bindings.Count > 0) - BindResultProperties(instance, reader, bindings); + BindResultMembers(instance, reader, bindings); else ((IProcedureResultReader)instance).ReadResults(reader); @@ -118,22 +139,36 @@ namespace DynamORM.Helpers return payload.ToDynamic(); } - private static IList GetResultPropertyBindings(Type resultType) + private static IList GetResultMemberBindings(Type resultType) { - return resultType.GetProperties(BindingFlags.Instance | BindingFlags.Public) + var properties = resultType.GetProperties(BindingFlags.Instance | BindingFlags.Public) .Where(x => x.CanWrite && x.GetIndexParameters().Length == 0) - .Select(x => new ResultPropertyBinding + .Select(x => new ResultMemberBinding { - Property = x, + 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) .OrderBy(x => x.Attribute.ResultIndex) - .ThenBy(x => x.Property.MetadataToken) + .ThenBy(x => x.SortOrder) .ToList(); } - private static void BindResultProperties(object instance, IDataReader reader, IList bindings) + private static void BindResultMembers(object instance, IDataReader reader, IList bindings) { ValidateBindings(instance.GetType(), bindings); @@ -142,7 +177,7 @@ namespace DynamORM.Helpers for (int i = 0; i < bindings.Count; i++) { - ResultPropertyBinding binding = bindings[i]; + ResultMemberBinding binding = bindings[i]; while (hasCurrent && currentIndex < binding.Attribute.ResultIndex) { hasCurrent = reader.NextResult(); @@ -152,8 +187,8 @@ namespace DynamORM.Helpers if (!hasCurrent || currentIndex != binding.Attribute.ResultIndex) break; - object value = ReadResultValue(binding.Property.PropertyType, binding.Attribute, reader); - binding.Property.SetValue(instance, value, null); + object value = ReadResultValue(binding.MemberType, binding.Attribute, reader); + binding.SetValue(instance, value); if (i + 1 < bindings.Count) { @@ -163,7 +198,7 @@ namespace DynamORM.Helpers } } - private static void ValidateBindings(Type resultType, IList bindings) + private static void ValidateBindings(Type resultType, IList bindings) { var duplicates = bindings.GroupBy(x => x.Attribute.ResultIndex).FirstOrDefault(x => x.Count() > 1); if (duplicates != null) @@ -196,6 +231,7 @@ namespace DynamORM.Helpers private static object ReadSimpleValue(Type propertyType, ProcedureResultAttribute attr, IDataReader reader) { + Type targetType = Nullable.GetUnderlyingType(propertyType) ?? propertyType; object value = null; int ordinal = -1; bool haveRow = false; @@ -213,19 +249,23 @@ namespace DynamORM.Helpers if (!haveRow || value == null) return propertyType.GetDefaultValue(); - if (propertyType == typeof(Guid)) + if (targetType == typeof(Guid)) { Guid g; if (Guid.TryParse(value.ToString(), out g)) - return g; + return propertyType == typeof(Guid) ? (object)g : new Guid?(g); return propertyType.GetDefaultValue(); } - return propertyType.CastObject(value); + if (targetType.IsEnum) + return Enum.ToObject(targetType, value); + + return targetType == propertyType ? propertyType.CastObject(value) : targetType.CastObject(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; @@ -246,13 +286,18 @@ namespace DynamORM.Helpers } object value = reader[ordinal]; - if (elementType == typeof(Guid)) + if (targetElementType == typeof(Guid)) { Guid g; - list.Add(Guid.TryParse(value.ToString(), out g) ? (object)g : elementType.GetDefaultValue()); + 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(elementType.CastObject(value)); + list.Add(targetElementType == elementType ? elementType.CastObject(value) : targetElementType.CastObject(value)); } if (propertyType.IsArray) @@ -337,12 +382,7 @@ namespace DynamORM.Helpers { if (attr == null || string.IsNullOrEmpty(attr.ColumnName)) return 0; - - int ordinal = reader.GetOrdinal(attr.ColumnName); - if (ordinal < 0) - throw new IndexOutOfRangeException(attr.ColumnName); - - return ordinal; + return reader.GetOrdinal(attr.ColumnName); } } } diff --git a/DynamORM/Mapper/ProcedureResultAttribute.cs b/DynamORM/Mapper/ProcedureResultAttribute.cs index a99ec3f..a060834 100644 --- a/DynamORM/Mapper/ProcedureResultAttribute.cs +++ b/DynamORM/Mapper/ProcedureResultAttribute.cs @@ -31,7 +31,7 @@ using System; namespace DynamORM.Mapper { /// Declares mapping of a typed procedure result property to a specific result set. - [AttributeUsage(AttributeTargets.Property)] + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] public class ProcedureResultAttribute : Attribute { /// Initializes a new instance of the class. From 9ce10273f1f4404314f51d2404995992a11a0ca4 Mon Sep 17 00:00:00 2001 From: Grzegorz Russek Date: Fri, 27 Feb 2026 16:21:42 +0100 Subject: [PATCH 06/10] Support main procedure result via ProcedureResultAttribute --- AmalgamationTool/DynamORM.Amalgamation.cs | 100 +++++++++++++++--- .../Helpers/ProcedureParameterModels.cs | 15 +++ .../ProcedureParameterBinderTests.cs | 30 ++++++ .../Helpers/DynamicProcedureResultBinder.cs | 98 ++++++++++++++--- DynamORM/Mapper/ProcedureResultAttribute.cs | 9 ++ 5 files changed, 219 insertions(+), 33 deletions(-) diff --git a/AmalgamationTool/DynamORM.Amalgamation.cs b/AmalgamationTool/DynamORM.Amalgamation.cs index 7784c0c..7d2786a 100644 --- a/AmalgamationTool/DynamORM.Amalgamation.cs +++ b/AmalgamationTool/DynamORM.Amalgamation.cs @@ -14648,13 +14648,17 @@ namespace DynamORM payload[item.Key] = item.Value == DBNull.Value ? null : item.Value; DynamicTypeMap mapper = DynamicMapperCache.GetMapper(resultType); + object instance = existing; + if (mapper != null) - return mapper.Map(payload.ToDynamic(), existing ?? mapper.Creator()); + instance = mapper.Map(payload.ToDynamic(), existing ?? mapper.Creator()); + else if (instance == null) + instance = payload.ToDynamic(); - if (existing != null) - return existing; + if (instance != null && resultType.IsInstanceOfType(instance)) + BindMainResultMembers(instance, mainResult); - return payload.ToDynamic(); + return instance; } private static IList GetResultMemberBindings(Type resultType) { @@ -14680,10 +14684,39 @@ namespace DynamORM 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); @@ -14718,6 +14751,21 @@ namespace DynamORM 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; @@ -14742,7 +14790,6 @@ namespace DynamORM } private static object ReadSimpleValue(Type propertyType, ProcedureResultAttribute attr, IDataReader reader) { - Type targetType = Nullable.GetUnderlyingType(propertyType) ?? propertyType; object value = null; int ordinal = -1; bool haveRow = false; @@ -14759,17 +14806,7 @@ namespace DynamORM if (!haveRow || value == null) return propertyType.GetDefaultValue(); - if (targetType == typeof(Guid)) - { - Guid g; - if (Guid.TryParse(value.ToString(), out g)) - return propertyType == typeof(Guid) ? (object)g : new Guid?(g); - return propertyType.GetDefaultValue(); - } - if (targetType.IsEnum) - return Enum.ToObject(targetType, value); - - return targetType == propertyType ? propertyType.CastObject(value) : targetType.CastObject(value); + return ConvertScalarValue(propertyType, value); } private static object ReadSimpleList(Type propertyType, Type elementType, ProcedureResultAttribute attr, IDataReader reader) { @@ -14879,6 +14916,29 @@ namespace DynamORM 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 @@ -17927,6 +17987,14 @@ namespace DynamORM [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) { diff --git a/DynamORM.Tests/Helpers/ProcedureParameterModels.cs b/DynamORM.Tests/Helpers/ProcedureParameterModels.cs index e86e8e6..504c98f 100644 --- a/DynamORM.Tests/Helpers/ProcedureParameterModels.cs +++ b/DynamORM.Tests/Helpers/ProcedureParameterModels.cs @@ -46,6 +46,21 @@ namespace DynamORM.Tests.Helpers 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")] diff --git a/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs b/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs index 648abe5..df49905 100644 --- a/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs +++ b/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs @@ -143,6 +143,36 @@ namespace DynamORM.Tests.Procedure 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() { diff --git a/DynamORM/Helpers/DynamicProcedureResultBinder.cs b/DynamORM/Helpers/DynamicProcedureResultBinder.cs index 4759935..96e415c 100644 --- a/DynamORM/Helpers/DynamicProcedureResultBinder.cs +++ b/DynamORM/Helpers/DynamicProcedureResultBinder.cs @@ -130,13 +130,17 @@ namespace DynamORM.Helpers payload[item.Key] = item.Value == DBNull.Value ? null : item.Value; DynamicTypeMap mapper = DynamicMapperCache.GetMapper(resultType); + object instance = existing; + if (mapper != null) - return mapper.Map(payload.ToDynamic(), existing ?? mapper.Creator()); + instance = mapper.Map(payload.ToDynamic(), existing ?? mapper.Creator()); + else if (instance == null) + instance = payload.ToDynamic(); - if (existing != null) - return existing; + if (instance != null && resultType.IsInstanceOfType(instance)) + BindMainResultMembers(instance, mainResult); - return payload.ToDynamic(); + return instance; } private static IList GetResultMemberBindings(Type resultType) @@ -163,11 +167,41 @@ namespace DynamORM.Helpers 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); @@ -205,6 +239,22 @@ namespace DynamORM.Helpers 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; @@ -231,7 +281,6 @@ namespace DynamORM.Helpers private static object ReadSimpleValue(Type propertyType, ProcedureResultAttribute attr, IDataReader reader) { - Type targetType = Nullable.GetUnderlyingType(propertyType) ?? propertyType; object value = null; int ordinal = -1; bool haveRow = false; @@ -249,18 +298,7 @@ namespace DynamORM.Helpers if (!haveRow || value == null) return propertyType.GetDefaultValue(); - if (targetType == typeof(Guid)) - { - Guid g; - if (Guid.TryParse(value.ToString(), out g)) - return propertyType == typeof(Guid) ? (object)g : new Guid?(g); - return propertyType.GetDefaultValue(); - } - - if (targetType.IsEnum) - return Enum.ToObject(targetType, value); - - return targetType == propertyType ? propertyType.CastObject(value) : targetType.CastObject(value); + return ConvertScalarValue(propertyType, value); } private static object ReadSimpleList(Type propertyType, Type elementType, ProcedureResultAttribute attr, IDataReader reader) @@ -384,5 +422,31 @@ namespace DynamORM.Helpers 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/ProcedureResultAttribute.cs b/DynamORM/Mapper/ProcedureResultAttribute.cs index a060834..b90a338 100644 --- a/DynamORM/Mapper/ProcedureResultAttribute.cs +++ b/DynamORM/Mapper/ProcedureResultAttribute.cs @@ -34,6 +34,15 @@ namespace DynamORM.Mapper [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) { From 416404f8d17232f116c068eaa3aa4cad8804da9b Mon Sep 17 00:00:00 2001 From: Grzegorz Russek Date: Fri, 27 Feb 2026 16:27:49 +0100 Subject: [PATCH 07/10] Add typed procedure descriptors and Exec invoker --- AmalgamationTool/DynamORM.Amalgamation.cs | 125 ++++++++++++++++-- .../Helpers/ProcedureParameterModels.cs | 14 ++ .../ProcedureParameterBinderTests.cs | 50 +++++++ DynamORM/DynamicProcedureInvoker.cs | 53 +++++--- .../Helpers/DynamicProcedureDescriptor.cs | 100 ++++++++++++++ DynamORM/Mapper/ProcedureAttribute.cs | 46 +++++++ DynamORM/Objects/Procedure.cs | 45 +++++++ 7 files changed, 410 insertions(+), 23 deletions(-) create mode 100644 DynamORM/Helpers/DynamicProcedureDescriptor.cs create mode 100644 DynamORM/Mapper/ProcedureAttribute.cs create mode 100644 DynamORM/Objects/Procedure.cs diff --git a/AmalgamationTool/DynamORM.Amalgamation.cs b/AmalgamationTool/DynamORM.Amalgamation.cs index 7d2786a..afd2ab7 100644 --- a/AmalgamationTool/DynamORM.Amalgamation.cs +++ b/AmalgamationTool/DynamORM.Amalgamation.cs @@ -5361,22 +5361,44 @@ namespace DynamORM // Get generic types IList types = binder.GetGenericTypeArguments() ?? new List(); Type declaredResultType = null; + string procedureName = binder.Name; + string resultName = binder.Name; + Type execArgumentsType = null; + if (binder.Name == "Exec") + { + if (types.Count != 1) + throw new InvalidOperationException("Exec(args) requires exactly one generic procedure descriptor type."); + + DynamicProcedureDescriptor descriptor = DynamicProcedureDescriptor.Resolve(types[0]); + procedureName = descriptor.ProcedureName; + resultName = descriptor.ResultName; + execArgumentsType = descriptor.ArgumentsType; + declaredResultType = descriptor.ResultType; + types = new List(); + + if (args.Length > 1) + throw new InvalidOperationException("Exec(args) accepts at most one arguments contract instance."); + + if (args.Length == 1 && args[0] != null && !execArgumentsType.IsAssignableFrom(args[0].GetType())) + throw new InvalidOperationException(string.Format("Exec<{0}>(args) expects argument of type '{1}', received '{2}'.", descriptor.ProcedureType.FullName, execArgumentsType.FullName, args[0].GetType().FullName)); + } 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); + 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; - declaredResultType = alen == 1 ? DynamicProcedureResultBinder.GetDeclaredResultType(args[0]) : null; + if (declaredResultType == null) + declaredResultType = alen == 1 ? DynamicProcedureResultBinder.GetDeclaredResultType(args[0]) : null; if (alen > 0) { @@ -5561,7 +5583,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()) { @@ -5688,9 +5710,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); @@ -5705,14 +5727,14 @@ namespace DynamORM result = res.ToDynamic(); } else if (declaredResultType != null) - result = DynamicProcedureResultBinder.BindPayload(declaredResultType, binder.Name, mainResult, res.Where(x => x.Key != binder.Name).ToDictionary(x => x.Key, x => x.Value), mainResult != null && declaredResultType.IsInstanceOfType(mainResult) ? mainResult : 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, binder.Name, mainResult, null); + result = DynamicProcedureResultBinder.BindPayload(declaredResultType, resultName, mainResult, null); else result = mainResult; @@ -14440,6 +14462,67 @@ 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 @@ -17935,6 +18018,19 @@ 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 @@ -18456,6 +18552,19 @@ namespace DynamORM _database = null; } } + /// Base class for typed stored procedure descriptors. + /// Procedure arguments contract. + public abstract class Procedure + 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 + where TArgs : IProcedureParameters + { + } /// Marks an object as an explicit stored procedure parameter contract. public interface IProcedureParameters { diff --git a/DynamORM.Tests/Helpers/ProcedureParameterModels.cs b/DynamORM.Tests/Helpers/ProcedureParameterModels.cs index 504c98f..093a2be 100644 --- a/DynamORM.Tests/Helpers/ProcedureParameterModels.cs +++ b/DynamORM.Tests/Helpers/ProcedureParameterModels.cs @@ -139,4 +139,18 @@ namespace DynamORM.Tests.Helpers [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 index df49905..a98f2d7 100644 --- a/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs +++ b/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs @@ -5,6 +5,7 @@ */ using System.Data; +using System.Dynamic; using DynamORM.Helpers; using DynamORM.Tests.Helpers; using NUnit.Framework; @@ -122,6 +123,55 @@ namespace DynamORM.Tests.Procedure 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 TestExecRejectsWrongArgumentsType() + { + dynamic procedures = new DynamicProcedureInvoker(null); + + Assert.Throws(() => + { + var ignored = procedures.Exec(new ProcedureParameterColumnFallbackObject()); + }); + } + + [Test] + public void TestExecRejectsMultipleArguments() + { + dynamic procedures = new DynamicProcedureInvoker(null); + + Assert.Throws(() => + { + var ignored = procedures.Exec(new ProcedureParameterObject(), new ProcedureParameterObject()); + }); + } + [Test] public void TestDeclaredResultPayloadBindingMapsMainAndOutValues() { diff --git a/DynamORM/DynamicProcedureInvoker.cs b/DynamORM/DynamicProcedureInvoker.cs index 329ca72..068115d 100644 --- a/DynamORM/DynamicProcedureInvoker.cs +++ b/DynamORM/DynamicProcedureInvoker.cs @@ -86,30 +86,53 @@ 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 + 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(); Type declaredResultType = null; + string procedureName = binder.Name; + string resultName = binder.Name; + Type execArgumentsType = null; + + if (binder.Name == "Exec") + { + if (types.Count != 1) + throw new InvalidOperationException("Exec(args) requires exactly one generic procedure descriptor type."); + + DynamicProcedureDescriptor descriptor = DynamicProcedureDescriptor.Resolve(types[0]); + procedureName = descriptor.ProcedureName; + resultName = descriptor.ResultName; + execArgumentsType = descriptor.ArgumentsType; + declaredResultType = descriptor.ResultType; + types = new List(); + + if (args.Length > 1) + throw new InvalidOperationException("Exec(args) accepts at most one arguments contract instance."); + + if (args.Length == 1 && args[0] != null && !execArgumentsType.IsAssignableFrom(args[0].GetType())) + throw new InvalidOperationException(string.Format("Exec<{0}>(args) expects argument of type '{1}', received '{2}'.", descriptor.ProcedureType.FullName, execArgumentsType.FullName, args[0].GetType().FullName)); + } 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)); + 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; - declaredResultType = alen == 1 ? DynamicProcedureResultBinder.GetDeclaredResultType(args[0]) : null; + if (declaredResultType == null) + declaredResultType = alen == 1 ? DynamicProcedureResultBinder.GetDeclaredResultType(args[0]) : null; if (alen > 0) { @@ -301,7 +324,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()) { @@ -433,10 +456,10 @@ namespace DynamORM 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); @@ -451,14 +474,14 @@ namespace DynamORM result = res.ToDynamic(); } else if (declaredResultType != null) - result = DynamicProcedureResultBinder.BindPayload(declaredResultType, binder.Name, mainResult, res.Where(x => x.Key != binder.Name).ToDictionary(x => x.Key, x => x.Value), mainResult != null && declaredResultType.IsInstanceOfType(mainResult) ? mainResult : 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, binder.Name, mainResult, null); + result = DynamicProcedureResultBinder.BindPayload(declaredResultType, resultName, mainResult, null); else result = mainResult; 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/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/Objects/Procedure.cs b/DynamORM/Objects/Procedure.cs new file mode 100644 index 0000000..8182de3 --- /dev/null +++ b/DynamORM/Objects/Procedure.cs @@ -0,0 +1,45 @@ +/* + * 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 +{ + /// Base class for typed stored procedure descriptors. + /// Procedure arguments contract. + public abstract class Procedure + 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 + where TArgs : IProcedureParameters + { + } +} From f8353e74888cac5ca430ccbb78586ab421735c9a Mon Sep 17 00:00:00 2001 From: Grzegorz Russek Date: Fri, 27 Feb 2026 16:34:16 +0100 Subject: [PATCH 08/10] Expose typed procedure execution as explicit APIs --- AmalgamationTool/DynamORM.Amalgamation.cs | 67 ++++++++++++----- .../ProcedureParameterBinderTests.cs | 10 +-- DynamORM/DynamicDatabase.cs | 38 +++++++--- DynamORM/DynamicProcedureInvoker.cs | 73 +++++++++++-------- 4 files changed, 122 insertions(+), 66 deletions(-) diff --git a/AmalgamationTool/DynamORM.Amalgamation.cs b/AmalgamationTool/DynamORM.Amalgamation.cs index afd2ab7..a9d936f 100644 --- a/AmalgamationTool/DynamORM.Amalgamation.cs +++ b/AmalgamationTool/DynamORM.Amalgamation.cs @@ -2681,6 +2681,24 @@ 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); + } #endregion Procedure #region Execute @@ -5331,6 +5349,23 @@ 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); + } /// This is where the magic begins. /// Binder to create owner. /// Binder invoke result. @@ -5360,31 +5395,23 @@ namespace DynamORM // Get generic types IList types = binder.GetGenericTypeArguments() ?? new List(); - Type declaredResultType = null; - string procedureName = binder.Name; - string resultName = binder.Name; - Type execArgumentsType = null; - if (binder.Name == "Exec") + 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 (types.Count != 1) - throw new InvalidOperationException("Exec(args) requires exactly one generic procedure descriptor type."); - - DynamicProcedureDescriptor descriptor = DynamicProcedureDescriptor.Resolve(types[0]); - procedureName = descriptor.ProcedureName; - resultName = descriptor.ResultName; - execArgumentsType = descriptor.ArgumentsType; - declaredResultType = descriptor.ResultType; - types = new List(); - if (args.Length > 1) throw new InvalidOperationException("Exec(args) accepts at most one arguments contract instance."); - if (args.Length == 1 && args[0] != null && !execArgumentsType.IsAssignableFrom(args[0].GetType())) - throw new InvalidOperationException(string.Format("Exec<{0}>(args) expects argument of type '{1}', received '{2}'.", descriptor.ProcedureType.FullName, execArgumentsType.FullName, args[0].GetType().FullName)); + 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)); } - Dictionary retParams = null; - using (IDbConnection con = _db.Open()) using (IDbCommand cmd = con.CreateCommand()) { @@ -5740,7 +5767,7 @@ namespace DynamORM #endregion Handle out params } - return true; + return result; } /// Performs application-defined tasks associated with /// freeing, releasing, or resetting unmanaged resources. diff --git a/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs b/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs index a98f2d7..6036f19 100644 --- a/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs +++ b/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs @@ -151,9 +151,9 @@ namespace DynamORM.Tests.Procedure } [Test] - public void TestExecRejectsWrongArgumentsType() + public void TestExecMethodRejectsWrongArgumentsType() { - dynamic procedures = new DynamicProcedureInvoker(null); + var procedures = new DynamicProcedureInvoker(null); Assert.Throws(() => { @@ -162,13 +162,11 @@ namespace DynamORM.Tests.Procedure } [Test] - public void TestExecRejectsMultipleArguments() + public void TestDynamicDatabaseTypedProcedureRejectsWrongArgumentsType() { - dynamic procedures = new DynamicProcedureInvoker(null); - Assert.Throws(() => { - var ignored = procedures.Exec(new ProcedureParameterObject(), new ProcedureParameterObject()); + var ignored = Database.Procedure(new ProcedureParameterColumnFallbackObject()); }); } diff --git a/DynamORM/DynamicDatabase.cs b/DynamORM/DynamicDatabase.cs index 0735cc5..843b5f9 100644 --- a/DynamORM/DynamicDatabase.cs +++ b/DynamORM/DynamicDatabase.cs @@ -1232,10 +1232,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 +1243,31 @@ 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); + } + + #endregion Procedure #region Execute diff --git a/DynamORM/DynamicProcedureInvoker.cs b/DynamORM/DynamicProcedureInvoker.cs index 068115d..4192bb1 100644 --- a/DynamORM/DynamicProcedureInvoker.cs +++ b/DynamORM/DynamicProcedureInvoker.cs @@ -57,11 +57,29 @@ 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); + } /// This is where the magic begins. /// Binder to create owner. @@ -93,35 +111,28 @@ namespace DynamORM // Get generic types IList types = binder.GetGenericTypeArguments() ?? new List(); - Type declaredResultType = null; - string procedureName = binder.Name; - string resultName = binder.Name; - Type execArgumentsType = null; - if (binder.Name == "Exec") + 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 (types.Count != 1) - throw new InvalidOperationException("Exec(args) requires exactly one generic procedure descriptor type."); - - DynamicProcedureDescriptor descriptor = DynamicProcedureDescriptor.Resolve(types[0]); - procedureName = descriptor.ProcedureName; - resultName = descriptor.ResultName; - execArgumentsType = descriptor.ArgumentsType; - declaredResultType = descriptor.ResultType; - types = new List(); - if (args.Length > 1) throw new InvalidOperationException("Exec(args) accepts at most one arguments contract instance."); - if (args.Length == 1 && args[0] != null && !execArgumentsType.IsAssignableFrom(args[0].GetType())) - throw new InvalidOperationException(string.Format("Exec<{0}>(args) expects argument of type '{1}', received '{2}'.", descriptor.ProcedureType.FullName, execArgumentsType.FullName, args[0].GetType().FullName)); + 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)); } - Dictionary retParams = null; - using (IDbConnection con = _db.Open()) using (IDbCommand cmd = con.CreateCommand()) - { + { if (_prefixes == null || _prefixes.Count == 0) cmd.SetCommand(CommandType.StoredProcedure, procedureName); else @@ -484,12 +495,12 @@ namespace DynamORM result = DynamicProcedureResultBinder.BindPayload(declaredResultType, resultName, mainResult, null); else result = mainResult; - - #endregion Handle out params - } - - return true; - } + + #endregion Handle out params + } + + return result; + } /// Performs application-defined tasks associated with /// freeing, releasing, or resetting unmanaged resources. From 7c084490d8ef2092277c64e2d73c6853684d36eb Mon Sep 17 00:00:00 2001 From: Grzegorz Russek Date: Fri, 27 Feb 2026 16:43:21 +0100 Subject: [PATCH 09/10] Add strongly typed procedure execution helpers --- AmalgamationTool/DynamORM.Amalgamation.cs | 135 +++++++++++++++++- .../ProcedureParameterBinderTests.cs | 33 +++++ DynamORM/DynamicDatabase.cs | 54 ++++++- DynamORM/DynamicProcedureInvoker.cs | 46 ++++++ DynamORM/Objects/Procedure.cs | 14 +- DynamORM/TypedProcedureCall.cs | 73 ++++++++++ 6 files changed, 351 insertions(+), 4 deletions(-) create mode 100644 DynamORM/TypedProcedureCall.cs diff --git a/AmalgamationTool/DynamORM.Amalgamation.cs b/AmalgamationTool/DynamORM.Amalgamation.cs index a9d936f..55e0de4 100644 --- a/AmalgamationTool/DynamORM.Amalgamation.cs +++ b/AmalgamationTool/DynamORM.Amalgamation.cs @@ -2699,6 +2699,51 @@ namespace DynamORM 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 @@ -5366,6 +5411,33 @@ namespace DynamORM 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. @@ -5769,6 +5841,20 @@ namespace DynamORM } 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. public void Dispose() @@ -6899,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. @@ -18579,16 +18702,26 @@ 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 + public abstract class Procedure : Procedure, IProcedureDescriptor where TArgs : IProcedureParameters { } diff --git a/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs b/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs index 6036f19..1b099f6 100644 --- a/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs +++ b/DynamORM.Tests/Procedure/ProcedureParameterBinderTests.cs @@ -170,6 +170,39 @@ namespace DynamORM.Tests.Procedure }); } + [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() { diff --git a/DynamORM/DynamicDatabase.cs b/DynamORM/DynamicDatabase.cs index 843b5f9..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 { @@ -1267,6 +1268,55 @@ namespace DynamORM 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 4192bb1..640253a 100644 --- a/DynamORM/DynamicProcedureInvoker.cs +++ b/DynamORM/DynamicProcedureInvoker.cs @@ -35,6 +35,7 @@ using System.Dynamic; using System.Linq; using DynamORM.Helpers; using DynamORM.Mapper; +using DynamORM.Objects; namespace DynamORM { @@ -80,6 +81,36 @@ namespace DynamORM 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. @@ -501,6 +532,21 @@ namespace DynamORM 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/Objects/Procedure.cs b/DynamORM/Objects/Procedure.cs index 8182de3..99c1025 100644 --- a/DynamORM/Objects/Procedure.cs +++ b/DynamORM/Objects/Procedure.cs @@ -28,9 +28,21 @@ 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 { } @@ -38,7 +50,7 @@ namespace DynamORM.Objects /// Base class for typed stored procedure descriptors with explicit result model. /// Procedure arguments contract. /// Procedure result model. - public abstract class Procedure : Procedure + public abstract class Procedure : Procedure, IProcedureDescriptor where TArgs : IProcedureParameters { } 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); + } + } +} From bb69720f919b47a125c92412bce2dbc372b27222 Mon Sep 17 00:00:00 2001 From: Grzegorz Russek Date: Fri, 27 Feb 2026 16:52:22 +0100 Subject: [PATCH 10/10] Document typed stored procedure APIs --- docs/quick-start.md | 12 ++ docs/stored-procedures.md | 435 +++++++++++++++++++++++++++++--------- 2 files changed, 349 insertions(+), 98 deletions(-) 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