diff --git a/README.md b/README.md new file mode 100644 index 0000000..7463a88 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# DynamORM + +Dynamic ORM and SQL builder for .NET, with dynamic and typed APIs. + +## Documentation + +Full documentation is available in [`docs/README.md`](docs/README.md): + +- Quick start +- Dynamic table API +- Fluent builder API +- Mapping and entity lifecycle +- Transaction/disposal semantics +- Stored procedures +- ADO.NET extensions and cached reader +- .NET 4.0 amalgamation workflow +- Test-driven examples + +## Repository Layout + +- `DynamORM/`: core library +- `DynamORM.Tests/`: test suite +- `AmalgamationTool/`: amalgamation generator and generated single-file output +- `DynamORM.Net40.csproj`: net40 build for amalgamated source compatibility diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..025deea --- /dev/null +++ b/docs/README.md @@ -0,0 +1,38 @@ +# DynamORM Documentation + +This documentation is based on: +- XML comments in the library source (`DynamORM/*.cs`, `DynamORM/*/*.cs`). +- Executable usage patterns from the test suite (`DynamORM.Tests`). + +## Contents + +- [Quick Start](quick-start.md) +- [Dynamic Table API](dynamic-table-api.md) +- [Fluent Builder API](fluent-builder-api.md) +- [Mapping and Entities](mapping-and-entities.md) +- [Transactions and Disposal](transactions-and-disposal.md) +- [Stored Procedures](stored-procedures.md) +- [ADO.NET Extensions](ado-net-extensions.md) +- [.NET 4.0 Amalgamation](net40-amalgamation.md) +- [Test-Driven Examples](test-driven-examples.md) + +## Supported Targets + +Main library targets are defined in `DynamORM/DynamORM.csproj`. + +For legacy .NET 4.0 consumers, use the amalgamated source workflow documented in [.NET 4.0 Amalgamation](net40-amalgamation.md). + +## Design Overview + +DynamORM provides two major usage styles: + +- Dynamic table access: + - `db.Table("users").Count(...)` + - `db.Table("users").Insert(...)` + - `db.Table("users").Query(...)` +- Fluent SQL builder access: + - `db.From()` + - `db.Select()` + - `db.Insert() / db.Update() / db.Delete()` + +The dynamic API is concise and fast to use. The fluent builder API gives stronger composition and explicit SQL control. diff --git a/docs/ado-net-extensions.md b/docs/ado-net-extensions.md new file mode 100644 index 0000000..f3686cc --- /dev/null +++ b/docs/ado-net-extensions.md @@ -0,0 +1,329 @@ +# ADO.NET Extensions Reference + +DynamORM exposes extension helpers mainly in: + +- `DynamORM/DynamicExtensions.cs` +- `DynamORM/Helpers/DataReaderExtensions.cs` +- `DynamORM/Helpers/ReaderExtensions.cs` + +## `IDbCommand` Extensions + +## Connection and Transaction + +- `SetConnection(this IDbCommand, IDbConnection)` +- `SetTransaction(this IDbCommand, IDbTransaction)` + +These enable fluent command setup. + +## `SetCommand` Overloads + +Core overloads: + +- `SetCommand(CommandType, int timeout, string text, params object[] args)` +- `SetCommand(int timeout, string text, params object[] args)` +- `SetCommand(CommandType, string text, params object[] args)` +- `SetCommand(string text, params object[] args)` +- `SetCommand(IDynamicQueryBuilder builder)` + +Examples: + +```csharp +cmd.SetCommand("SELECT * FROM users WHERE id = {0}", 19); +cmd.SetCommand(CommandType.StoredProcedure, "sp_DoWork"); +cmd.SetCommand(builder); +``` + +## Parameter Helpers + +Bulk: + +- `AddParameters(DynamicDatabase, params object[] args)` +- `AddParameters(DynamicDatabase, ExpandoObject)` +- `AddParameters(DynamicDatabase, DynamicExpando)` + +Single parameter helpers: + +- `AddParameter(DynamicDatabase, object item)` +- `AddParameter(DynamicDatabase, string name, object item)` +- `AddParameter(IDynamicQueryBuilder, DynamicSchemaColumn? col, object value)` +- `AddParameter(IDynamicQueryBuilder, DynamicColumn item)` + +Advanced overloads support explicit `ParameterDirection`, `DbType`, `size`, `precision`, `scale`. + +Compatibility note on overload resolution: + +- Best practice is to cast value arguments to `object` when calling `AddParameter(...)` with value payloads. +- Alternatively, use named arguments to force the intended overload. +- This avoids accidental binding to a different overload in ambiguous cases. +- Current overload shape is preserved for backward compatibility. + +Example: + +```csharp +cmd.AddParameter("@Result", ParameterDirection.Output, DbType.String, 256, 0, 0, DBNull.Value); +cmd.AddParameter("@Name", DbType.String, 50, "Alice"); +``` + +## Updating Existing Parameters + +- `SetParameter(this IDbCommand, string parameterName, object value)` +- `SetParameter(this IDbCommand, int index, object value)` + +## Execution and Conversion + +- `ExecuteScalarAs()` +- `ExecuteScalarAs(defaultValue)` +- `ExecuteScalarAs(TryParseHandler)` +- `ExecuteScalarAs(defaultValue, TryParseHandler)` +- `ExecuteEnumeratorOf(defaultValue, TryParseHandler)` + +These convert ADO.NET results to requested types with fallback behavior. + +## Command Debugging + +- `DumpToString()` +- `Dump(StringBuilder)` +- `Dump(TextWriter)` + +Useful with `DynamicDatabase.DumpCommands` and custom log sinks. + +## `IDataReader` and Row Helpers + +From `DynamicExtensions.cs`: + +- `ToList(this IDataReader)` +- `RowToDynamic(this IDataReader)` +- `RowToExpando(this IDataReader)` +- `RowToDynamic(this DataRow)` +- `RowToExpando(this DataRow)` +- upper-case variants (`RowToDynamicUpper`, `RowToExpandoUpper`) +- `GetFieldDbType(this IDataReader, int i)` + +## Reader Caching + +- `CachedReader(this IDataReader, int offset = 0, int limit = -1, Func progress = null)` + +This creates an in-memory `DynamicCachedReader` snapshot. + +## `DataReaderExtensions` + +- `ToDataTable(this IDataReader, string name = null, string nameSpace = null)` + +Converts current reader rows/schema to a `DataTable`. + +## `ReaderExtensions` Null-Safe Accessors + +Typed nullable access by column name: + +- `GetBooleanIfNotNull` +- `GetByteIfNotNull` +- `GetCharIfNotNull` +- `GetDateTimeIfNotNull` +- `GetDecimalIfNotNull` +- `GetDoubleIfNotNull` +- `GetFloatIfNotNull` +- `GetGuidIfNotNull` +- `GetInt16IfNotNull` +- `GetInt32IfNotNull` +- `GetInt64IfNotNull` +- `GetStringIfNotNull` +- `GetValueIfNotNull` + +Each method accepts an optional default value and returns it when DB value is null. + +## `DynamicCachedReader` + +`DynamicCachedReader` implements `IDataReader` and stores: + +- schema metadata +- field names/types/ordinals +- full row cache (supports multi-result sets) + +Construction options: + +- `new DynamicCachedReader(IDataReader, offset, limit, progress)` +- `DynamicCachedReader.FromDynamicEnumerable(...)` +- `DynamicCachedReader.FromEnumerable(...)` +- `DynamicCachedReader.FromEnumerable(Type, IEnumerable)` + +Typical usage: + +```csharp +using (var rdr = cmd.ExecuteReader()) +using (var cached = rdr.CachedReader()) +{ + while (cached.Read()) + { + var row = cached.RowToDynamic(); + } +} +``` + +When to use it: + +- You need disconnected reader semantics. +- You need multiple passes or deferred mapping. +- You need stable materialization before connection disposal. + +Tradeoff: + +- Higher memory use proportional to result size. + +## End-to-End Samples (Genericized) + +These samples mirror real production patterns while using generic names. + +## Sample 1: Stored Procedure with Multiple Result Sets + Null-Safe Reader Accessors + +```csharp +var records = new Dictionary(); + +using var con = Database.Open(); +using var cmd = con.CreateCommand() + .SetCommand(CommandType.StoredProcedure, "usp_get_messages"); + +using var r = cmd.ExecuteReader(); + +while (r.Read()) +{ + var msg = new GenericMessage + { + Id = r.GetStringIfNotNull("id"), + Type = r.GetStringIfNotNull("type"), + Value = r.GetStringIfNotNull("value"), + State = (GenericMessageState)(r.GetInt32IfNotNull("state") ?? 0), + Date = r.GetDateTimeIfNotNull("date") ?? new DateTime(2099, 12, 31), + }; + + records.Add(msg.Id, msg); +} + +if (r.NextResult()) +{ + while (r.Read()) + { + var messageId = r.GetStringIfNotNull("message_id"); + if (!records.ContainsKey(messageId)) + continue; + + records[messageId].Items.Add(new GenericMessageItem + { + Id = r.GetStringIfNotNull("id"), + Type = r.GetStringIfNotNull("type"), + Value = r.GetStringIfNotNull("value"), + }); + } +} +``` + +Extension methods used: + +- `SetCommand(CommandType, string)` +- `GetStringIfNotNull` +- `GetInt32IfNotNull` +- `GetDateTimeIfNotNull` + +## Sample 2: Stored Procedure with Input/Output Parameters + Result Check + +```csharp +using var con = Database.Open(); +using var cmd = con.CreateCommand() + .SetCommand(CommandType.StoredProcedure, "usp_set_message_status") + .AddParameter(Database.GetParameterName("id"), DbType.String, (object)messageId) + .AddParameter(Database.GetParameterName("status"), DbType.Int32, (object)(int)status) + .AddParameter(Database.GetParameterName("note"), DbType.String, (object)note) + .AddParameter(Database.GetParameterName("result"), ParameterDirection.Output, DbType.Int32, 0, null) + .AddParameter(Database.GetParameterName("result_description"), ParameterDirection.Output, DbType.String, 1024, null); + +cmd.ExecuteNonQuery(); + +var result = cmd.Parameters[Database.GetParameterName("result")] as IDataParameter; +var resultDescription = cmd.Parameters[Database.GetParameterName("result_description")] as IDataParameter; + +if (result != null && result.Value != DBNull.Value && result.Value != null) +{ + if ((int)result.Value != 0) + { + var description = (resultDescription != null && + resultDescription.Value != DBNull.Value && + resultDescription.Value != null) + ? resultDescription.Value.ToString() + : "UNSPECIFIED"; + + throw new Exception($"{result.Value} - {description}"); + } +} +``` + +Extension methods used: + +- `SetCommand(CommandType, string)` +- `AddParameter(...)` overloads with `DbType` and `ParameterDirection` +- `DumpToString()` (optional diagnostic logging) + +## Sample 3: Parameterized `UPDATE` with Runtime SQL and ADO.NET Extensions + +```csharp +using var conn = Database.Open(); + +using var cmdUpdate = conn.CreateCommand() + .SetCommand($@"UPDATE {Database.DecorateName("transport_task")} +SET {Database.DecorateName("confirmed")} = {Database.GetParameterName("confirmed")} +WHERE {Database.DecorateName("id")} = {Database.GetParameterName("id")}") + .AddParameter(Database.GetParameterName("confirmed"), DbType.Int32, (object)1) + .AddParameter(Database.GetParameterName("id"), DbType.Guid, (object)taskId); + +if (cmdUpdate.ExecuteNonQuery() > 0) +{ + // updated +} +``` + +Extension methods used: + +- `SetCommand(string, params object[])` +- `AddParameter(string, DbType, object)` + +## Sample 4: Parameterized `SELECT` + Reader Mapping Method + +```csharp +using var conn = Database.Open(); + +using var cmd = conn.CreateCommand() + .SetCommand($@"SELECT * FROM {Database.DecorateName("transport_task")} +WHERE {Database.DecorateName("id")} = {Database.GetParameterName("id")}") + .AddParameter(Database.GetParameterName("id"), DbType.Guid, (object)id); + +using var r = cmd.ExecuteReader(); +if (r.Read()) + return ReadTask(r); + +return null; + +protected virtual MoveTask ReadTask(IDataReader r) +{ + var source = r.GetStringIfNotNull("Source"); + var destination = r.GetStringIfNotNull("Destination"); + + return new MoveTask + { + Id = r.GetGuid(r.GetOrdinal("Id")), + Created = r.GetDateTime(r.GetOrdinal("Created")), + StartDate = r.GetDateTimeIfNotNull("StartDate"), + EndDate = r.GetDateTimeIfNotNull("EndDate"), + ExternalId = r.GetStringIfNotNull("ExternalId"), + TaskType = (TransportTaskType)(r.GetInt32IfNotNull("Type") ?? 0), + Source = string.IsNullOrEmpty(source) ? null : Deserialize
(source), + Destination = string.IsNullOrEmpty(destination) ? null : Deserialize
(destination), + Priority = r.GetInt32IfNotNull("Priority") ?? 0, + }; +} +``` + +Extension methods used: + +- `SetCommand(...)` +- `AddParameter(...)` +- `GetStringIfNotNull` +- `GetDateTimeIfNotNull` +- `GetInt32IfNotNull` diff --git a/docs/dynamic-table-api.md b/docs/dynamic-table-api.md new file mode 100644 index 0000000..21db32b --- /dev/null +++ b/docs/dynamic-table-api.md @@ -0,0 +1,118 @@ +# Dynamic Table API + +The dynamic table API centers on `DynamicTable` and allows concise runtime calls. + +```csharp +dynamic users = db.Table("users"); +``` + +This API is best when table/column selection is dynamic or you want very short CRUD code. + +## Read Operations + +Examples backed by `DynamORM.Tests/Select/DynamicAccessTests.cs`: + +```csharp +users.Count(columns: "id"); +users.First(columns: "id"); +users.Last(columns: "id"); +users.Min(columns: "id"); +users.Max(columns: "id"); +users.Avg(columns: "id"); +users.Sum(columns: "id"); +users.Scalar(columns: "first", id: 19); +users.Single(id: 19); +users.Query(columns: "first,last", order: "id:desc"); +``` + +## Filtering with Named Arguments + +```csharp +users.Count(first: "Ori"); +users.Single(code: "101"); +users.Query(columns: "id,first", id: 19); +``` + +## Conditions with `DynamicColumn` + +```csharp +users.Count(where: new DynamicColumn("id").Greater(100)); +users.Count(where: new DynamicColumn("login").Like("Hoyt.%")); +users.Count(where: new DynamicColumn("id").Between(75, 100)); +users.Count(where: new DynamicColumn("id").In(75, 99, 100)); +users.Count(where: new DynamicColumn("id").In(new[] { 75, 99, 100 })); +``` + +Using aggregate expressions in a condition: + +```csharp +users.Count(condition1: new DynamicColumn +{ + ColumnName = "email", + Aggregate = "length", + Operator = DynamicColumn.CompareOperator.Gt, + Value = 27 +}); +``` + +## Insert + +```csharp +users.Insert(code: "201", first: "Juri", last: "Gagarin", email: "juri.gagarin@megacorp.com"); + +users.Insert(values: new +{ + code = "202", + first = "Juri", + last = "Gagarin", + email = "juri.gagarin@megacorp.com" +}); +``` + +## Update + +```csharp +users.Update(id: 1, first: "Yuri", last: "Gagarin"); + +users.Update( + values: new { first = "Yuri" }, + where: new { id = 1 }); +``` + +## Delete + +```csharp +users.Delete(code: "201"); +users.Delete(where: new { id = 14, code = 14 }); +``` + +## Typed Dynamic Table Calls + +`DynamicTable` methods also accept `type: typeof(T)` for mapped class scenarios: + +```csharp +users.Count(type: typeof(User), columns: "id"); +users.Query(type: typeof(User)); + +var list = (users.Query(type: typeof(User)) as IEnumerable) + .Cast() + .ToList(); +``` + +These usage patterns are covered in `DynamORM.Tests/Select/TypedAccessTests.cs`. + +## When to Prefer Fluent Builder Instead + +Use fluent builders when: + +- Query structure is complex (joins/subqueries/having). +- You need deterministic SQL text assertions. +- You prefer strongly typed lambda parser expressions. + +See [Fluent Builder API](fluent-builder-api.md). + +## Notes + +- Dynamic member names map to table/column names and builder conventions. +- Unknown dynamic operations throw `InvalidOperationException`. +- Explicit schema behavior depends on `DynamicDatabaseOptions.SupportSchema`. diff --git a/docs/fluent-builder-api.md b/docs/fluent-builder-api.md new file mode 100644 index 0000000..55449eb --- /dev/null +++ b/docs/fluent-builder-api.md @@ -0,0 +1,263 @@ +# Fluent Builder API + +The fluent API is built around: + +- `IDynamicSelectQueryBuilder` +- `IDynamicInsertQueryBuilder` +- `IDynamicUpdateQueryBuilder` +- `IDynamicDeleteQueryBuilder` + +This page documents call semantics and parser nuances from: + +- `DynamORM/Builders/Implementation/DynamicSelectQueryBuilder.cs` +- `DynamORM/Builders/Implementation/DynamicQueryBuilder.cs` +- `DynamORM/Builders/Extensions/DynamicWhereQueryExtensions.cs` +- `DynamORM/Builders/Extensions/DynamicHavingQueryExtensions.cs` +- parser tests in `DynamORM.Tests/Select/ParserTests.cs` and `LegacyParserTests.cs` + +## Builder Lifecycle and Clause Output Order + +Builder methods can be chained in many orders, but generated SQL is emitted in canonical order: + +1. `SELECT` +2. `FROM` +3. `JOIN` +4. `WHERE` +5. `GROUP BY` +6. `HAVING` +7. `ORDER BY` +8. paging (`TOP`/`FIRST SKIP` or `LIMIT OFFSET` depending on options) + +Recommendation: start with `From(...)` first so alias registration is unambiguous for later clauses. + +## Fast Baseline + +```csharp +using (var query = db.From("users", "u") + .Where("u.id", 19) + .SelectColumn("u.first", "u.last")) +{ + var rows = query.Execute().ToList(); +} +``` + +## `From(...)` Deep Dive + +Supported source forms: + +- string source: + - `From(x => "dbo.Users")` + - `From(x => "dbo.Users AS u")` +- member source: + - `From(x => x.dbo.Users)` + - `From(x => x.dbo.Users.As(x.u))` +- typed source: + - `From(x => x(typeof(User)).As(x.u))` + - `db.From("u")` +- subquery source: + - `From(x => x(subQuery).As(x.u))` + +Aliasing nuance: + +- `As(...)` is optional for simple table/member source. +- For generic invoke source (`x => x(expression)`), aliasing is effectively required when the source should be referenced later. + +`NoLock()` nuance: + +- `NoLock()` is parsed in `From`/`Join`, but SQL `WITH(NOLOCK)` is emitted only if database option `SupportNoLock` is enabled. + +Examples from tests: + +```csharp +cmd.From(x => x.dbo.Users.As(x.u)); +cmd.From(x => x("\"dbo\".\"Users\"").As(x.u)); +cmd.From(x => x(cmd.SubQuery().From(y => y.dbo.Users)).As(x.u)); +cmd.From(x => x.dbo.Users.As("u").NoLock()); +``` + +## `Join(...)` Deep Dive + +`Join(...)` supports table/member/string/invoke/subquery forms with `On(...)` conditions. + +### Two-pass join processing + +Implementation runs join parsing in two passes: + +- pass 1: collect tables/aliases +- pass 2: render SQL conditions + +This enables robust alias resolution inside `On(...)` expressions. + +### Join type resolution + +Join type comes from a dynamic method near the root of the join expression. + +Common forms: + +- `Inner()` -> `INNER JOIN` +- `Left()` -> `LEFT JOIN` +- `LeftOuter()` -> `LEFT OUTER JOIN` +- `Right()` -> `RIGHT JOIN` +- `RightOuter()` -> `RIGHT OUTER JOIN` +- no type method -> plain `JOIN` + +Non-obvious option: + +- the join-type method can accept arguments. +- if first argument is `false`, auto-append/split of `JOIN` is disabled. +- if any argument is a string, that string is used as explicit join type text. + +This allows custom forms such as provider-specific joins. + +Example patterns: + +```csharp +cmd.Join(x => x.Inner().dbo.UserClients.As(x.uc).On(x.u.Id == x.uc.UserId)); + +cmd.Join(x => x.Custom(false, "CROSS APPLY") + .dbo.UserClients.As(x.uc) + .On(x.u.Id == x.uc.UserId)); +``` + +### `On(...)` ordering rule + +`On(...)` can appear with other join modifiers in the same lambda chain. Parsed order is normalized by node traversal. + +Recommended readable order: + +```csharp +.Join(x => x.Inner().dbo.UserClients.As(x.uc).NoLock().On(x.u.Id == x.uc.UserId)) +``` + +Validated join variants are heavily covered in parser tests (`TestJoinClassic`, `TestInnerJoin*`, `TestLeftJoin`, `TestRightJoin`, `TestSubQueryJoin`). + +## `Where(...)` and `Having(...)` Nuances + +Both clauses share the same behavior model. + +### Lambda conditions + +```csharp +.Where(x => x.u.UserName == "admin") +.Where(x => x.Or(x.u.IsActive == true)) +``` + +Chaining behavior: + +- each chained call defaults to `AND` +- root wrapper `Or(...)` switches that clause append to `OR` + +### `DynamicColumn` behavior + +`DynamicColumn` supports: + +- comparison operators (`Eq`, `Not`, `Like`, `NotLike`, `In`, `Between`, etc.) +- `SetOr()` to force OR join with previous condition +- `SetBeginBlock()` / `SetEndBlock()` to control parenthesis grouping +- `SetVirtualColumn(...)` to influence null parameterization behavior + +Example (legacy parser tests): + +```csharp +.Where(new DynamicColumn("u.Deleted").Eq(0).SetBeginBlock()) +.Where(new DynamicColumn("u.IsActive").Eq(1).SetOr().SetEndBlock()) +``` + +### Object conditions + `_table` + +```csharp +.Where(new { Deleted = 0, IsActive = 1, _table = "u" }) +``` + +Nuance: + +- `_table` applies alias/table prefix during object-condition expansion. +- when `schema: true`, only key-like mapped columns are included. + +`Having(...)` mirrors these forms and semantics. + +## `Select(...)` Nuances + +Supported forms: + +- simple member: `Select(x => x.u.UserName)` +- aliasing: `Select(x => x.u.UserName.As(x.Name))` +- all columns: `Select(x => x.u.All())` +- aggregate functions: `Select(x => x.Sum(x.u.Score).As(x.Total))` +- anonymous projection: `Select(x => new { Id = x.u.Id, Name = x.u.UserName })` +- raw invoke concatenation: `Select(x => x("CASE WHEN ", x.u.Active == 1, " THEN ", 1, " ELSE ", 0, " END").As(x.Flag))` + +Non-obvious behavior: + +- anonymous projection property names become SQL aliases. +- `All()` cannot be combined with alias emission for that same select item. +- invoke syntax (`x("...", expr, "...")`) is the parser escape hatch for complex SQL expressions. + +## `OrderBy(...)` Nuances + +Default direction is ascending. + +```csharp +.OrderBy(x => x.u.UserName) // ASC +.OrderBy(x => x.Desc(x.u.UserName)) // DESC +.OrderBy(x => "1 DESC") // numeric column position +.OrderBy(x => x.Desc(1)) // numeric column position +``` + +`OrderByColumn(...)` also supports `DynamicColumn` parsing style, including numeric alias position patterns from legacy tests. + +## `GroupBy(...)` + +```csharp +.GroupBy(x => x.u.UserName) +// or +.GroupByColumn("u.Name") +``` + +Group-by supports multiple entries by repeated calls or params arrays. + +## Paging and Distinct + +- `Distinct(true)` toggles `SELECT DISTINCT`. +- `Top(n)` delegates to `Limit(n)`. +- `Limit(n)` and `Offset(n)` validate database capability flags. + +Capability checks: + +- `Limit` requires one of: `SupportLimitOffset`, `SupportFirstSkip`, `SupportTop`. +- `Offset` requires one of: `SupportLimitOffset`, `SupportFirstSkip`. + +Unsupported combinations throw `NotSupportedException`. + +## Subqueries + +Subqueries can be embedded in select/where/join contexts. + +```csharp +cmd.From(x => x.dbo.Users.As(x.u)) + .Select(x => x(cmd.SubQuery() + .From(y => y.dbo.AccessRights.As(y.a)) + .Where(y => y.a.User_Id == y.u.Id) + .Select(y => y.a.IsAdmin)).As(x.IsAdmin)); +``` + +Supported tested placements: + +- `SELECT (subquery) AS alias` +- `WHERE column = (subquery)` +- `WHERE column IN (subquery)` +- `JOIN (subquery) AS alias ON ...` + +## Typed Execution and Scalar + +```csharp +var list = db.From("u").Where(x => x.u.Active == true).Execute().ToList(); +var count = db.From("u").Select(x => x.Count()).ScalarAs(); +``` + +## Practical Patterns + +- Register source aliases early (`From(...)` first). +- Keep join lambdas readable with explicit `Inner/Left/Right` and `On(...)` at the end. +- Use invoke syntax only for cases the parser cannot express directly. +- Use legacy `DynamicColumn` API when you need explicit grouping flags (`SetBeginBlock`, `SetEndBlock`, `SetOr`). diff --git a/docs/mapping-and-entities.md b/docs/mapping-and-entities.md new file mode 100644 index 0000000..1288e1e --- /dev/null +++ b/docs/mapping-and-entities.md @@ -0,0 +1,190 @@ +# Mapping and Entities + +DynamORM mapping is a core feature, not just a convenience layer. + +It controls: + +- class-to-table resolution +- property-to-column resolution +- key semantics for updates/deletes +- schema override details (`DbType`, size, precision, scale) +- lifecycle operations (`Insert`, `Update`, `Delete`, `Refresh`, `Save`) + +## Mapping Resolution Model + +At runtime, `DynamicMapperCache` builds and caches `DynamicTypeMap` for each type. + +`DynamicTypeMap` is used by: + +- typed query execution (`Execute()`) +- dynamic projection to classes +- entity lifecycle operations in `DynamicEntityBase` +- procedure result mapping +- validation (`ValidateObject`) + +## `TableAttribute` + +```csharp +[Table(Name = "users", Owner = "dbo", Override = true)] +public class User +{ + // ... +} +``` + +Fields: + +- `Name`: table name override. +- `Owner`: schema/owner segment. +- `Override`: prefer attribute schema over database schema (important when provider schema is limited). + +## `ColumnAttribute` + +```csharp +public class User +{ + [Column("id", isKey: true)] + public int Id { get; set; } + + [Column("email")] + public string Email { get; set; } + + [Column("created_at", false, DbType.DateTime)] + public DateTime CreatedAt { get; set; } +} +``` + +Important flags: + +- `IsKey` +- `AllowNull` +- `IsNoInsert` +- `IsNoUpdate` +- `Type`, `Size`, `Precision`, `Scale` + +These influence generated parameters and update/insert behavior. + +## Advanced Mapping Example + +```csharp +[Table(Name = "users", Override = true)] +public class UserEntity : DynamicEntityBase +{ + [Column("id", true, DbType.Int32)] + public int Id { get; set; } + + [Column("code", false, DbType.String, 32)] + public string Code { get; set; } + + [Column("email", false, DbType.String, 256)] + public string Email { get; set; } + + [Column("created_at", false, DbType.DateTime)] + [Ignore] + public DateTime CreatedAtInternal { get; set; } +} +``` + +## Ignore and Partial Mapping + +Use `[Ignore]` when: + +- property is computed locally +- property is not persisted +- property should not participate in auto update/insert + +## Validation (`RequiredAttribute`) + +`RequiredAttribute` can enforce range and null/empty semantics. + +```csharp +public class Profile +{ + [Required(1f, 10f)] + public int Rank { get; set; } + + [Required(2, true)] + [Required(7, 18, ElementRequirement = true)] + public decimal[] Scores { get; set; } +} + +var issues = DynamicMapperCache.GetMapper().ValidateObject(profile); +``` + +Validation scenarios are verified in `DynamORM.Tests/Helpers/Validation/ObjectValidationTest.cs`. + +## `DynamicEntityBase` Lifecycle + +`DynamicEntityBase` tracks state and changed fields. + +State-driven `Save(db)` behavior: + +- `New` => `Insert` +- `Existing` + modified => `Update` +- `ToBeDeleted` => `Delete` +- `Deleted` => throws for write operations +- `Unknown` => throws (explicit state needed) + +Example entity with property change tracking: + +```csharp +public class UserEntity : DynamicEntityBase +{ + private string _first; + + [Column("id", true)] + public int Id { get; set; } + + [Column("first")] + public string First + { + get => _first; + set + { + OnPropertyChanging(nameof(First), _first, value); + _first = value; + } + } +} +``` + +Typical flow: + +```csharp +var user = db.From() + .Where(x => x.id == 19) + .Execute() + .First(); + +user.SetDynamicEntityState(DynamicEntityState.Existing); +user.First = "Yuri"; +user.Save(db); // updates only changed fields when possible +``` + +## Refresh and Key Requirements + +`Refresh(db)`, `Update(db)`, and `Delete(db)` rely on key columns. + +If no key mapping is available, entity update/delete operations throw. + +Always map at least one key (`IsKey = true`) for mutable entities. + +## Repository Layer (`DynamicRepositoryBase`) + +`DynamicRepositoryBase` provides: + +- `GetAll()` +- `GetByQuery(...)` +- `Insert(...)` +- `Update(...)` +- `Delete(...)` +- `Save(...)` + +It validates that query tables match mapped type `T` before executing typed enumeration. + +## Practical Recommendations + +- Always define keys explicitly in mapped models. +- Use `Override = true` when provider schema metadata is weak or inconsistent. +- Keep entities focused on persistence fields; use `[Ignore]` for non-persisted members. +- Use validation on boundary objects before persisting. diff --git a/docs/net40-amalgamation.md b/docs/net40-amalgamation.md new file mode 100644 index 0000000..f050cdc --- /dev/null +++ b/docs/net40-amalgamation.md @@ -0,0 +1,42 @@ +# .NET 4.0 Amalgamation + +Legacy .NET 4.0 consumers should use the amalgamated source model. + +## Files + +- `AmalgamationTool/DynamORM.Amalgamation.cs`: merged source file. +- `DynamORM.Net40.csproj`: net40 build project that compiles the amalgamation output. + +## Regenerate Amalgamation + +The amalgamation file must be regenerated after library source changes. + +Current workflow in this repository: + +1. Build or run `AmalgamationTool` (or use an equivalent merge script). +2. Merge all files from `DynamORM/` into `AmalgamationTool/DynamORM.Amalgamation.cs`. +3. Build `DynamORM.Net40.csproj`. + +## Build in Mono Container + +```bash +docker run --rm -v "$PWD":/src -w /src mono:latest \ + bash -lc "msbuild /t:Build /p:Configuration=Release DynamORM.Net40.csproj" +``` + +Expected output artifact: + +- `bin/Release/net40/DynamORM.Net40.dll` + +## Build with .NET SDK Container + +```bash +docker run --rm -v "$PWD":/src -w /src mcr.microsoft.com/dotnet/sdk:10.0-preview \ + dotnet build DynamORM.Net40.csproj -c Release +``` + +## Important Constraint + +Do not force net40 into the main multi-target `DynamORM/DynamORM.csproj` for modern workflows. + +Keep net40 compatibility validated through the dedicated amalgamation project so legacy consumers can embed the single-file source without destabilizing primary targets. diff --git a/docs/quick-start.md b/docs/quick-start.md new file mode 100644 index 0000000..4a24707 --- /dev/null +++ b/docs/quick-start.md @@ -0,0 +1,65 @@ +# Quick Start + +## Install and Reference + +Reference the `DynamORM` project or package from your application. + +## Basic Setup + +```csharp +using System.Data.SQLite; +using DynamORM; + +var options = + DynamicDatabaseOptions.SingleConnection | + DynamicDatabaseOptions.SingleTransaction | + DynamicDatabaseOptions.SupportLimitOffset | + DynamicDatabaseOptions.SupportSchema; + +using (var db = new DynamicDatabase( + SQLiteFactory.Instance, + "Data Source=app.db;", + options)) +{ + db.DumpCommands = true; + + var users = db.Table("users"); + var total = users.Count(columns: "id"); + var first = users.First(columns: "id,first,last"); +} +``` + +This setup mirrors `DynamORM.Tests/TestsBase.cs`. + +## First Query (Dynamic API) + +```csharp +using (var db = new DynamicDatabase(SQLiteFactory.Instance, "Data Source=app.db;", options)) +{ + var row = db.Table("users").Single(id: 19); + Console.WriteLine(row.first); +} +``` + +## First Query (Fluent Builder API) + +```csharp +using (var db = new DynamicDatabase(SQLiteFactory.Instance, "Data Source=app.db;", options)) +using (var query = db.From("users").Where("id", 19).SelectColumn("first")) +{ + var first = query.ScalarAs(); + Console.WriteLine(first); +} +``` + +## Insert, Update, Delete + +```csharp +var table = db.Table("users"); + +table.Insert(code: "201", first: "Juri", last: "Gagarin"); +table.Update(values: new { first = "Yuri" }, where: new { code = "201" }); +table.Delete(code: "201"); +``` + +These forms are validated in `DynamORM.Tests/Modify/DynamicModificationTests.cs`. diff --git a/docs/stored-procedures.md b/docs/stored-procedures.md new file mode 100644 index 0000000..c4fd6d0 --- /dev/null +++ b/docs/stored-procedures.md @@ -0,0 +1,264 @@ +# Stored Procedures + +Stored procedure support is available through `db.Procedures` when `DynamicDatabaseOptions.SupportStoredProcedures` is enabled. + +This page documents actual runtime behavior from `DynamicProcedureInvoker`. + +## `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 (safer for providers that do not expose SQL Server-like return value behavior) + +Why this matters: + +- 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 + +If procedures fail on non-SQL Server providers, first disable `SupportStoredProceduresResult` and rely on explicit `out_` parameters for status/result codes. + +## Invocation Basics + +```csharp +var scalar = db.Procedures.sp_Exp_Scalar(); +var scalarTyped = db.Procedures.sp_Exp_Scalar(); +``` + +Schema-qualified naming is built through dynamic chaining: + +```csharp +var res = db.Procedures.dbo.reporting.MyProc(); +``` + +Final command name is `dbo.reporting.MyProc`. + +## Parameter Direction Prefixes + +Because dynamic `out/ref` is limited, parameter direction is encoded by argument name prefix: + +- `out_` => `ParameterDirection.Output` +- `ret_` => `ParameterDirection.ReturnValue` +- `both_` => `ParameterDirection.InputOutput` + +Example: + +```csharp +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 (`out_message` -> `message`). + +## How to Specify Type/Length for Out Parameters + +This is the most common pain point. You have 2 primary options. + +## Option A: `DynamicSchemaColumn` (recommended for output-only) + +Use this when you need explicit type, length, precision, or scale. + +```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); +``` + +## Option B: `DynamicColumn` (recommended for input/output with value) + +Use this when you need direction + value + schema in one object. + +```csharp +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 + name prefix + +Quickest form, but type/size inference is automatic. + +```csharp +dynamic res = db.Procedures.sp_Message_SetState( + id: "abc-001", + out_message: "", + ret_code: 0); +``` + +Use Option A/B whenever output size/type must be controlled. + +## Return Shape Matrix (What You Actually Get) + +`DynamicProcedureInvoker` chooses result shape from generic type arguments. + +## No generic type arguments (`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 + +Final returned object: + +- if no out/ret params were requested: scalar main result (`int` or return value) +- if any out/ret/both params were requested: dynamic object with keys + +## One generic type argument (`MyProc(...)`) + +Main result type resolution: + +- `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`) + +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 + +Important nuance: + +- when out params exist and only one generic type is provided, the result is a dynamic object, not bare `TMain`. + +## Two generic type arguments (`MyProc(...)`) + +This is the 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 + +## Result Key Names in Out Payload + +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) + +Example call: + +```csharp +var res = db.Procedures.sp_Message_SetState( + 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` (main result) +- `message` +- `code` + +## Preparing Result Classes Correctly + +Use `ColumnAttribute` to map returned payload keys. + +```csharp +public class MessageSetStateResult +{ + [Column("sp_Message_SetState")] + public int MainResult { get; set; } + + [Column("message")] + public string Message { get; set; } + + [Column("code")] + public int ReturnCode { get; set; } +} +``` + +If key names and property names already match, attributes are optional. + +## Common Patterns + +## Pattern 1: Main scalar only + +```csharp +int count = db.Procedures.sp_CountMessages(); +``` + +## Pattern 2: Output params + mapped output model + +```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); +``` + +## Pattern 3: Procedure returning dataset as table + +```csharp +DataTable dt = db.Procedures.sp_Message_GetBatch(batchId: 10); +``` + +## Pattern 4: Procedure returning mapped collection + +```csharp +List rows = db.Procedures.sp_Message_List>(status: 1); +``` + +## Pattern 5: Read output dynamically + +```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; +``` + +## 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`. diff --git a/docs/test-driven-examples.md b/docs/test-driven-examples.md new file mode 100644 index 0000000..27daf66 --- /dev/null +++ b/docs/test-driven-examples.md @@ -0,0 +1,70 @@ +# Test-Driven Examples + +This page maps concrete examples to test files so behavior can be verified quickly. + +## Dynamic Select and Aggregates + +Source: `DynamORM.Tests/Select/DynamicAccessTests.cs` + +- `Count/Min/Max/Avg/Sum/Scalar` +- `First/Last/Single` +- `IN`, `LIKE`, `BETWEEN`, comparison operators +- ad-hoc column expressions and aliases + +## Typed Access and Mapping + +Source: `DynamORM.Tests/Select/TypedAccessTests.cs` + +- `Query(type: typeof(T))` +- `Execute()` +- projection with `MapEnumerable()` +- table mapping and strongly typed expressions + +## SQL Parser and CommandText Expectations + +Source: `DynamORM.Tests/Select/ParserTests.cs` + +- `From` variants (`schema.table`, aliasing, string forms) +- join expressions and join kind helpers +- subquery embedding +- `NoLock()` translation +- deterministic SQL text generation checks + +## Data Modification + +Source: `DynamORM.Tests/Modify/DynamicModificationTests.cs` + +- insert via named args, anonymous object, mapped class, plain class +- update via key/object/where +- delete via key/object/where + +## Schema Variants + +Sources: + +- `DynamORM.Tests/Modify/DynamicTypeSchemaModificationTests.cs` +- `DynamORM.Tests/Modify/DynamicNoSchemaModificationTests.cs` +- `DynamORM.Tests/Select/DynamicTypeSchemaAccessTests.cs` +- `DynamORM.Tests/Select/DynamicNoSchemaAccessTests.cs` + +Use these to validate behavior with and without schema support. + +## Resource and Pooling Semantics + +Source: `DynamORM.Tests/Helpers/PoolingTests.cs` + +- command invalidation after database disposal +- transaction rollback behavior during disposal + +## Validation + +Source: `DynamORM.Tests/Helpers/Validation/ObjectValidationTest.cs` + +- rule-based object validation with `RequiredAttribute` +- array element-level validation + +## Dynamic Parser (Helper Layer) + +Source: `DynamORM.Tests/Helpers/Dynamic/DynamicParserTests.cs` + +Covers lower-level parser behavior used by fluent lambda parsing. diff --git a/docs/transactions-and-disposal.md b/docs/transactions-and-disposal.md new file mode 100644 index 0000000..694b835 --- /dev/null +++ b/docs/transactions-and-disposal.md @@ -0,0 +1,65 @@ +# Transactions and Disposal + +DynamORM manages connections, command pools, and transaction stacks internally. + +## Connection and Transaction Options + +`DynamicDatabaseOptions` controls behavior: + +- `SingleConnection` +- `SingleTransaction` +- `SupportSchema` +- `SupportStoredProcedures` +- `SupportNoLock` +- `SupportTop` / `SupportLimitOffset` / `SupportFirstSkip` + +Typical setup: + +```csharp +var options = + DynamicDatabaseOptions.SingleConnection | + DynamicDatabaseOptions.SingleTransaction | + DynamicDatabaseOptions.SupportLimitOffset | + DynamicDatabaseOptions.SupportSchema; +``` + +## Transaction Usage + +```csharp +using (var db = new DynamicDatabase(factory, connectionString, options)) +using (var conn = db.Open()) +using (var tx = conn.BeginTransaction()) +using (var cmd = conn.CreateCommand()) +{ + cmd.SetCommand("UPDATE users SET first = 'Ada' WHERE id = 1").ExecuteNonQuery(); + tx.Commit(); +} +``` + +Global transaction mode is also available via `db.BeginTransaction()`. + +## Disposal Guarantees + +Current disposal behavior includes idempotent guards on key objects: + +- `DynamicDatabase` +- `DynamicConnection` +- `DynamicCommand` +- `DynamicTransaction` +- `DynamicTable` +- builder and parser helpers + +This prevents repeated cleanup from throwing or re-disposing lower-level resources. + +## Pooling and Rollback Behavior + +Behavior validated by `DynamORM.Tests/Helpers/PoolingTests.cs`: + +- Disposing the database invalidates active commands. +- Open transactions are rolled back during disposal when not committed. + +## Practices + +- Prefer `using` blocks for `DynamicDatabase`, connections, commands, transactions, and builders. +- Do not manually re-dispose the same object from multiple ownership paths unless `IsDisposed` is checked. +- Keep transaction scope short and explicit.