Files
DynamORM/docs/stored-procedures.md

15 KiB

Stored Procedures

Stored procedure support is available when DynamicDatabaseOptions.SupportStoredProcedures is enabled.

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<TProcedure>(args) or db.Procedure<TProcedure>(args)
  4. strongly typed direct calls through db.Procedures.Exec<TProcedure, TResult>(args) or db.Procedure<TProcedure, TResult>(args)
  5. typed handle calls through db.Procedures.Typed<TProcedure, TResult>().Exec(args) or db.TypedProcedure<TProcedure, TResult>().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

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

Why this exists:

  • SQL Server commonly supports procedure return values in this style
  • 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 output parameters for status/result codes.

1. Old Dynamic Invocation

This is the original API:

var scalar = db.Procedures.sp_Exp_Scalar();
var scalarTyped = db.Procedures.sp_Exp_Scalar<int>();

Schema-qualified naming is built through dynamic chaining:

var res = db.Procedures.dbo.reporting.MyProc();

Final command name is dbo.reporting.MyProc.

Parameter Direction Prefixes

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
  • both_ => ParameterDirection.InputOutput

Example:

dynamic res = db.Procedures.sp_Message_SetState(
    id: "abc-001",
    both_state: "pending",
    out_message: "",
    ret_code: 0);

Prefix is removed from the exposed output key, so out_message becomes message in the returned payload.

How to Specify Type, Length, Precision, or Scale for Output Parameters

This is the most common issue with the old dynamic API.

Option A: DynamicSchemaColumn

Use this for output-only parameters when schema must be explicit.

dynamic res = db.Procedures.sp_Message_SetState(
    id: "abc-001",
    out_message: new DynamicSchemaColumn
    {
        Name = "message",
        Type = DbType.String,
        Size = 1024,
    },
    ret_code: 0);

Option B: DynamicColumn

Use this when the parameter is input/output and you need both value and schema.

dynamic res = db.Procedures.sp_Message_SetState(
    both_state: new DynamicColumn
    {
        ColumnName = "state",
        ParameterDirection = ParameterDirection.InputOutput,
        Value = "pending",
        Schema = new DynamicSchemaColumn
        {
            Name = "state",
            Type = DbType.String,
            Size = 32,
        }
    });

Option C: plain value with prefixed argument name

Fastest form, but type/size inference is automatic.

dynamic res = db.Procedures.sp_Message_SetState(
    id: "abc-001",
    out_message: "",
    ret_code: 0);

Use DynamicSchemaColumn or DynamicColumn whenever output schema must be controlled.

Result Shape Matrix for Old Dynamic Invocation

No generic type arguments

db.Procedures.MyProc(...)

Main result path:

  • procedure executes via ExecuteNonQuery()
  • 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/both params were requested: scalar main result
  • if any out/ret/both params were requested: dynamic object with keys

One generic type argument

db.Procedures.MyProc<TMain>(...)

Main result resolution:

  • TMain == IDataReader => cached reader (DynamicCachedReader)
  • TMain == DataTable => DataTable
  • TMain == IEnumerable<object> / List<object> => list of dynamic rows
  • TMain == IEnumerable<primitive> / List<primitive> => first-column converted list
  • TMain == IEnumerable<complex> / List<complex> => 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
  • if out/ret params exist: dynamic payload containing main result plus out params

Important nuance:

  • with out params and only one generic type, the final result is payload-shaped, not raw TMain

Two generic type arguments

db.Procedures.MyProc<TMain, TOutModel>(...)

This is the original preferred pattern when out params are involved.

  • TMain is resolved as above
  • 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

Out payload key names

When out payload is used:

  • main result is stored under procedure method name key
  • each out/both/ret param is stored under normalized parameter name

Example:

var res = db.Procedures.sp_Message_SetState<int, MessageSetStateResult>(
    id: "abc-001",
    out_message: new DynamicSchemaColumn { Name = "message", Type = DbType.String, Size = 1024 },
    ret_code: 0);

Expected payload keys before mapping:

  • sp_Message_SetState
  • message
  • code

2. Parameter Contract Object Invocation

This is the new object-contract path.

Instead of encoding output metadata into prefixed dynamic arguments, you pass a single object implementing IProcedureParameters.

public class SetMessageStatusArgs : IProcedureParameters<SetMessageStatusResult>
{
    [ProcedureParameter("status", Direction = ParameterDirection.ReturnValue, Order = 1)]
    public int Status { get; set; }

    [ProcedureParameter("id", Order = 2, DbType = DbType.String, Size = 64)]
    public string Id { 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; }
}

Then:

var res = db.Procedures.sp_SetMessageStatus(new SetMessageStatusArgs
{
    Id = "A-100",
    Description = "seed"
});

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<TResult>

3. Typed Procedure Descriptors

You can now describe a procedure with a class-level descriptor.

[Procedure(Name = "sp_set_message_status", Owner = "dbo")]
public class SetMessageStatusProcedure : Procedure<SetMessageStatusArgs>
{
}

This enables:

var res1 = db.Procedures.Exec<SetMessageStatusProcedure>(args);
var res2 = db.Procedure<SetMessageStatusProcedure>(args);

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<TArgs> or Procedure<TArgs, TResult>
  • 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:

[Procedure(Name = "sp_set_message_status")]
public class SetMessageStatusProcedure : Procedure<SetMessageStatusArgs, SetMessageStatusResult>
{
}

Calls:

var res1 = db.Procedures.Exec<SetMessageStatusProcedure, SetMessageStatusResult>(args);
var res2 = db.Procedure<SetMessageStatusProcedure, SetMessageStatusResult>(args);

Why this needs two generic arguments:

  • C# cannot infer a method return type from TProcedure : IProcedureDescriptor<TResult> 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.

var res1 = db.Procedures.Typed<SetMessageStatusProcedure, SetMessageStatusResult>().Exec(args);
var res2 = db.TypedProcedure<SetMessageStatusProcedure, SetMessageStatusResult>().Exec(args);

There is also a non-result variant:

var res = db.TypedProcedure<SetMessageStatusProcedure>().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.

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.

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:

[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.

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<string> Codes { get; set; }

    [ProcedureResult(2, ColumnName = "State")]
    public int[] States { get; set; }

    [ProcedureResult(3)]
    public UserRow User { get; set; }

    [ProcedureResult(4)]
    public List<UserRow> 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.

public class BatchResult : IProcedureResultReader
{
    public List<string> Codes { get; } = new List<string>();
    public List<int> States { get; } = new List<int>();

    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<TArgs, TResult>
  2. declared result from IProcedureParameters<TResult> 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.

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<TProcedure, TResult>(...) or Procedure<TProcedure, TResult>(...)

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

  • 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