diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
new file mode 100644
index 0000000..c4d1107
--- /dev/null
+++ b/.github/workflows/dotnet.yml
@@ -0,0 +1,37 @@
+name: .NET 6 and 7 CI
+
+on: [push]
+
+jobs:
+ build:
+
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ dotnet: [ '6.0.x', '7.0.x' ]
+ steps:
+ - uses: actions/checkout@v3
+ - name: Setup .NET SDK
+ uses: actions/setup-dotnet@v3
+ with:
+ dotnet-version: |
+ 3.1.x
+ ${{ matrix.dotnet }}
+ - name: Check BOM
+ run: |
+ dotnet tool install -g BomSweeper.GlobalTool
+ $HOME/.dotnet/tools/bomsweeper '**/*.cs' '**/*.csproj' '**/*.sln'
+ - name: Build
+ run: dotnet build --configuration Release
+ - name: Install
+ run: dotnet tool install -g dotnet-reportgenerator-globaltool
+ - name: Test
+ run: |
+ rm -rf MsTestResults
+ dotnet test --configuration Release --no-build --logger "console;verbosity=detailed" --collect:"XPlat Code Coverage" --results-directory MsTestResults
+ reportgenerator -reports:MsTestResults/*/coverage.cobertura.xml -targetdir:Coverlet-html
+ - name: Archive artifacts (code coverage)
+ uses: actions/upload-artifact@v3
+ with:
+ name: code-coverage-report-${{ matrix.dotnet }}
+ path: Coverlet-html
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..da47047
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+/.vs/
+/*/bin/
+/*/obj/
+/SqlBind/dcx/
+/SqlBind.Test/TestResults/
+/Coverlet-Html/
+/SqlBind/html/
+/SqlBind/Properties/
+/SqlBind.Test/Properties/
+*.csproj.user
diff --git a/COPYRIGHT.md b/COPYRIGHT.md
new file mode 100644
index 0000000..908e3d2
--- /dev/null
+++ b/COPYRIGHT.md
@@ -0,0 +1,23 @@
+Copyright (c) 2023 Maroontress Fast Software. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the
+ distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS *AS IS* AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS
+BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
+BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
+OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN
+IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3017a01
--- /dev/null
+++ b/README.md
@@ -0,0 +1,304 @@
+# SqlBind
+
+SqlBind.CSharp is a C# class library that is a wrapper for SQLite.
+
+## How to create a table and insert rows
+
+Let's consider creating the Actors table as follows:
+
+> ### _Actors_
+>
+> | id | name |
+> | ---: | :--- |
+> | 1 | Chloë Grace Moretz |
+> | 2 | Gary Carr |
+> | 3 | Jack Reynor |
+
+Create the following class to represent this table:
+
+```csharp
+[Table("Actors")]
+public record class Actor(
+ [Column("id")][PrimaryKey][AutoIncrement] long Id,
+ [Column("name")] string Name)
+{
+}
+```
+
+Each parameter in the constructor of the `Actor` class corresponds to each column in the Actors table in the same order. The type of each parameter must be either `long` or `string`.
+
+Note that you can implement the `Actor` class without a `record` class. However, the parameter names of the constructor must start with an _uppercase_ letter if you create a regular one according to the naming conventions of the `record` class. This is inconsistent with general naming conventions. Therefore, we recommend that you use `record` classes.
+
+The following code from the `Example` class uses the `Actor` class to create the Actors table and add three rows of data to the table:
+
+```csharp
+public sealed class Example
+{
+ private TransactionKit Kit { get; } = new TransactionKit(
+ "example.db",
+ m => Console.WriteLine(m()));
+
+ public void CreateTableAndInsertRows()
+ {
+ Kit.Execute(q =>
+ {
+ q.NewTables(typeof(Actor));
+ q.Insert(new Actor(0, "Chloë Grace Moretz"));
+ q.Insert(new Actor(0, "Gary Carr"));
+ q.Insert(new Actor(0, "Jack Reynor"));
+ });
+ }
+ ...
+```
+
+The `Kit` property has the `TransactionKit` instance, which uses the `example.db` file as a database backend and writes log messages to the console. The `Execute` method executes the queries that the lambda expression of its parameter performs atomically (as a single transaction).
+
+Note that calling the `Insert(object)` method with the `Actor` instance ignores its `Id` property, which is specified with the first parameter of the constructor of the `Actor` class, because it is qualified with the `AutoIncrement` attribute.
+
+The log messages that the `CreateTableAndInsertRows()` method prints to the console are as follows:
+
+```plaintext
+DROP TABLE IF EXISTS Actors
+CREATE TABLE Actors (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)
+INSERT INTO Actors (name) VALUES ($name)
+ ($name, Chloë Grace Moretz)
+INSERT INTO Actors (name) VALUES ($name)
+ ($name, Gary Carr)
+INSERT INTO Actors (name) VALUES ($name)
+ ($name, Jack Reynor)
+```
+
+The non-indented lines are actual SQL statements that were automatically generated and executed.
+
+## How to select a table and get rows
+
+Then run the `SelectAllRows()` method as follows:
+
+```csharp
+public sealed class Example
+{
+ ...
+ public void SelectAllRows()
+ {
+ Kit.Execute(q =>
+ {
+ var all = q.SelectAll();
+ foreach (var i in all)
+ {
+ Console.WriteLine(i);
+ }
+ });
+ }
+ ...
+```
+
+The `SelectAllRows()` method outputs:
+
+```plaintext
+SELECT id, name FROM Actors
+Actor { Id = 1, Name = Chloë Grace Moretz }
+Actor { Id = 2, Name = Gary Carr }
+Actor { Id = 3, Name = Jack Reynor }
+```
+
+The first line is the log message that the `TransactionKit` instance prints. The `SelectAll()` method generates this statement.
+
+The next three lines are the messages that the `WriteLine(object)` method outputs within the `foreach` block.
+
+## Inner join with two or more tables
+
+Consider the following Titles table:
+
+> ### _Titles_
+>
+> | id | name |
+> | ---: | :--- |
+> | 1 | Peripheral |
+
+And the following Casts table:
+
+> ### _Casts_
+>
+> | id | titleId | actorId | role |
+> | ---: | ---: | ---: | :--- |
+> | 1 | 1 | 1 | Flynne Fisher |
+> | 2 | 1 | 2 | Wilf Netherton |
+> | 3 | 1 | 3 | Burton Fisher |
+
+The classes that correspond to these tables are:
+
+```csharp
+[Table("Titles")]
+public record class Title(
+ [Column("id")][PrimaryKey][AutoIncrement] long Id,
+ [Column("name")] string Name)
+{
+}
+
+[Table("Casts")]
+public record class Cast(
+ [Column("id")][PrimaryKey][AutoIncrement] long Id,
+ [Column("titleId")] long TitleId,
+ [Column("actorId")] long ActorId,
+ [Column("role")] string Role)
+{
+}
+```
+
+The following code creates the tables and inserts the rows:
+
+```csharp
+public sealed class Example
+{
+ ...
+ public void CreateTables()
+ {
+ Kit.Execute(q =>
+ {
+ q.NewTables(typeof(Title));
+ q.NewTables(typeof(Actor));
+ q.NewTables(typeof(Cast));
+ var titleId = q.InsertAndGetRowId(new Title(0, "Peripheral"));
+ var allCasts = new (string Name, string Role)[]
+ {
+ ("Chloë Grace Moretz", "Flynne Fisher"),
+ ("Gary Carr", "Wilf Netherton"),
+ ("Jack Reynor", "Burton Fisher"),
+ };
+ foreach (var (name, role) in allCasts)
+ {
+ var actorId = q.InsertAndGetRowId(new Actor(0, name));
+ q.Insert(new Cast(0, titleId, actorId, role));
+ }
+ });
+ }
+ ...
+```
+
+The log messages that the `CreateTables()` method prints to the console are as follows:
+
+```
+DROP TABLE IF EXISTS Titles
+CREATE TABLE Titles (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)
+DROP TABLE IF EXISTS Actors
+CREATE TABLE Actors (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)
+DROP TABLE IF EXISTS Casts
+CREATE TABLE Casts (id INTEGER PRIMARY KEY AUTOINCREMENT, titleId INTEGER, actorId INTEGER, role TEXT)
+INSERT INTO Titles (name) VALUES ($name)
+ ($name, Peripheral)
+select last_insert_rowid()
+INSERT INTO Actors (name) VALUES ($name)
+ ($name, Chloë Grace Moretz)
+select last_insert_rowid()
+INSERT INTO Casts (titleId, actorId, role) VALUES ($titleId, $actorId, $role)
+ ($titleId, 1)
+ ($role, Flynne Fisher)
+ ($actorId, 1)
+INSERT INTO Actors (name) VALUES ($name)
+ ($name, Gary Carr)
+select last_insert_rowid()
+INSERT INTO Casts (titleId, actorId, role) VALUES ($titleId, $actorId, $role)
+ ($titleId, 1)
+ ($role, Wilf Netherton)
+ ($actorId, 2)
+INSERT INTO Actors (name) VALUES ($name)
+ ($name, Jack Reynor)
+select last_insert_rowid()
+INSERT INTO Casts (titleId, actorId, role) VALUES ($titleId, $actorId, $role)
+ ($titleId, 1)
+ ($role, Burton Fisher)
+ ($actorId, 3)
+```
+
+Let's suppose that you would like to get a list of the names of the actors who performed in the specified title. To do this, use the APIs as follows:
+
+```csharp
+public sealed class Example
+{
+ ...
+ public void ListActorNames(string title)
+ {
+ Kit.Execute(q =>
+ {
+ var map = new Dictionary
+ {
+ ["$name"] = title,
+ };
+ var all = q.SelectAllFrom("a")
+ .InnerJoin("c", "a.id = c.actorId")
+ .InnerJoin("t", "t.id = c.titleId")
+ .Where("t.name = $name", map)
+ .Execute();
+ foreach (var i in all)
+ {
+ Console.WriteLine(i.Name);
+ }
+ });
+ }
+ ...
+
+```
+
+Calling `ListActorNames("Peripheral");` results in the following output:
+
+```
+SELECT a.id, a.name FROM Actors a INNER JOIN Casts c ON a.id = c.actorId INNER JOIN Titles t ON t.id = c.titleId WHERE t.name = $name
+ ($name, Peripheral)
+Chloë Grace Moretz
+Gary Carr
+Jack Reynor
+```
+
+
+
+## API Reference
+
+- [Maroontress.SqlBind][apiref-maroontress.sqlbind] namespace
+
+## How to build
+
+### Requirements for build
+
+- Visual Studio 2022 (Version 17.5)
+ or [.NET 7.0 SDK (SDK 7.0.203)][dotnet-sdk]
+
+### Build
+
+```plaintext
+git clone URL
+cd SqlBind.CSharp
+dotnet build
+```
+
+### Get the test coverage report with Coverlet
+
+Install [ReportGenerator][report-generator] as follows:
+
+```plaintext
+dotnet tool install -g dotnet-reportgenerator-globaltool
+```
+
+Run all tests and get the report in the file `Coverlet-html/index.html`:
+
+```plaintext
+rm -rf MsTestResults
+dotnet test --collect:"XPlat Code Coverage" --results-directory MsTestResults \
+ && reportgenerator -reports:MsTestResults/*/coverage.cobertura.xml \
+ -targetdir:Coverlet-html
+```
+
+[report-generator]:
+ https://github.com/danielpalme/ReportGenerator
+[dotnet-sdk]:
+ https://dotnet.microsoft.com/en-us/download
+[apiref-maroontress.sqlbind]:
+ https://maroontress.github.io/SqlBind-CSharp/api/latest/html/Maroontress.SqlBind.html
+[nuget-maroontress.sqlbind]:
+ https://www.nuget.org/packages/Maroontress.SqlBind/
+[nuget-logo]:
+ https://maroontress.github.io/images/NuGet-logo.png
diff --git a/SqlBind.Test/.editorconfig b/SqlBind.Test/.editorconfig
new file mode 100644
index 0000000..f9766ce
--- /dev/null
+++ b/SqlBind.Test/.editorconfig
@@ -0,0 +1,28 @@
+[*.cs]
+
+# SA1600: Elements should be documented
+dotnet_diagnostic.SA1600.severity = none
+
+# SA1101: Prefix local calls with this
+dotnet_diagnostic.SA1101.severity = none
+
+# SA1633: File should have header
+dotnet_diagnostic.SA1633.severity = none
+
+# SA1513: Closing brace should be followed by blank line
+dotnet_diagnostic.SA1513.severity = none
+
+# SA0001: XML comment analysis disabled
+dotnet_diagnostic.SA0001.severity = none
+
+# SA1002: Semicolons should be spaced correctly
+dotnet_diagnostic.SA1002.severity = none
+
+# SA1122: Use string.Empty for empty strings
+dotnet_diagnostic.SA1122.severity = none
+
+# SA1012: Opening braces should be spaced correctly
+dotnet_diagnostic.SA1012.severity = none
+
+# SA1013: Closing braces should be spaced correctly
+dotnet_diagnostic.SA1013.severity = none
diff --git a/SqlBind.Test/Maroontress/SqlBind/Examples/Actor.cs b/SqlBind.Test/Maroontress/SqlBind/Examples/Actor.cs
new file mode 100644
index 0000000..c877c21
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Examples/Actor.cs
@@ -0,0 +1,8 @@
+namespace Maroontress.SqlBind.Examples;
+
+[Table("Actors")]
+public record class Actor(
+ [Column("id")][PrimaryKey][AutoIncrement] long Id,
+ [Column("name")] string Name)
+{
+}
diff --git a/SqlBind.Test/Maroontress/SqlBind/Examples/Cast.cs b/SqlBind.Test/Maroontress/SqlBind/Examples/Cast.cs
new file mode 100644
index 0000000..46c6a0b
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Examples/Cast.cs
@@ -0,0 +1,10 @@
+namespace Maroontress.SqlBind.Examples;
+
+[Table("Casts")]
+public record class Cast(
+ [Column("id")][PrimaryKey][AutoIncrement] long Id,
+ [Column("titleId")] long TitleId,
+ [Column("actorId")] long ActorId,
+ [Column("role")] string Role)
+{
+}
diff --git a/SqlBind.Test/Maroontress/SqlBind/Examples/Example.cs b/SqlBind.Test/Maroontress/SqlBind/Examples/Example.cs
new file mode 100644
index 0000000..a24ac17
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Examples/Example.cs
@@ -0,0 +1,106 @@
+namespace Maroontress.SqlBind.Examples;
+
+public sealed class Example
+{
+ private TransactionKit Kit { get; } = new TransactionKit(
+ "example.db",
+ m => Console.WriteLine(m()));
+
+ public void CreateTableAndInsertRows()
+ {
+ var allActorNames = new[]
+ {
+ "Chloë Grace Moretz",
+ "Gary Carr",
+ "Jack Reynor",
+ };
+
+ Kit.Execute(q =>
+ {
+ q.NewTables(typeof(Actor));
+ foreach (var i in allActorNames.Select(n => new Actor(0, n)))
+ {
+ q.Insert(i);
+ }
+ });
+ }
+
+ public void SelectAllRows()
+ {
+ Kit.Execute(q =>
+ {
+ var all = q.SelectAll();
+ foreach (var i in all)
+ {
+ Console.WriteLine(i);
+ }
+ });
+ }
+
+ public void CreateTables()
+ {
+ Kit.Execute(q =>
+ {
+ q.NewTables(typeof(Title));
+ q.NewTables(typeof(Actor));
+ q.NewTables(typeof(Cast));
+ var titleId = q.InsertAndGetRowId(new Title(0, "Peripheral"));
+ var allCasts = new (string Name, string Role)[]
+ {
+ ("Chloë Grace Moretz", "Flynne Fisher"),
+ ("Gary Carr", "Wilf Netherton"),
+ ("Jack Reynor", "Burton Fisher"),
+ };
+ foreach (var (name, role) in allCasts)
+ {
+ var actorId = q.InsertAndGetRowId(new Actor(0, name));
+ q.Insert(new Cast(0, titleId, actorId, role));
+ }
+ });
+ }
+
+ public void ListActorNames(string title)
+ {
+ Kit.Execute(q =>
+ {
+ var map = new Dictionary
+ {
+ ["$name"] = title,
+ };
+ var all = q.SelectAllFrom("a")
+ .InnerJoin("c", "a.id = c.actorId")
+ .InnerJoin("t", "t.id = c.titleId")
+ .Where("t.name = $name", map)
+ .Execute();
+ foreach (var i in all)
+ {
+ Console.WriteLine(i.Name);
+ }
+ });
+ }
+
+ public void ListActorNamesV2(string title)
+ {
+ Kit.Execute(q =>
+ {
+ var map = new Dictionary
+ {
+ ["$name"] = title,
+ };
+ var cActorId = q.ColumnName(nameof(Cast.ActorId));
+ var cTitleId = q.ColumnName(nameof(Cast.TitleId));
+ var aId = q.ColumnName(nameof(Actor.Id));
+ var tId = q.ColumnName(nameof(Title.Id));
+ var tName = q.ColumnName(nameof(Title.Name));
+ var all = q.SelectAllFrom("a")
+ .InnerJoin("c", $"a.{aId} = c.{cActorId}")
+ .InnerJoin("t", $"t.{tId} = c.{cTitleId}")
+ .Where($"t.{tName} = $name", map)
+ .Execute();
+ foreach (var i in all)
+ {
+ Console.WriteLine(i.Name);
+ }
+ });
+ }
+}
diff --git a/SqlBind.Test/Maroontress/SqlBind/Examples/ExampleTest.cs b/SqlBind.Test/Maroontress/SqlBind/Examples/ExampleTest.cs
new file mode 100644
index 0000000..3af2e49
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Examples/ExampleTest.cs
@@ -0,0 +1,16 @@
+namespace Maroontress.SqlBind.Examples;
+
+[TestClass]
+public sealed class ExampleTest
+{
+ [TestMethod]
+ public void Run()
+ {
+ var e = new Example();
+ e.CreateTableAndInsertRows();
+ e.SelectAllRows();
+ e.CreateTables();
+ e.ListActorNames("Peripheral");
+ e.ListActorNamesV2("Peripheral");
+ }
+}
diff --git a/SqlBind.Test/Maroontress/SqlBind/Examples/Title.cs b/SqlBind.Test/Maroontress/SqlBind/Examples/Title.cs
new file mode 100644
index 0000000..2a93567
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Examples/Title.cs
@@ -0,0 +1,8 @@
+namespace Maroontress.SqlBind.Examples;
+
+[Table("Titles")]
+public record class Title(
+ [Column("id")][PrimaryKey][AutoIncrement] long Id,
+ [Column("name")] string Name)
+{
+}
diff --git a/SqlBind.Test/Maroontress/SqlBind/Impl/Test/DefaultConstructorRow.cs b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/DefaultConstructorRow.cs
new file mode 100644
index 0000000..4c2581a
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/DefaultConstructorRow.cs
@@ -0,0 +1,9 @@
+namespace Maroontress.SqlBind.Impl.Test;
+
+[Table("foo")]
+public sealed class DefaultConstructorRow
+{
+ public DefaultConstructorRow()
+ {
+ }
+}
diff --git a/SqlBind.Test/Maroontress/SqlBind/Impl/Test/DuplicatedColumnNamesRow.cs b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/DuplicatedColumnNamesRow.cs
new file mode 100644
index 0000000..59ff91a
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/DuplicatedColumnNamesRow.cs
@@ -0,0 +1,8 @@
+namespace Maroontress.SqlBind.Impl.Test;
+
+[Table("duplicatedColumnNames")]
+public record DuplicatedColumnNamesRow(
+ [Column("name")] string Name,
+ [Column("name")] string Country)
+{
+}
diff --git a/SqlBind.Test/Maroontress/SqlBind/Impl/Test/EmptyIndexedColumnsRow.cs b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/EmptyIndexedColumnsRow.cs
new file mode 100644
index 0000000..37f4706
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/EmptyIndexedColumnsRow.cs
@@ -0,0 +1,9 @@
+namespace Maroontress.SqlBind.Impl.Test;
+
+[Table("foo")]
+[IndexedColumns]
+public record EmptyIndexedColumnsRow(
+ [Column("id")] long Id,
+ [Column("value")] string Value)
+{
+}
diff --git a/SqlBind.Test/Maroontress/SqlBind/Impl/Test/FieldTest.cs b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/FieldTest.cs
new file mode 100644
index 0000000..b160e01
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/FieldTest.cs
@@ -0,0 +1,23 @@
+namespace Maroontress.SqlBind.Impl.Test;
+
+using Maroontress.SqlBind.Test;
+
+[TestClass]
+public sealed class FieldTest
+{
+ [TestMethod]
+ public void New_TypeMismatchRow()
+ {
+ var t = typeof(CoffeeRow);
+ var ctor = t.GetConstructor(new[]
+ {
+ typeof(string),
+ typeof(string),
+ });
+ var all = ctor!.GetParameters();
+ var country = all[1];
+
+ Assert.ThrowsException(
+ () => _ = new Field(country));
+ }
+}
diff --git a/SqlBind.Test/Maroontress/SqlBind/Impl/Test/IntParameterRow.cs b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/IntParameterRow.cs
new file mode 100644
index 0000000..b6c379e
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/IntParameterRow.cs
@@ -0,0 +1,6 @@
+namespace Maroontress.SqlBind.Impl.Test;
+
+[Table("foo")]
+public record IntParameterRow([Column("id")] int Id)
+{
+}
diff --git a/SqlBind.Test/Maroontress/SqlBind/Impl/Test/MetadataTest.cs b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/MetadataTest.cs
new file mode 100644
index 0000000..bbbb24c
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/MetadataTest.cs
@@ -0,0 +1,126 @@
+namespace Maroontress.SqlBind.Impl.Test;
+
+using Maroontress.SqlBind.Test;
+
+[TestClass]
+public sealed class MetadataTest
+{
+ [TestMethod]
+ public void New_DuplicatedColumnNamesRow()
+ {
+ NewThrowArgumentException();
+ }
+
+ [TestMethod]
+ public void New_ParameterAndPropertyNameMismatchRow()
+ {
+ NewThrowArgumentException();
+ }
+
+ [TestMethod]
+ public void New_IntParameterRow()
+ {
+ NewThrowArgumentException();
+ }
+
+ [TestMethod]
+ public void New_EmptyIndexedColumnsRow()
+ {
+ NewThrowArgumentException();
+ }
+
+ [TestMethod]
+ public void New_TwoOrMoreConstructorRow()
+ {
+ NewThrowArgumentException();
+ }
+
+ [TestMethod]
+ public void New_PrimaryAndNonPublicConstructorsRow()
+ {
+ var m = new Metadata();
+ CheckFooTable(m);
+ }
+
+ [TestMethod]
+ public void New_PrimaryAndIgnoredConstructorsRow()
+ {
+ var m = new Metadata();
+ CheckFooTable(m);
+ }
+
+ [TestMethod]
+ public void New_NoPublicConstructorRow()
+ {
+ NewThrowArgumentException();
+ }
+
+ [TestMethod]
+ public void New_DefaultConstructorRow()
+ {
+ NewThrowArgumentException();
+ }
+
+ [TestMethod]
+ public void New_ParameterMissingColumnAttributeRow()
+ {
+ NewThrowArgumentException();
+ }
+
+ [TestMethod]
+ public void New_NoTableAttributeRow()
+ {
+ NewThrowArgumentException();
+ }
+
+ [TestMethod]
+ public void NewSelectStatement()
+ {
+ var m = new Metadata();
+ Assert.ThrowsException(
+ () => _ = m.NewSelectStatement("price"));
+ }
+
+ [TestMethod]
+ public void NewInsertParameterMap_NullValue()
+ {
+ var row = new NullablePropertyRow(1, null);
+ var m = new Metadata();
+ Assert.ThrowsException(
+ () => _ = m.NewInsertParameterMap(row));
+ }
+
+ [TestMethod]
+ public void ToColumnName_ParameterNameNotFound()
+ {
+ var m = new Metadata();
+ Assert.ThrowsException(
+ () => _ = m.ToColumnName("Price"));
+ }
+
+ [TestMethod]
+ public void ToColumnName()
+ {
+ var m = new Metadata();
+ Assert.AreEqual("name", m.ToColumnName(nameof(CoffeeRow.Name)));
+ Assert.AreEqual("country", m.ToColumnName(nameof(CoffeeRow.Country)));
+ }
+
+ private static void NewThrowArgumentException()
+ where T : notnull
+ {
+ Assert.ThrowsException(() => _ = new Metadata());
+ }
+
+ private static void CheckFooTable(Metadata m)
+ where T : notnull
+ {
+ Assert.AreEqual("foo", m.TableName);
+ var allFields = m.Fields.ToArray();
+ Assert.AreEqual(2, allFields.Length);
+ Assert.AreEqual("id", allFields[0].ColumnName);
+ Assert.AreEqual("Id", allFields[0].ParameterName);
+ Assert.AreEqual("value", allFields[1].ColumnName);
+ Assert.AreEqual("Value", allFields[1].ParameterName);
+ }
+}
diff --git a/SqlBind.Test/Maroontress/SqlBind/Impl/Test/NoPublicConstructorRow.cs b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/NoPublicConstructorRow.cs
new file mode 100644
index 0000000..2fadbee
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/NoPublicConstructorRow.cs
@@ -0,0 +1,9 @@
+namespace Maroontress.SqlBind.Impl.Test;
+
+[Table("foo")]
+public sealed class NoPublicConstructorRow
+{
+ private NoPublicConstructorRow()
+ {
+ }
+}
diff --git a/SqlBind.Test/Maroontress/SqlBind/Impl/Test/NoTableAttributeRow.cs b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/NoTableAttributeRow.cs
new file mode 100644
index 0000000..ec4fc52
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/NoTableAttributeRow.cs
@@ -0,0 +1,7 @@
+namespace Maroontress.SqlBind.Impl.Test;
+
+public record class NoTableAttributeRow(
+ [Column("id")] long Id,
+ [Column("value")] string Value)
+{
+}
diff --git a/SqlBind.Test/Maroontress/SqlBind/Impl/Test/NullablePropertyRow.cs b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/NullablePropertyRow.cs
new file mode 100644
index 0000000..2901a4b
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/NullablePropertyRow.cs
@@ -0,0 +1,8 @@
+namespace Maroontress.SqlBind.Impl.Test;
+
+[Table("foo")]
+public record class NullablePropertyRow(
+ [Column("id")] long Id,
+ [Column("value")] string? Value)
+{
+}
diff --git a/SqlBind.Test/Maroontress/SqlBind/Impl/Test/ParameterAndPropertyNameMismatchRow.cs b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/ParameterAndPropertyNameMismatchRow.cs
new file mode 100644
index 0000000..3eb1560
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/ParameterAndPropertyNameMismatchRow.cs
@@ -0,0 +1,17 @@
+namespace Maroontress.SqlBind.Impl.Test;
+
+[Table("foo")]
+public sealed class ParameterAndPropertyNameMismatchRow
+{
+ public ParameterAndPropertyNameMismatchRow(
+ [Column("one")] string one,
+ [Column("two")] string two)
+ {
+ One = one;
+ Two = two;
+ }
+
+ public string One { get; }
+
+ public string Two { get; }
+}
diff --git a/SqlBind.Test/Maroontress/SqlBind/Impl/Test/ParameterMissingColumnAttributeRow.cs b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/ParameterMissingColumnAttributeRow.cs
new file mode 100644
index 0000000..92c0fbf
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/ParameterMissingColumnAttributeRow.cs
@@ -0,0 +1,8 @@
+namespace Maroontress.SqlBind.Impl.Test;
+
+[Table("foo")]
+public record class ParameterMissingColumnAttributeRow(
+ long Id,
+ string Value)
+{
+}
diff --git a/SqlBind.Test/Maroontress/SqlBind/Impl/Test/PrimaryAndIgnoredConstructorsRow.cs b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/PrimaryAndIgnoredConstructorsRow.cs
new file mode 100644
index 0000000..4cca88e
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/PrimaryAndIgnoredConstructorsRow.cs
@@ -0,0 +1,13 @@
+namespace Maroontress.SqlBind.Impl.Test;
+
+[Table("foo")]
+public record PrimaryAndIgnoredConstructorsRow(
+ [Column("id")] long Id,
+ [Column("value")] string Value)
+{
+ [Ignored]
+ public PrimaryAndIgnoredConstructorsRow()
+ : this(0, "(default)")
+ {
+ }
+}
diff --git a/SqlBind.Test/Maroontress/SqlBind/Impl/Test/PrimaryAndNonPublicConstructorsRow.cs b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/PrimaryAndNonPublicConstructorsRow.cs
new file mode 100644
index 0000000..290fd29
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/PrimaryAndNonPublicConstructorsRow.cs
@@ -0,0 +1,17 @@
+namespace Maroontress.SqlBind.Impl.Test;
+
+[Table("foo")]
+public record PrimaryAndNonPublicConstructorsRow(
+ [Column("id")] long Id,
+ [Column("value")] string Value)
+{
+ private PrimaryAndNonPublicConstructorsRow()
+ : this(0, "(default)")
+ {
+ }
+
+ public static PrimaryAndNonPublicConstructorsRow Of()
+ {
+ return new PrimaryAndNonPublicConstructorsRow();
+ }
+}
diff --git a/SqlBind.Test/Maroontress/SqlBind/Impl/Test/PropertyReturningNullRow.cs b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/PropertyReturningNullRow.cs
new file mode 100644
index 0000000..3345c36
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/PropertyReturningNullRow.cs
@@ -0,0 +1,20 @@
+#pragma warning disable SA1313
+
+namespace Maroontress.SqlBind.Impl.Test;
+
+[Table("foo")]
+public sealed class PropertyReturningNullRow
+{
+ public PropertyReturningNullRow(
+ [Column("id")] long Id,
+ [Column("value")] string Value)
+ {
+ _ = Value;
+ this.Id = Id;
+ this.Value = null;
+ }
+
+ public long Id { get; }
+
+ public string? Value { get; }
+}
diff --git a/SqlBind.Test/Maroontress/SqlBind/Impl/Test/TwoOrMoreConstructorRow.cs b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/TwoOrMoreConstructorRow.cs
new file mode 100644
index 0000000..d8346b9
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Impl/Test/TwoOrMoreConstructorRow.cs
@@ -0,0 +1,12 @@
+namespace Maroontress.SqlBind.Impl.Test;
+
+[Table("foo")]
+public record TwoOrMoreConstructorRow(
+ [Column("id")] long Id,
+ [Column("value")] string Value)
+{
+ public TwoOrMoreConstructorRow()
+ : this(0, "(default)")
+ {
+ }
+}
diff --git a/SqlBind.Test/Maroontress/SqlBind/Test/CoffeeRow.cs b/SqlBind.Test/Maroontress/SqlBind/Test/CoffeeRow.cs
new file mode 100644
index 0000000..155ab97
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Test/CoffeeRow.cs
@@ -0,0 +1,8 @@
+namespace Maroontress.SqlBind.Test;
+
+[Table("coffees")]
+public record CoffeeRow(
+ [Column("name")] string Name,
+ [Column("country")] string Country)
+{
+}
diff --git a/SqlBind.Test/Maroontress/SqlBind/Test/DecoyCommittable.cs b/SqlBind.Test/Maroontress/SqlBind/Test/DecoyCommittable.cs
new file mode 100644
index 0000000..5f7c9d9
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Test/DecoyCommittable.cs
@@ -0,0 +1,28 @@
+namespace Maroontress.SqlBind.Test;
+
+using Maroontress.SqlBind.Impl;
+
+public sealed class DecoyCommittable : Committable
+{
+ public DecoyCommittable(List trace)
+ {
+ Trace = trace;
+ }
+
+ private List Trace { get; }
+
+ public void Commit()
+ {
+ Trace.Add($"{nameof(Committable)}#{nameof(Commit)}");
+ }
+
+ public void Dispose()
+ {
+ Trace.Add($"{nameof(Committable)}#{nameof(Dispose)}");
+ }
+
+ public void Rollback()
+ {
+ Trace.Add($"{nameof(Committable)}#{nameof(Rollback)}");
+ }
+}
diff --git a/SqlBind.Test/Maroontress/SqlBind/Test/DecoyDatabaseLink.cs b/SqlBind.Test/Maroontress/SqlBind/Test/DecoyDatabaseLink.cs
new file mode 100644
index 0000000..6b5fb53
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Test/DecoyDatabaseLink.cs
@@ -0,0 +1,32 @@
+namespace Maroontress.SqlBind.Test;
+
+using System;
+using Maroontress.SqlBind.Impl;
+using StyleChecker.Annotations;
+
+public sealed class DecoyDatabaseLink : DatabaseLink
+{
+ public DecoyDatabaseLink(List trace)
+ {
+ Trace = trace;
+ }
+
+ private List Trace { get; }
+
+ public Committable BeginTransaction()
+ {
+ Trace.Add($"{nameof(DatabaseLink)}#{nameof(BeginTransaction)}");
+ return new DecoyCommittable(Trace);
+ }
+
+ public void Dispose()
+ {
+ Trace.Add($"{nameof(DatabaseLink)}#{nameof(Dispose)}");
+ }
+
+ public Siphon NewSiphon([Unused] Action> logger)
+ {
+ Trace.Add($"{nameof(DatabaseLink)}#{nameof(NewSiphon)}");
+ return new DecoySiphon();
+ }
+}
diff --git a/SqlBind.Test/Maroontress/SqlBind/Test/DecoySiphon.cs b/SqlBind.Test/Maroontress/SqlBind/Test/DecoySiphon.cs
new file mode 100644
index 0000000..e18ca52
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Test/DecoySiphon.cs
@@ -0,0 +1,29 @@
+namespace Maroontress.SqlBind.Test;
+
+using System.Collections.Generic;
+using Maroontress.SqlBind.Impl;
+using StyleChecker.Annotations;
+
+public sealed class DecoySiphon : Siphon
+{
+ public long ExecuteLong(
+ [Unused] string text,
+ [Unused] IReadOnlyDictionary? parameters = null)
+ {
+ throw new NotImplementedException();
+ }
+
+ public void ExecuteNonQuery(
+ [Unused] string text,
+ [Unused] IReadOnlyDictionary? parameters = null)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Reservoir ExecuteReader(
+ [Unused] string text,
+ [Unused] IReadOnlyDictionary? parameters = null)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/SqlBind.Test/Maroontress/SqlBind/Test/DecoyToolkit.cs b/SqlBind.Test/Maroontress/SqlBind/Test/DecoyToolkit.cs
new file mode 100644
index 0000000..8b8767b
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Test/DecoyToolkit.cs
@@ -0,0 +1,18 @@
+namespace Maroontress.SqlBind.Test;
+
+using Maroontress.SqlBind.Impl;
+
+public sealed class DecoyToolkit : Toolkit
+{
+ public DecoyToolkit(Func toLink)
+ {
+ ToLink = toLink;
+ }
+
+ private Func ToLink { get; }
+
+ public DatabaseLink NewDatabaseLink(string databasePath)
+ {
+ return ToLink(databasePath);
+ }
+}
\ No newline at end of file
diff --git a/SqlBind.Test/Maroontress/SqlBind/Test/PersonRow.cs b/SqlBind.Test/Maroontress/SqlBind/Test/PersonRow.cs
new file mode 100644
index 0000000..bf3017d
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Test/PersonRow.cs
@@ -0,0 +1,11 @@
+namespace Maroontress.SqlBind.Test;
+
+[Table("persons")]
+[IndexedColumns("firstNameId")]
+[IndexedColumns("lastNameId")]
+public record PersonRow(
+ [Column("id")][PrimaryKey][AutoIncrement] long Id,
+ [Column("firstNameId")] long FirstNameId,
+ [Column("lastNameId")] long LastNameId)
+{
+}
diff --git a/SqlBind.Test/Maroontress/SqlBind/Test/QueryTest.cs b/SqlBind.Test/Maroontress/SqlBind/Test/QueryTest.cs
new file mode 100644
index 0000000..0dfc860
--- /dev/null
+++ b/SqlBind.Test/Maroontress/SqlBind/Test/QueryTest.cs
@@ -0,0 +1,493 @@
+namespace Maroontress.SqlBind.Test;
+
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using Maroontress.SqlBind.Impl;
+
+[TestClass]
+public sealed class QueryTest
+{
+ private static readonly CoffeeRow Bolivia
+ = new("SOL DE LA MA\x00d1ANA", "BOLIVIA");
+
+ private static readonly CoffeeRow Zambia = new("ISANYA ESTATE", "ZAMBIA");
+
+ [TestMethod]
+ public void NewTablesWithoutIndex()
+ {
+ var cache = new MetadataBank();
+ var siphon = new TestSiphon();
+ var q = new QueryImpl(siphon, cache);
+ q.NewTables(new[] { typeof(CoffeeRow) });
+ var result = siphon.StatementList;
+ var resultTexts = result.Select(i => i.Text).ToArray();
+ var expectedTexts = new[]
+ {
+ "DROP TABLE IF EXISTS coffees",
+ "CREATE TABLE coffees (name TEXT, country TEXT)",
+ };
+ CollectionAssert.AreEqual(expectedTexts, resultTexts);
+ Assert.IsTrue(result.All(i => i.Parameters is null));
+ }
+
+ [TestMethod]
+ public void NewTablesWithIndices()
+ {
+ var cache = new MetadataBank();
+ var siphon = new TestSiphon();
+ var q = new QueryImpl(siphon, cache);
+ q.NewTables(new[] { typeof(PersonRow) });
+ var result = siphon.StatementList;
+ var resultTexts = result.Select(i => i.Text).ToArray();
+ var expectedTexts = new[]
+ {
+ "DROP TABLE IF EXISTS persons",
+ "DROP INDEX IF EXISTS persons_Index_firstNameId",
+ "DROP INDEX IF EXISTS persons_Index_lastNameId",
+ "CREATE TABLE persons (id INTEGER PRIMARY KEY AUTOINCREMENT, "
+ + "firstNameId INTEGER, lastNameId INTEGER)",
+ "CREATE INDEX persons_Index_firstNameId on persons (firstNameId)",
+ "CREATE INDEX persons_Index_lastNameId on persons (lastNameId)",
+ };
+ CollectionAssert.AreEqual(expectedTexts, resultTexts);
+ Assert.IsTrue(result.All(i => i.Parameters is null));
+ }
+
+ [TestMethod]
+ public void Insert()
+ {
+ var cache = new MetadataBank();
+ var siphon = new TestSiphon(Array.Empty());
+ var q = new QueryImpl(siphon, cache);
+ q.Insert(Bolivia);
+ var result = siphon.StatementList;
+ var resultTexts = result.Select(i => i.Text).ToArray();
+ var expectedTexts = new[]
+ {
+ "INSERT INTO coffees (name, country) VALUES ($name, $country)",
+ };
+ CollectionAssert.AreEqual(expectedTexts, resultTexts);
+ var p = result[0].Parameters;
+ Assert.IsNotNull(p);
+ Assert.AreEqual(p["$name"], Bolivia.Name);
+ Assert.AreEqual(p["$country"], Bolivia.Country);
+ }
+
+ [TestMethod]
+ public void SelectAll()
+ {
+ var cache = new MetadataBank();
+ var reservoir = new TestReservoir(new[]
+ {
+ Bolivia,
+ Zambia,
+ });
+ var siphon = new TestSiphon(new[] { reservoir });
+ var q = new QueryImpl(siphon, cache);
+ var all = q.SelectAll().ToArray();
+ var result = siphon.StatementList;
+ var resultTexts = result.Select(i => i.Text).ToArray();
+ var expectedTexts = new[]
+ {
+ "SELECT name, country FROM coffees",
+ };
+ CollectionAssert.AreEqual(expectedTexts, resultTexts);
+ var p = result[0].Parameters;
+ Assert.IsNull(p);
+ Assert.AreEqual(2, all.Length);
+ Assert.AreSame(Bolivia, all[0]);
+ Assert.AreSame(Zambia, all[1]);
+ }
+
+ [TestMethod]
+ public void SelectAllFrom_Where_Execute()
+ {
+ var cache = new MetadataBank();
+ var reservoir = new TestReservoir(new[]
+ {
+ Zambia,
+ });
+ var siphon = new TestSiphon(new[] { reservoir });
+ var q = new QueryImpl(siphon, cache);
+ var parameters = ImmutableDictionary.CreateRange(
+ ImmutableArray.Create(
+ KeyValuePair.Create("$country", (object)Zambia.Name)));
+ var all = q.SelectAllFrom("c")
+ .Where("c.country = $country", parameters)
+ .Execute()
+ .ToArray();
+ var result = siphon.StatementList;
+ var resultTexts = result.Select(i => i.Text).ToArray();
+ var expectedTexts = new[]
+ {
+ "SELECT c.name, c.country FROM coffees c "
+ + "WHERE c.country = $country",
+ };
+ CollectionAssert.AreEqual(expectedTexts, resultTexts);
+ var p = result[0].Parameters;
+ Assert.IsNotNull(p);
+ Assert.AreEqual(1, all.Length);
+ Assert.AreSame(Zambia, all[0]);
+ }
+
+ [TestMethod]
+ public void SelectAllFrom_Where_OrderBy()
+ {
+ var cache = new MetadataBank();
+ var reservoir = new TestReservoir(new[]
+ {
+ Zambia,
+ });
+ var siphon = new TestSiphon(new[] { reservoir });
+ var q = new QueryImpl(siphon, cache);
+ var parameters = ImmutableDictionary.CreateRange(
+ ImmutableArray.Create(
+ KeyValuePair.Create("$country", (object)Zambia.Name)));
+ var all = q.SelectAllFrom("c")
+ .Where("c.country = $country", parameters)
+ .OrderBy("c.country")
+ .ToArray();
+ var result = siphon.StatementList;
+ var resultTexts = result.Select(i => i.Text).ToArray();
+ var expectedTexts = new[]
+ {
+ "SELECT c.name, c.country FROM coffees c "
+ + "WHERE c.country = $country "
+ + "ORDER BY c.country",
+ };
+ CollectionAssert.AreEqual(expectedTexts, resultTexts);
+ var p = result[0].Parameters;
+ Assert.IsNotNull(p);
+ Assert.AreEqual(1, all.Length);
+ Assert.AreSame(Zambia, all[0]);
+ }
+
+ [TestMethod]
+ public void SelectAllFrom_Execute()
+ {
+ var cache = new MetadataBank();
+ var reservoir = new TestReservoir(new[]
+ {
+ Zambia,
+ Bolivia,
+ });
+ var siphon = new TestSiphon(new[] { reservoir });
+ var q = new QueryImpl(siphon, cache);
+ var all = q.SelectAllFrom("c")
+ .Execute()
+ .ToArray();
+ var result = siphon.StatementList;
+ var resultTexts = result.Select(i => i.Text).ToArray();
+ var expectedTexts = new[]
+ {
+ "SELECT c.name, c.country FROM coffees c",
+ };
+ CollectionAssert.AreEqual(expectedTexts, resultTexts);
+ var p = result[0].Parameters;
+ Assert.IsNull(p);
+ Assert.AreEqual(2, all.Length);
+ Assert.AreSame(Zambia, all[0]);
+ Assert.AreSame(Bolivia, all[1]);
+ }
+
+ [TestMethod]
+ public void SelectAllFrom_OrderBy()
+ {
+ var cache = new MetadataBank();
+ var reservoir = new TestReservoir(new[]
+ {
+ Zambia,
+ Bolivia,
+ });
+ var siphon = new TestSiphon(new[] { reservoir });
+ var q = new QueryImpl(siphon, cache);
+ var all = q.SelectAllFrom("c")
+ .OrderBy("c.name")
+ .ToArray();
+ var result = siphon.StatementList;
+ var resultTexts = result.Select(i => i.Text).ToArray();
+ var expectedTexts = new[]
+ {
+ "SELECT c.name, c.country FROM coffees c ORDER BY c.name",
+ };
+ CollectionAssert.AreEqual(expectedTexts, resultTexts);
+ var p = result[0].Parameters;
+ Assert.IsNull(p);
+ Assert.AreEqual(2, all.Length);
+ Assert.AreSame(Zambia, all[0]);
+ Assert.AreSame(Bolivia, all[1]);
+ }
+
+ [TestMethod]
+ public void SelectAllFrom_InnerJoin_Where()
+ {
+ var personRow = new PersonRow(1, 2, 3);
+ var cache = new MetadataBank();
+ var reservoir = new TestReservoir(new[]
+ {
+ personRow,
+ });
+ var siphon = new TestSiphon(new[] { reservoir });
+ var q = new QueryImpl(siphon, cache);
+ var parameters = ImmutableDictionary.CreateRange(
+ ImmutableArray.Create(
+ KeyValuePair.Create("$firstName", (object)"Yuri")));
+ var all = q.SelectAllFrom("p")
+ .InnerJoin("s", "s.id = p.firstNameId")
+ .Where("s.value = $firstName", parameters)
+ .Execute()
+ .ToArray();
+ var result = siphon.StatementList;
+ var resultTexts = result.Select(i => i.Text).ToArray();
+ var expectedTexts = new[]
+ {
+ "SELECT p.id, p.firstNameId, p.lastNameId FROM persons p "
+ + "INNER JOIN strings s ON s.id = p.firstNameId "
+ + "WHERE s.value = $firstName",
+ };
+ CollectionAssert.AreEqual(expectedTexts, resultTexts);
+ var p = result[0].Parameters;
+ Assert.IsNotNull(p);
+ Assert.AreSame("Yuri", p["$firstName"]);
+ Assert.AreEqual(1, all.Length);
+ Assert.AreSame(personRow, all[0]);
+ }
+
+ [TestMethod]
+ public void Select()
+ {
+ var cache = new MetadataBank();
+ var reservoir = new TestReservoir(
+ new[]
+ {
+ Zambia,
+ Bolivia,
+ }.Select(i => new StringView(i.Name)));
+ var siphon = new TestSiphon(new[] { reservoir });
+ var q = new QueryImpl(siphon, cache);
+ var all = q.Select("c.country")
+ .From("c")
+ .OrderBy("c.name")
+ .ToArray();
+ var result = siphon.StatementList;
+ var resultTexts = result.Select(i => i.Text).ToArray();
+ var expectedTexts = new[]
+ {
+ "SELECT c.country FROM coffees c ORDER BY c.name",
+ };
+ CollectionAssert.AreEqual(expectedTexts, resultTexts);
+ var p = result[0].Parameters;
+ Assert.IsNull(p);
+ Assert.AreEqual(2, all.Length);
+ Assert.AreEqual(all[0].Value, Zambia.Name);
+ Assert.AreEqual(all[1].Value, Bolivia.Name);
+ }
+
+ [TestMethod]
+ public void DeleteFrom()
+ {
+ var cache = new MetadataBank();
+ var siphon = new TestSiphon();
+ var parameters = ImmutableDictionary.CreateRange(
+ ImmutableArray.Create(
+ KeyValuePair.Create("$country", (object)Bolivia.Name)));
+ var q = new QueryImpl(siphon, cache);
+ q.DeleteFrom()
+ .Where("country = $country", parameters);
+ var result = siphon.StatementList;
+ var resultTexts = result.Select(i => i.Text).ToArray();
+ var expectedTexts = new[]
+ {
+ "DELETE FROM coffees WHERE country = $country",
+ };
+ CollectionAssert.AreEqual(expectedTexts, resultTexts);
+ var p = result[0].Parameters;
+ Assert.IsNotNull(p);
+ Assert.AreEqual(p["$country"], Bolivia.Name);
+ }
+
+ [TestMethod]
+ public void InsertAndGetRowId()
+ {
+ var cache = new MetadataBank();
+ var siphon = new TestSiphon(Array.Empty());
+ var q = new QueryImpl(siphon, cache);
+ var row = new PersonRow(0, 1, 2);
+ var id = q.InsertAndGetRowId(row);
+ var result = siphon.StatementList;
+ var resultTexts = result.Select(i => i.Text).ToArray();
+ var expectedTexts = new[]
+ {
+ "INSERT INTO persons (firstNameId, lastNameId) VALUES "
+ + "($firstNameId, $lastNameId)",
+ "select last_insert_rowid()",
+ };
+ Assert.AreEqual(id, 1);
+ CollectionAssert.AreEqual(expectedTexts, resultTexts);
+ var p = result[0].Parameters;
+ Assert.IsNotNull(p);
+ Assert.AreEqual(1L, p["$firstNameId"]);
+ Assert.AreEqual(2L, p["$lastNameId"]);
+ }
+
+ [TestMethod]
+ public void SelectUnique()
+ {
+ var cache = new MetadataBank();
+ var reservoir = new TestReservoir(
+ new[]
+ {
+ Zambia,
+ });
+ var siphon = new TestSiphon(new[] { reservoir });
+ var q = new QueryImpl(siphon, cache);
+ var maybeRow = q.SelectUnique("name", Zambia.Name);
+ var result = siphon.StatementList;
+ var resultTexts = result.Select(i => i.Text).ToArray();
+ var expectedTexts = new[]
+ {
+ "SELECT name, country FROM coffees WHERE name = $name",
+ };
+ Assert.IsNotNull(maybeRow);
+ CollectionAssert.AreEqual(expectedTexts, resultTexts);
+ var p = result[0].Parameters;
+ Assert.IsNotNull(p);
+ Assert.AreEqual(p["$name"], Zambia.Name);
+ Assert.AreEqual(Zambia, maybeRow);
+ }
+
+ [TestMethod]
+ public void SelectUniqueButNotFound()
+ {
+ var cache = new MetadataBank();
+ var reservoir = new TestReservoir();
+ var siphon = new TestSiphon(new[] { reservoir });
+ var q = new QueryImpl(siphon, cache);
+ var maybeRow = q.SelectUnique("name", Zambia.Name);
+ var result = siphon.StatementList;
+ var resultTexts = result.Select(i => i.Text).ToArray();
+ var expectedTexts = new[]
+ {
+ "SELECT name, country FROM coffees WHERE name = $name",
+ };
+ Assert.IsNull(maybeRow);
+ CollectionAssert.AreEqual(expectedTexts, resultTexts);
+ var p = result[0].Parameters;
+ Assert.IsNotNull(p);
+ Assert.AreEqual(p["$name"], Zambia.Name);
+ }
+
+ public sealed class TestReservoir : Reservoir
+ {
+ public TestReservoir()
+ : this(Array.Empty