diff --git a/docsite/CONTRIBUTING-DOCS.md b/docsite/CONTRIBUTING-DOCS.md index d90a7e5..92f786c 100644 --- a/docsite/CONTRIBUTING-DOCS.md +++ b/docsite/CONTRIBUTING-DOCS.md @@ -7,6 +7,7 @@ Every push to `master` rebuilds and publishes the site (`.github/workflows/docs. - **Articles:** add a markdown file to `docsite/articles/` and one `- href: file.md` line to `docsite/articles/toc.yml` (the menu title is the file's first heading). - **Namespace and package overviews:** a markdown file with `uid: ` and `summary: *content` front matter. Put it in `Source//docs/` next to the code, or in `docsite/api/overview/`; both are picked up without a config edit. - **Diagrams:** a standalone `.svg` in `docsite/images/`, colors as CSS variables, no external references. +- **Side-by-side comparisons:** wrap two markdown code blocks in `
` (one `
` per column, blank lines around the content, styled by `templates/custom/public/main.css`). Both regions need the same number of lines so the rows align. - **README.md:** a pitch with absolute links; its code blocks are copied from sample regions. - **Build locally:** `dotnet tool restore`, then `dotnet docfx docsite/docfx.json --serve`. - **Failures:** warnings are listed in the workflow summary and never block a deploy. A DocFX error or a sample that does not compile does, and the live site stays as it was. \ No newline at end of file diff --git a/docsite/articles/coming-from-dapper.md b/docsite/articles/coming-from-dapper.md index 0c2326e..1694c9f 100644 --- a/docsite/articles/coming-from-dapper.md +++ b/docsite/articles/coming-from-dapper.md @@ -1,53 +1,238 @@ # Coming from Dapper -Open.Database.Extensions is a set of extensions on ADO.NET, not a Dapper clone, and plenty of code uses both on the same connection. This page is for readers who know Dapper's API and want the equivalent here. For speed and memory, see [Performance](performance.md). +Open.Database.Extensions is a set of extensions on ADO.NET, not a Dapper clone, and plenty of code uses both. This page is for readers who know Dapper. The biggest difference is not the query syntax: it is who handles the connection. For speed and memory, see [Performance](performance.md). -## Side by side +## The steps, side by side -Both snippets run against the same data in the build, and the results are checked to be equal. +Each comparison lists the same four steps in the same order, row against row: (1) get the connection, (2) the command or query, (3) the parameters, (4) the terminal and the mapping. A row that one side does not need says "nothing". The SQL is predefined once, so the lines stay short: -**A list** +[!code-csharp[](../samples/ConnectionPattern.cs#shared_sql)] -[!code-csharp[](../samples/DapperComparison.cs#dapper_list)] +**Dapper needs no setup.** It needs the connection string and the provider type in scope at every call site. It opens a closed connection for the call and closes it again afterwards, so `connection.Open()` is not required; add it only if you want to control when the connection opens. -[!code-csharp[](../samples/DapperComparison.cs#ode_list)] +**Open.Database.Extensions needs a connection factory,** created or registered once per application (see [Registering the factory once](#registering-the-factory-once)): -**The first row** +[!code-csharp[](../samples/ConnectionPattern.cs#ode_setup_once)] -[!code-csharp[](../samples/DapperComparison.cs#dapper_first)] +After that, no call site writes any connection code. The counts below do not include this one line of one-time setup. -[!code-csharp[](../samples/DapperComparison.cs#ode_first)] +### A list of rows -**A scalar** +
+
-[!code-csharp[](../samples/DapperComparison.cs#dapper_scalar)] +**Dapper** -[!code-csharp[](../samples/DapperComparison.cs#ode_scalar)] +[!code-csharp[](../samples/ConnectionPattern.cs#dapper_list_call)] -**A command with no result set** +
+
-[!code-csharp[](../samples/DapperComparison.cs#dapper_execute)] +**Open.Database.Extensions (factory)** -[!code-csharp[](../samples/DapperComparison.cs#ode_execute)] +[!code-csharp[](../samples/ConnectionPattern.cs#ode_list_call)] -## Differences to know about +
+
-| | Dapper | Open.Database.Extensions | -|---|---|---| -| Parameters | An anonymous object or `DynamicParameters` | `.AddParam(name, value)` calls on the command | -| Starting point | Extension methods on `IDbConnection` | `connection.Command(sql)` or `connectionFactory.Command(sql)` | -| Connection handling | Opens a closed connection for the call and closes it again | Same rule for a connection. From a factory, it creates and disposes the connection | -| Mapping to a class | Constructor binding, settable properties and fields, configurable name matching | Public settable properties only; see [the rules](mapping.md#the-rules) | -| Positional records | Supported | Not supported: `Map()` needs `new T()` | -| Other mapping features | Multi-mapping, `QueryMultiple`, dynamic results, custom type handlers | Not provided; use `Map(selector)` or the reader helpers | +Lines you write per call: Dapper 4, Open.Database.Extensions 3 (plus the one-time setup). -Dapper's features are described in Dapper's own documentation. The record example below runs in the build against Dapper 2.1.89: +What is different: + +- **The connection.** With Dapper you create it and dispose it, and Dapper opens it if it is closed and closes it when the call finishes. With a factory, the factory creates it for each execution, the command opens it only if it is closed and disposes it when the terminal returns, so it is open only for the duration of the call. +- **Rows.** Dapper's `Query` buffers by default (`buffered = true`) and returns an `IEnumerable`, turned into a list here by `AsList()`. `ToList()` reads every row into a list during the call. `Read(handler)` offers a lazy sequence that is only valid inside the handler. +- **Mapping.** Both match columns to members by name, ignoring case. Dapper can also bind constructor parameters and fields and, if you turn on `MatchNamesWithUnderscores`, match `first_name` to `FirstName`. The extensions set public settable properties only and do no naming-convention matching: a renamed column needs an override (see [Renamed columns](#renamed-columns)). +- **Null parameters.** Both convert a null parameter value to `DBNull`. +- **Mapping errors.** Dapper wraps them in a `DataException` ("Error parsing column ..."). The extensions throw an `InvalidOperationException` that names the property and the column. + +### One row + +
+
+ +**Dapper** + +[!code-csharp[](../samples/ConnectionPattern.cs#dapper_first_call)] + +
+
+ +**Open.Database.Extensions (factory)** + +[!code-csharp[](../samples/ConnectionPattern.cs#ode_first_call)] + +
+
+ +Lines you write per call: Dapper 3, Open.Database.Extensions 3 (plus the one-time setup). + +What is different: + +- **No rows, or several.** Dapper's `QueryFirstOrDefault` returns `default` for no rows and the first row for several. `QuerySingleOrDefault` returns `default` for none and throws for more than one. `QueryFirst` and `QuerySingle` throw when there is no row. The extensions' `FirstOrDefault()` returns `null` for no rows and the first row for several; `SingleOrDefault()` returns `null` for none and throws `InvalidOperationException` for more than one. There is no throwing `First` or `Single`. + +### A scalar + +
+
+ +**Dapper** + +[!code-csharp[](../samples/ConnectionPattern.cs#dapper_scalar_call)] + +
+
+ +**Open.Database.Extensions (factory)** + +[!code-csharp[](../samples/ConnectionPattern.cs#ode_scalar_call)] + +
+
+ +Lines you write per call: Dapper 3, Open.Database.Extensions 3 (plus the one-time setup). + +What is different: + +- **The value's type.** Dapper converts the provider's value to `T` where it can (it uses `Convert.ChangeType`) and returns `default` for `NULL`. The extensions' `ExecuteScalar()` casts the value exactly as the provider returns it, so `T` must be that type (SQLite counts as `long`, SQL Server as `int`). To convert, pass a function: `ExecuteScalar(value => ...)`. + +### A command with no result set + +
+
+ +**Dapper** + +[!code-csharp[](../samples/ConnectionPattern.cs#dapper_nonquery_call)] + +
+
+ +**Open.Database.Extensions (factory)** + +[!code-csharp[](../samples/ConnectionPattern.cs#ode_nonquery_call)] + +
+
+ +Lines you write per call: Dapper 3, Open.Database.Extensions 3 (plus the one-time setup). + +What is different: + +- **The result.** Both return the number of rows affected, as an `int`. + +### A transaction + +Two facts are both true, and mixing them up causes the "Dapper needs `Open()`" belief. Dapper opens a closed connection for plain queries and closes it again afterwards. ADO.NET requires an open connection to begin a transaction, so any code that begins one opens the connection first. Dapper does not begin, commit or roll back transactions: its methods take an `IDbTransaction` that you created from an open connection. + +
+
+ +**Dapper** + +[!code-csharp[](../samples/ConnectionPattern.cs#dapper_transaction_call)] + +
+
+ +**Open.Database.Extensions (factory)** + +[!code-csharp[](../samples/ConnectionPattern.cs#ode_transaction_call)] + +
+
+ +Lines you write per call: Dapper 14, Open.Database.Extensions 6 (plus the one-time setup). + +What is different: + +- **Who opens the connection.** Dapper: you, with `Open()`, because `BeginTransaction()` needs it. Microsoft's `SqlConnection.BeginTransaction` examples open the connection first, and Microsoft.Data.Sqlite reports that the call "can only be called when the connection is open". The extensions' `ExecuteTransaction` opens the connection if it is closed. +- **Begin, commit, roll back.** Dapper: you write all three, usually with a `try`/`catch`, and pass the transaction to every call. The extensions begin the transaction, run your action, commit when it returns without throwing, and roll back (and rethrow) when it throws. `ExecuteTransactionConditional` commits only when your action returns `true`; otherwise it rolls back. +- **A connection that was already open.** The extensions close only a connection they opened; an open connection stays open. +- **Where the helpers live.** They are extension methods on `DbConnection`, with async forms (`ExecuteTransactionAsync`, `ExecuteTransactionConditionalAsync`), an optional isolation level and a cancellation token. A factory reaches them through `Using`. +### A connection you own + +If you would rather own the connection, the extensions work on a connection the way Dapper does: the same first row, then the same rows as the factory form. + +
+
+ +**Dapper** + +[!code-csharp[](../samples/ConnectionPattern.cs#dapper_list_call)] + +
+
+ +**Open.Database.Extensions (your connection)** + +[!code-csharp[](../samples/ConnectionPattern.cs#ode_list_own)] + +
+
+ +Lines you write per call: Dapper 4, Open.Database.Extensions 4. + +What is different: + +- **Nothing about the connection.** You create it and dispose it in both. Each opens a closed connection for the call and closes only what it opened; neither needs `Open()`. The extensions never dispose a connection you gave them. +- **Concurrency.** A command built from one connection throws `InvalidOperationException` if two terminals run at once. + +### The total + +Across the four query calls above Dapper takes 13 lines and the extensions 12, plus one line of one-time setup. The counts are close: the difference is what the lines are. None of the extensions' per-call lines creates, owns or disposes a connection, so there is nothing to forget, to leak or to keep open too long, and connection handling lives in one place instead of at every call site. + +## Who owns what + +| | Dapper | Extensions with a factory | Extensions with your connection | +|---|---|---|---| +| Creates the connection | You, at each call site | The factory, for each execution | You | +| Needs the connection string and provider type in scope | At each call site | Once, where the factory is created | You, where you create it | +| Opens it | Dapper, if it is closed | The command, if it is closed | The command, only if it is closed | +| Closes it | Dapper, if it opened it | The command disposes it | The command, only if it opened it | +| Disposes it | You | The command | You. Never the command | + +## Registering the factory once + +`IDbConnectionFactory` is the abstraction, `DbConnectionFactory` wraps any `Func`, and `SqlConnectionFactory` (in the MSSqlClient package) takes a connection string. The library has no dependency-injection extension of its own: register the factory with whatever container you use. With Microsoft.Extensions.DependencyInjection: + +[!code-csharp[](../samples/ConnectionPattern.cs#di_registration)] + +Consumers take the factory as a constructor parameter and never see a connection string: + +[!code-csharp[](../samples/ConnectionPattern.cs#di_consumer)] + +For SQL Server, register `SqlConnectionFactory` as `IDbConnectionFactory`: + +[!code-csharp[](../samples/ConnectionPattern.cs#sql_server_registration)] + +A singleton is fine because the factory holds no connection: `Create()` returns a new connection object each time. + +## Renamed columns + +Dapper can match underscored names (`MatchNamesWithUnderscores`) or you can alias the columns in SQL. With the extensions, name the column for each property that needs it. `nameof` keeps the property side checked by the compiler: + +[!code-csharp[](../samples/ConnectionPattern.cs#ode_renamed_columns)] + +## Async and cancellation + +Dapper's `QueryAsync` returns a `Task>`, and a cancellation token travels in a `CommandDefinition`. The extensions' terminals have `Async` twins that take a `CancellationToken` and return a `ValueTask`, for example `ToArrayAsync(cancellationToken)`; a token set on the command with `UseCancellationToken(token)` is used when a terminal is not given one. + +## Where Dapper does more + +Dapper has features this library does not offer: + +- **Constructor binding.** Positional records work with Dapper. `Map()` needs `new T()` and settable properties. +- **Multi-mapping and `QueryMultiple`.** Dapper maps several types from one row and reads several result sets from one call. +- **Type handlers and custom type maps.** `SqlMapper.AddTypeHandler` and `SqlMapper.SetTypeMap`. +- **Dynamic results.** `Query(sql)` without a type returns dynamic rows. [!code-csharp[](../samples/DapperComparison.cs#dapper_record)] +With the extensions, use `Map(selector)` or the reader helpers for those jobs. + ## Migrating a call site -1. Replace the anonymous parameter object with `.AddParam` calls. -2. Replace `Query(sql, ...)` with `Command(sql)...Map().ToList()` (or `ToArray()`). +1. Create the connection factory once and register or inject it. +2. Replace `using var connection = ...` and `connection.Query(sql, new { ... })` with `connectionFactory.Command(sql).AddParam(...).Map().ToList()` (or `ToArray()`). 3. If a model uses constructor binding, give it a parameterless constructor and settable properties, or write a `Map(selector)`. -4. If column names differ from property names, add overrides: Dapper's global name matching has no equivalent here. +4. If column names differ from property names, add overrides. \ No newline at end of file diff --git a/docsite/articles/expressive-commands.md b/docsite/articles/expressive-commands.md index ee245ae..11175b7 100644 --- a/docsite/articles/expressive-commands.md +++ b/docsite/articles/expressive-commands.md @@ -4,15 +4,24 @@ An expressive command is a small fluent object: pick the SQL (or stored procedur ## Two starting points -The factory form and the connection form look the same after the first call. +The factory form (`connectionFactory.Command(sql)` or `connectionFactory.StoredProcedure(name)`) and the connection form (`connection.Command(sql)` or `connection.StoredProcedure(name)`) look the same after the first call. Side by side, row against row: -**Factory form** (`connectionFactory.Command(sql)` or `connectionFactory.StoredProcedure(name)`): +
+
-[!code-csharp[](../samples/ExpressiveCommands.cs#factory_form)] +**Factory form** -**Connection form** (`connection.Command(sql)` or `connection.StoredProcedure(name)`): +[!code-csharp[](../samples/ConnectionPattern.cs#ode_list_call)] -[!code-csharp[](../samples/ExpressiveCommands.cs#connection_form)] +
+
+ +**Connection form** + +[!code-csharp[](../samples/ConnectionPattern.cs#ode_list_own)] + +
+
In the factory form the command creates, opens and disposes a connection for every terminal. In the connection form the connection is yours: the command opens it only when it is closed, closes only what it opened, and never disposes it. See [who owns the connection](foundation.md#who-owns-the-connection). diff --git a/docsite/articles/getting-started.md b/docsite/articles/getting-started.md index 1e8b64d..a16db85 100644 --- a/docsite/articles/getting-started.md +++ b/docsite/articles/getting-started.md @@ -25,15 +25,61 @@ A model needs a public parameterless constructor and public settable properties. [!code-csharp[](../samples/Models.cs#post_model)] -There are two ways to start a command. Both end the same way, with `AddParam` for parameters and a terminal that runs the query. +There are two ways to start a command. Both end the same way, with `AddParam` for parameters and a terminal that runs the query. Side by side, row against row ("nothing" marks a step the factory makes unnecessary): -**From a connection factory.** The command takes a connection from the factory when a terminal runs, opens it, and disposes it afterwards: +
+
-[!code-csharp[](../samples/ExpressiveCommands.cs#factory_form)] +**From a connection factory** -**From a connection you already have.** You own the connection. The command opens it only if it is closed and closes only what it opened: +[!code-csharp[](../samples/ConnectionPattern.cs#ode_list_call)] -[!code-csharp[](../samples/ExpressiveCommands.cs#connection_form)] +
+
+ +**From a connection you own** + +[!code-csharp[](../samples/ConnectionPattern.cs#ode_list_own)] + +
+
+ +The factory is created once per application: + +[!code-csharp[](../samples/ConnectionPattern.cs#ode_setup_once)] + +What is different: + +- **Who creates the connection.** The factory, for each execution; or you. +- **Open and close.** In both forms the command opens the connection only if it is closed. The factory form disposes the connection when the terminal returns. In the connection form the command closes only what it opened and never disposes it. + +## Before and after + +The same query with plain ADO.NET and with the extensions, row against row: the connection, the command, the parameter, then the read loop and the mapping. + +
+
+ +**Plain ADO.NET** + +[!code-csharp[](../samples/ConnectionPattern.cs#plain_list_aligned)] + +
+
+ +**Open.Database.Extensions (factory)** + +[!code-csharp[](../samples/ConnectionPattern.cs#ode_list_aligned)] + +
+
+ +Lines you write per call: plain ADO.NET 20, Open.Database.Extensions 4 (plus one line of one-time setup per application). + +What is different: + +- **Plain ADO.NET** needs you to open the connection, create and dispose the command and the reader, add each parameter by hand and copy each column by ordinal, with an `IsDBNull` check for nullable columns. +- **With the extensions** the factory opens and disposes the connection, the command is one call, and the terminal does the read loop and the mapping. Matching columns to properties is by name, ignoring case. ## With SQL Server diff --git a/docsite/docfx.json b/docsite/docfx.json index 7caecec..952cb0f 100644 --- a/docsite/docfx.json +++ b/docsite/docfx.json @@ -51,7 +51,7 @@ } ], "output": "_site", - "template": [ "default", "modern" ], + "template": [ "default", "modern", "templates/custom" ], "globalMetadata": { "_appName": "Open.Database.Extensions", "_appTitle": "Open.Database.Extensions", diff --git a/docsite/samples/ConnectionPattern.cs b/docsite/samples/ConnectionPattern.cs new file mode 100644 index 0000000..531bd12 --- /dev/null +++ b/docsite/samples/ConnectionPattern.cs @@ -0,0 +1,276 @@ +using Dapper; +using Microsoft.Data.SqlClient; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; +using Open.Database.Extensions; +using static Samples.Sql; + +namespace Samples; + +// The comparisons in the docs: every pair has the same number of lines, so the rows line up side by side. +// Row 1 gets the connection, row 2 is the command or query, row 3 the parameters, row 4 the terminal and mapping. +public static class Sql +{ + #region shared_sql + public const string ListSql = "select * from posts where author = @author order by id"; + public const string OneSql = "select * from posts where id = @id"; + public const string CountSql = "select count(*) from posts where author = @author"; + public const string TouchSql = "update posts set views = views where author = @author"; + public const string InsertSql = "insert into posts (id, author, title, body, views) values (@id, 1, 'Temporary', null, 0)"; + #endregion +} + +public static class ConnectionPattern +{ + public static DbConnectionFactory Setup(string connectionString) + { + #region ode_setup_once + var connectionFactory = new DbConnectionFactory(() => new SqliteConnection(connectionString)); + #endregion + + return connectionFactory; + } + + public static List DapperList(string connectionString) + { + #region dapper_list_call + using var connection = new SqliteConnection(connectionString); + List posts = connection.Query(ListSql, + new { author = 42 }) + .AsList(); + #endregion + + return posts; + } + + public static List OdeList(IDbConnectionFactory connectionFactory) + { + #region ode_list_call + // nothing: the factory creates, opens and disposes the connection + List posts = connectionFactory.Command(ListSql) + .AddParam("author", 42) + .Map().ToList(); + #endregion + + return posts; + } + + public static List OdeListOwn(string connectionString) + { + #region ode_list_own + using var connection = new SqliteConnection(connectionString); + List posts = connection.Command(ListSql) + .AddParam("author", 42) + .Map().ToList(); + #endregion + + return posts; + } + + public static Post? DapperFirst(string connectionString) + { + #region dapper_first_call + using var connection = new SqliteConnection(connectionString); + Post? post = connection.QueryFirstOrDefault(OneSql, + new { id = 3 }); + // nothing: "first or default" and Post are in the method name above + #endregion + + return post; + } + + public static Post? OdeFirst(IDbConnectionFactory connectionFactory) + { + #region ode_first_call + // nothing: the factory creates, opens and disposes the connection + Post? post = connectionFactory.Command(OneSql) + .AddParam("id", 3) + .Map().FirstOrDefault(); + #endregion + + return post; + } + + public static long DapperScalar(string connectionString) + { + #region dapper_scalar_call + using var connection = new SqliteConnection(connectionString); + long count = connection.ExecuteScalar(CountSql, + new { author = 42 }); + // nothing: the terminal is the method name above + #endregion + + return count; + } + + public static long OdeScalar(IDbConnectionFactory connectionFactory) + { + #region ode_scalar_call + // nothing: the factory creates, opens and disposes the connection + long count = connectionFactory.Command(CountSql) + .AddParam("author", 42) + .ExecuteScalar(); + #endregion + + return count; + } + + public static int DapperNonQuery(string connectionString) + { + #region dapper_nonquery_call + using var connection = new SqliteConnection(connectionString); + int updated = connection.Execute(TouchSql, + new { author = 42 }); + // nothing: the terminal is the method name above + #endregion + + return updated; + } + + public static int OdeNonQuery(IDbConnectionFactory connectionFactory) + { + #region ode_nonquery_call + // nothing: the factory creates, opens and disposes the connection + int updated = connectionFactory.Command(TouchSql) + .AddParam("author", 42) + .ExecuteNonQuery(); + #endregion + + return updated; + } + + public static void DapperTransaction(string connectionString) + { + #region dapper_transaction_call + using var connection = new SqliteConnection(connectionString); + connection.Open(); + using var transaction = connection.BeginTransaction(); + try + { + connection.Execute(InsertSql, new { id = 100 }, transaction); + connection.Execute(InsertSql, new { id = 101 }, transaction); + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + #endregion + } + + public static void OdeTransaction(IDbConnectionFactory connectionFactory) + { + #region ode_transaction_call + connectionFactory.Using(connection => + // nothing: ExecuteTransaction opens the connection if it is closed + // nothing: ExecuteTransaction begins the transaction + connection.ExecuteTransaction(transaction => + { + transaction.Command(InsertSql).AddParam("id", 100).ExecuteNonQuery(); + transaction.Command(InsertSql).AddParam("id", 101).ExecuteNonQuery(); + // nothing: it commits when the action returns + })); + // nothing: it rolls back if the action throws + // nothing: Using disposes the connection + #endregion + } + public static List PlainListAligned(string connectionString) + { + #region plain_list_aligned + using var connection = new SqliteConnection(connectionString); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = ListSql; + var parameter = command.CreateParameter(); + parameter.ParameterName = "@author"; + parameter.Value = 42; + command.Parameters.Add(parameter); + var posts = new List(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + posts.Add(new Post + { + Id = reader.GetInt32(0), + Author = reader.GetInt32(1), + Title = reader.GetString(2), + Body = reader.IsDBNull(3) ? null : reader.GetString(3), + }); + } + #endregion + + return posts; + } + + public static List OdeListAligned(IDbConnectionFactory connectionFactory) + { + #region ode_list_aligned + // nothing: the factory creates, opens and disposes the connection + + List posts = connectionFactory + .Command(ListSql) + .AddParam("author", 42) + + + + .Map().ToList(); + #endregion + + return posts; + } + + public static Person[] RenamedColumns(SqliteConnection connection) + { + #region ode_renamed_columns + Person[] people = connection + .Command("select person_id, first_name, last_name from people order by person_id") + .Map( + (nameof(Person.Id), "person_id"), + (nameof(Person.FirstName), "first_name"), + (nameof(Person.LastName), "last_name")) + .ToArray(); + #endregion + + return people; + } + + // Compile-only: needs a SQL Server. + public static IServiceCollection RegisterSqlServer(IServiceCollection services, string connectionString) + { + #region sql_server_registration + services.AddSingleton>(new SqlConnectionFactory(connectionString)); + #endregion + + return services; + } +} + +#region di_consumer +public class PostService(IDbConnectionFactory connectionFactory) +{ + public List ByAuthor(int author) => connectionFactory.Command(ListSql) + .AddParam("author", author) + .Map().ToList(); +} +#endregion + +public static class DependencyInjectionSample +{ + public static List ResolveAndUse() + { + #region di_registration + var services = new ServiceCollection(); + + // A singleton is fine: the factory holds no connection, it only creates them. + services.AddSingleton>( + new DbConnectionFactory(() => new SqliteConnection(Db.ConnectionString))); + services.AddTransient(); + + using ServiceProvider provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + #endregion + + return service.ByAuthor(42); + } +} \ No newline at end of file diff --git a/docsite/samples/Program.cs b/docsite/samples/Program.cs index 0cc37aa..0326bcd 100644 --- a/docsite/samples/Program.cs +++ b/docsite/samples/Program.cs @@ -87,6 +87,42 @@ void Check(bool condition, string what) Check(plainFirst is not null && odeFirst is not null && (plainFirst.Id, plainFirst.Title, plainFirst.Body) == (odeFirst.Id, odeFirst.Title, odeFirst.Body), "plain ADO.NET first row equals the extension first row"); Check(BeforeAfter.PlainScalar() == 3 && BeforeAfter.OdeScalar(factory) == 3, "plain and extension scalar agree"); Check(BeforeAfter.PlainNonQuery() == 3 && BeforeAfter.OdeNonQuery(factory) == 3, "plain and extension non-query agree"); +// The comparisons in the docs: both sides return the same data. +Db.Seed(); +string connectionString = Db.ConnectionString; +Check(ConnectionPattern.DapperList(connectionString).Select(p => p.Id).SequenceEqual(ConnectionPattern.OdeList(factory).Select(p => p.Id)), "Dapper and factory list agree"); +Check(ConnectionPattern.OdeListOwn(connectionString).Select(p => p.Id).SequenceEqual([1, 2, 3]), "own-connection list"); +Check(ConnectionPattern.PlainListAligned(connectionString).Select(p => p.Id).SequenceEqual(ConnectionPattern.OdeListAligned(factory).Select(p => p.Id)), "plain ADO.NET and factory aligned list agree"); +Check(ConnectionPattern.DapperFirst(connectionString)?.Title == ConnectionPattern.OdeFirst(factory)?.Title, "Dapper and factory first row agree"); +Check(ConnectionPattern.DapperScalar(connectionString) == 3 && ConnectionPattern.OdeScalar(factory) == 3, "Dapper and factory scalar agree"); +Check(ConnectionPattern.DapperNonQuery(connectionString) == 3 && ConnectionPattern.OdeNonQuery(factory) == 3, "Dapper and factory non-query agree"); +Check(DependencyInjectionSample.ResolveAndUse().Count == 3, "a factory registered once is injected"); +Check(ConnectionPattern.RenamedColumns(connection) is [{ FirstName: "Ada" }, { FirstName: "Alan" }], "renamed columns with nameof"); +Check(Throws(() => connection.Command("select * from posts").Map().SingleOrDefault()), "SingleOrDefault throws for more than one row (again)"); +Check(factory.Command("select * from posts where id = 999").Map().FirstOrDefault() is null, "FirstOrDefault is null for no rows"); +// Transactions: ADO.NET needs an open connection to begin one; Dapper begins nothing itself. +long RowsFrom100() => factory.Command("select count(*) from posts where id >= 100").ExecuteScalar(); +void ClearFrom100() => factory.Command("delete from posts where id >= 100").ExecuteNonQuery(); +ConnectionPattern.DapperTransaction(connectionString); +Check(RowsFrom100() == 2, "the Dapper transaction committed two rows"); +ClearFrom100(); +ConnectionPattern.OdeTransaction(factory); +Check(RowsFrom100() == 2, "ExecuteTransaction committed two rows"); +ClearFrom100(); +Check(Throws(() => factory.Using(c => c.ExecuteTransaction(tx => +{ + tx.Command(Sql.InsertSql).AddParam("id", 100).ExecuteNonQuery(); + throw new InvalidOperationException("boom"); +}))), "ExecuteTransaction propagates the exception"); +Check(RowsFrom100() == 0, "ExecuteTransaction rolled back when the action threw"); +using (var closedConnection = new SqliteConnection(connectionString)) +{ + closedConnection.ExecuteTransaction(tx => tx.Command("select 1").ExecuteScalar()); + Check(closedConnection.State == ConnectionState.Closed, "ExecuteTransaction closes a connection it opened"); +} + +connection.ExecuteTransaction(tx => tx.Command("select 1").ExecuteScalar()); +Check(connection.State == ConnectionState.Open, "ExecuteTransaction leaves an open connection open"); Console.WriteLine($"All {checks} sample checks passed."); return 0; diff --git a/docsite/samples/Samples.csproj b/docsite/samples/Samples.csproj index 902225d..28af0f2 100644 --- a/docsite/samples/Samples.csproj +++ b/docsite/samples/Samples.csproj @@ -19,6 +19,7 @@ + diff --git a/docsite/templates/custom/public/main.css b/docsite/templates/custom/public/main.css new file mode 100644 index 0000000..750f0d5 --- /dev/null +++ b/docsite/templates/custom/public/main.css @@ -0,0 +1,22 @@ +/* Side-by-side comparisons (see docsite/CONTRIBUTING-DOCS.md): two equal columns that stack on narrow screens. + Each column is a subgrid over two shared rows (heading, code), so the code blocks start on the same line + even when one heading wraps. Colors are inherited from the template, so light and dark mode both work. */ +.side-by-side { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 1rem; + margin: 1rem 0; +} +.side-by-side > div { + display: grid; + grid-row: span 2; + grid-template-rows: subgrid; + min-width: 0; +} +.side-by-side > div > p { margin: 0 0 0.25rem; font-weight: 600; } +.side-by-side pre { margin: 0; overflow-x: auto; } +.side-by-side pre code { white-space: pre; } +@media (max-width: 767.98px) { + .side-by-side { grid-template-columns: minmax(0, 1fr); } + .side-by-side > div + div { margin-top: 1rem; } +} \ No newline at end of file