Document typed stored procedure APIs

This commit is contained in:
2026-02-27 16:52:22 +01:00
parent 7c084490d8
commit bb69720f91
2 changed files with 349 additions and 98 deletions

View File

@@ -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<MyProcedure>(new MyProcedureArgs());
var typedResult = db.TypedProcedure<MyProcedure, MyProcedureResult>().Exec(new MyProcedureArgs());
```
Full details are documented in [Stored Procedures](stored-procedures.md).

View File

@@ -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<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
- 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<TMain>(...)`)
### One generic type argument
Main result type resolution:
`db.Procedures.MyProc<TMain>(...)`
- `TMain == IDataReader` => returns cached reader (`DynamicCachedReader`)
- `TMain == DataTable` => returns `DataTable`
- `TMain == List<object>` or `IEnumerable<object>` => list of dynamic rows
- `TMain == List<primitive/string>` => converted first-column list
- `TMain == List<complex>` => 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<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` 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<TMain, TOutModel>(...)`)
### Two generic type arguments
This is the preferred pattern when out params are involved.
`db.Procedures.MyProc<TMain, TOutModel>(...)`
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<int, MessageSetStateResult>(
@@ -176,89 +190,314 @@ var res = db.Procedures.sp_Message_SetState<int, MessageSetStateResult>(
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<SetMessageStatusResult>
{
[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<int>();
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<TResult>`
## 3. Typed Procedure Descriptors
You can now describe a procedure with a class-level descriptor.
```csharp
var res = db.Procedures.sp_Message_SetState<int, MessageSetStateResult>(
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<SetMessageStatusArgs>
{
}
```
## Pattern 3: Procedure returning dataset as table
This enables:
```csharp
DataTable dt = db.Procedures.sp_Message_GetBatch<DataTable>(batchId: 10);
var res1 = db.Procedures.Exec<SetMessageStatusProcedure>(args);
var res2 = db.Procedure<SetMessageStatusProcedure>(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<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:
```csharp
List<MessageRow> rows = db.Procedures.sp_Message_List<List<MessageRow>>(status: 1);
[Procedure(Name = "sp_set_message_status")]
public class SetMessageStatusProcedure : Procedure<SetMessageStatusArgs, SetMessageStatusResult>
{
}
```
## 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<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.
```csharp
var res1 = db.Procedures.Typed<SetMessageStatusProcedure, SetMessageStatusResult>().Exec(args);
var res2 = db.TypedProcedure<SetMessageStatusProcedure, SetMessageStatusResult>().Exec(args);
```
There is also a non-result variant:
```csharp
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.
```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<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`.
```csharp
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.
## 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<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
- 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