From bb69720f919b47a125c92412bce2dbc372b27222 Mon Sep 17 00:00:00 2001 From: Grzegorz Russek Date: Fri, 27 Feb 2026 16:52:22 +0100 Subject: [PATCH] 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