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 +``` + +<!-- +## Get started + +SqlBind.CSharp is available as +[the ![NuGet-logo][nuget-logo] NuGet package][nuget-maroontress.sqlbind]. +--> + +## 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<Actor>(); + 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<string, object> + { + ["$name"] = title, + }; + var all = q.SelectAllFrom<Actor>("a") + .InnerJoin<Cast>("c", "a.id = c.actorId") + .InnerJoin<Title>("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<string, object> + { + ["$name"] = title, + }; + var cActorId = q.ColumnName<Cast>(nameof(Cast.ActorId)); + var cTitleId = q.ColumnName<Cast>(nameof(Cast.TitleId)); + var aId = q.ColumnName<Actor>(nameof(Actor.Id)); + var tId = q.ColumnName<Title>(nameof(Title.Id)); + var tName = q.ColumnName<Title>(nameof(Title.Name)); + var all = q.SelectAllFrom<Actor>("a") + .InnerJoin<Cast>("c", $"a.{aId} = c.{cActorId}") + .InnerJoin<Title>("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<ArgumentException>( + () => _ = new Field<PersonRow>(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<DuplicatedColumnNamesRow>(); + } + + [TestMethod] + public void New_ParameterAndPropertyNameMismatchRow() + { + NewThrowArgumentException<ParameterAndPropertyNameMismatchRow>(); + } + + [TestMethod] + public void New_IntParameterRow() + { + NewThrowArgumentException<IntParameterRow>(); + } + + [TestMethod] + public void New_EmptyIndexedColumnsRow() + { + NewThrowArgumentException<EmptyIndexedColumnsRow>(); + } + + [TestMethod] + public void New_TwoOrMoreConstructorRow() + { + NewThrowArgumentException<TwoOrMoreConstructorRow>(); + } + + [TestMethod] + public void New_PrimaryAndNonPublicConstructorsRow() + { + var m = new Metadata<PrimaryAndNonPublicConstructorsRow>(); + CheckFooTable(m); + } + + [TestMethod] + public void New_PrimaryAndIgnoredConstructorsRow() + { + var m = new Metadata<PrimaryAndIgnoredConstructorsRow>(); + CheckFooTable(m); + } + + [TestMethod] + public void New_NoPublicConstructorRow() + { + NewThrowArgumentException<NoPublicConstructorRow>(); + } + + [TestMethod] + public void New_DefaultConstructorRow() + { + NewThrowArgumentException<DefaultConstructorRow>(); + } + + [TestMethod] + public void New_ParameterMissingColumnAttributeRow() + { + NewThrowArgumentException<ParameterMissingColumnAttributeRow>(); + } + + [TestMethod] + public void New_NoTableAttributeRow() + { + NewThrowArgumentException<NoTableAttributeRow>(); + } + + [TestMethod] + public void NewSelectStatement() + { + var m = new Metadata<CoffeeRow>(); + Assert.ThrowsException<ArgumentException>( + () => _ = m.NewSelectStatement("price")); + } + + [TestMethod] + public void NewInsertParameterMap_NullValue() + { + var row = new NullablePropertyRow(1, null); + var m = new Metadata<NullablePropertyRow>(); + Assert.ThrowsException<ArgumentException>( + () => _ = m.NewInsertParameterMap(row)); + } + + [TestMethod] + public void ToColumnName_ParameterNameNotFound() + { + var m = new Metadata<CoffeeRow>(); + Assert.ThrowsException<ArgumentException>( + () => _ = m.ToColumnName("Price")); + } + + [TestMethod] + public void ToColumnName() + { + var m = new Metadata<CoffeeRow>(); + Assert.AreEqual("name", m.ToColumnName(nameof(CoffeeRow.Name))); + Assert.AreEqual("country", m.ToColumnName(nameof(CoffeeRow.Country))); + } + + private static void NewThrowArgumentException<T>() + where T : notnull + { + Assert.ThrowsException<ArgumentException>(() => _ = new Metadata<T>()); + } + + private static void CheckFooTable<T>(Metadata<T> 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<string> trace) + { + Trace = trace; + } + + private List<string> 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<string> trace) + { + Trace = trace; + } + + private List<string> 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<Func<string>> 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<string, object>? parameters = null) + { + throw new NotImplementedException(); + } + + public void ExecuteNonQuery( + [Unused] string text, + [Unused] IReadOnlyDictionary<string, object>? parameters = null) + { + throw new NotImplementedException(); + } + + public Reservoir ExecuteReader( + [Unused] string text, + [Unused] IReadOnlyDictionary<string, object>? 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<string, DatabaseLink> toLink) + { + ToLink = toLink; + } + + private Func<string, DatabaseLink> 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<Reservoir>()); + 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<CoffeeRow>().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<CoffeeRow>("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<CoffeeRow>("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<CoffeeRow>("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<CoffeeRow>("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<PersonRow>("p") + .InnerJoin<StringRow>("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<StringView>("c.country") + .From<CoffeeRow>("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<CoffeeRow>() + .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<Reservoir>()); + 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<CoffeeRow>("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<CoffeeRow>("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<object>()) + { + } + + public TestReservoir(IEnumerable<object> list) + { + InstanceQueue = new Queue<object>(list); + Current = null; + } + + private Queue<object> InstanceQueue { get; } + + private object? Current { get; set; } + + public void Dispose() + { + } + + public T NewInstance<T>() + { + if (Current is null) + { + throw new NullReferenceException(); + } + if (Current is not T) + { + throw new InvalidOperationException(); + } + return (T)Current; + } + + public IEnumerable<T> NewInstances<T>() + { + while (Read()) + { + yield return NewInstance<T>(); + } + } + + public bool Read() + { + if (!InstanceQueue.TryDequeue(out var instance)) + { + return false; + } + Current = instance; + return true; + } + } + + public sealed class TestSiphon : Siphon + { + private long scalar = 0; + + public TestSiphon() + : this(Array.Empty<Reservoir>()) + { + } + + public TestSiphon(IEnumerable<Reservoir> reservoirs) + { + Reservoirs = reservoirs.GetEnumerator(); + } + + public List<Statement> StatementList { get; } = new(); + + private IEnumerator<Reservoir> Reservoirs { get; } + + public long ExecuteLong( + string text, + IReadOnlyDictionary<string, object>? parameters = null) + { + StatementList.Add(new Statement(text, parameters)); + return ++scalar; + } + + public void ExecuteNonQuery( + string text, + IReadOnlyDictionary<string, object>? parameters = null) + { + StatementList.Add(new Statement(text, parameters)); + } + + public Reservoir ExecuteReader( + string text, + IReadOnlyDictionary<string, object>? parameters) + { + StatementList.Add(new Statement(text, parameters)); + if (!Reservoirs.MoveNext()) + { + throw new InvalidOperationException("invalid reservoirs"); + } + return Reservoirs.Current; + } + } + + public sealed class Statement + { + public Statement( + string text, + IReadOnlyDictionary<string, object>? parameters) + { + Text = text; + Parameters = parameters?.ToImmutableDictionary(); + } + + public string Text { get; } + + public IReadOnlyDictionary<string, object>? Parameters { get; } + } +} diff --git a/SqlBind.Test/Maroontress/SqlBind/Test/StringRow.cs b/SqlBind.Test/Maroontress/SqlBind/Test/StringRow.cs new file mode 100644 index 0000000..72c4a4e --- /dev/null +++ b/SqlBind.Test/Maroontress/SqlBind/Test/StringRow.cs @@ -0,0 +1,9 @@ +namespace Maroontress.SqlBind.Test; + +[Table("strings")] +[IndexedColumns("value")] +public record StringRow( + [Column("id")][PrimaryKey][AutoIncrement] long Id, + [Column("value")][Unique] string Value) +{ +} diff --git a/SqlBind.Test/Maroontress/SqlBind/Test/StringView.cs b/SqlBind.Test/Maroontress/SqlBind/Test/StringView.cs new file mode 100644 index 0000000..454445a --- /dev/null +++ b/SqlBind.Test/Maroontress/SqlBind/Test/StringView.cs @@ -0,0 +1,5 @@ +namespace Maroontress.SqlBind.Test; + +public record StringView(string Value) +{ +} diff --git a/SqlBind.Test/Maroontress/SqlBind/Test/TransactionKitTest.cs b/SqlBind.Test/Maroontress/SqlBind/Test/TransactionKitTest.cs new file mode 100644 index 0000000..e116920 --- /dev/null +++ b/SqlBind.Test/Maroontress/SqlBind/Test/TransactionKitTest.cs @@ -0,0 +1,95 @@ +namespace Maroontress.SqlBind.Test; + +using System.Collections.Immutable; +using Maroontress.SqlBind.Impl; +using StyleChecker.Annotations; + +[TestClass] +public sealed class TransactionKitTest +{ + private static ImmutableArray<string> ExpectedCommitTrace { get; } + = ImmutableArray.Create( + "decoy.db", + "DatabaseLink#BeginTransaction", + "DatabaseLink#NewSiphon", + "Committable#Commit", + "Committable#Dispose", + "DatabaseLink#Dispose"); + + private static ImmutableArray<string> ExpectedRollbackTrace { get; } + = ImmutableArray.Create( + "decoy.db", + "DatabaseLink#BeginTransaction", + "DatabaseLink#NewSiphon", + "Committable#Rollback", + "Committable#Dispose", + "DatabaseLink#Dispose"); + + private List<string> Trace { get; } = new(); + + [TestInitialize] + public void Initialize() + { + Toolkit.Instance = new DecoyToolkit(s => + { + Trace.Add(s); + return new DecoyDatabaseLink(Trace); + }); + } + + [TestCleanup] + public void Cleanup() + { + Toolkit.Instance = new DefaultToolkit(); + } + + [TestMethod] + public void Execute_Action_Commit() + { + Trace.Clear(); + var kit = new TransactionKit("decoy.db", m => {}); + kit.Execute(q => {}); + CollectionAssert.AreEqual(ExpectedCommitTrace, Trace); + } + + [TestMethod] + public void Execute_Action_Rollback() + { + Trace.Clear(); + var kit = new TransactionKit("decoy.db", m => {}); + static void Function([Unused] Query q) + { + throw new Exception("!"); + } + Assert.ThrowsException<Exception>( + () => kit.Execute(Function), + "!"); + CollectionAssert.AreEqual(ExpectedRollbackTrace, Trace); + } + + [TestMethod] + public void Execute_Func_Commit() + { + Trace.Clear(); + var kit = new TransactionKit("decoy.db", m => {}); + var result = kit.Execute(q => "foo"); + Assert.IsNotNull(result); + Assert.AreEqual("foo", result); + CollectionAssert.AreEqual(ExpectedCommitTrace, Trace); + } + + [TestMethod] + public void Execute_Func_Rollback() + { + Trace.Clear(); + var kit = new TransactionKit("decoy.db", m => {}); + static string Function([Unused] Query q) + { + throw new Exception("!"); + } + Assert.ThrowsException<Exception>( + () => _ = kit.Execute(Function), + "!"); + CollectionAssert.AreEqual(ExpectedRollbackTrace, Trace); + } +} diff --git a/SqlBind.Test/SqlBind.Test.csproj b/SqlBind.Test/SqlBind.Test.csproj new file mode 100644 index 0000000..d0df398 --- /dev/null +++ b/SqlBind.Test/SqlBind.Test.csproj @@ -0,0 +1,29 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net6.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + + <IsPackable>false</IsPackable> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="StyleChecker" Version="1.0.27" PrivateAssets="all" /> + <PackageReference Include="StyleChecker.Annotations" Version="1.0.1" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="all" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" /> + <PackageReference Include="MSTest.TestAdapter" Version="3.0.2" /> + <PackageReference Include="MSTest.TestFramework" Version="3.0.2" /> + <PackageReference Include="coverlet.collector" Version="3.2.0"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <PackageReference Include="System.Collections.Immutable" Version="7.0.0" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\SqlBind\SqlBind.csproj" /> + </ItemGroup> + +</Project> diff --git a/SqlBind.Test/Usings.cs b/SqlBind.Test/Usings.cs new file mode 100644 index 0000000..c60834c --- /dev/null +++ b/SqlBind.Test/Usings.cs @@ -0,0 +1,3 @@ +#pragma warning disable SA1200 + +global using Microsoft.VisualStudio.TestTools.UnitTesting; diff --git a/SqlBind.sln b/SqlBind.sln new file mode 100644 index 0000000..2db54c9 --- /dev/null +++ b/SqlBind.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.3.32929.385 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SqlBind", "SqlBind\SqlBind.csproj", "{6AF444BC-A0C8-4BBF-BF7D-3E74ADA6219E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SqlBind.Test", "SqlBind.Test\SqlBind.Test.csproj", "{68A7E754-56AE-41F6-870C-D98983306FDF}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6AF444BC-A0C8-4BBF-BF7D-3E74ADA6219E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6AF444BC-A0C8-4BBF-BF7D-3E74ADA6219E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6AF444BC-A0C8-4BBF-BF7D-3E74ADA6219E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6AF444BC-A0C8-4BBF-BF7D-3E74ADA6219E}.Release|Any CPU.Build.0 = Release|Any CPU + {68A7E754-56AE-41F6-870C-D98983306FDF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {68A7E754-56AE-41F6-870C-D98983306FDF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {68A7E754-56AE-41F6-870C-D98983306FDF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {68A7E754-56AE-41F6-870C-D98983306FDF}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {B325363E-053D-4C20-B555-56F7619B46E4} + EndGlobalSection +EndGlobal diff --git a/SqlBind/.editorconfig b/SqlBind/.editorconfig new file mode 100644 index 0000000..af812c2 --- /dev/null +++ b/SqlBind/.editorconfig @@ -0,0 +1,116 @@ +[*.cs] + +# SA1633: File should have header +dotnet_diagnostic.SA1633.severity = none + +# CA1715: Identifiers should have correct prefix +dotnet_diagnostic.CA1715.severity = none + +# SA1314: Type parameter names should begin with T +dotnet_diagnostic.SA1314.severity = none + +# SA1200: Using directives should be placed correctly +dotnet_diagnostic.SA1200.severity = none + +# SA1513: Closing brace should be followed by blank line +dotnet_diagnostic.SA1513.severity = none + +# SA1101: Prefix local calls with this +dotnet_diagnostic.SA1101.severity = none + +# SA1302: Interface names should begin with I +dotnet_diagnostic.SA1302.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 + +# SA1002: Semicolons should be spaced correctly +dotnet_diagnostic.SA1002.severity = none + +csharp_indent_labels = one_less_than_current +csharp_using_directive_placement = outside_namespace:silent +csharp_prefer_simple_using_statement = true:suggestion +csharp_prefer_braces = true:silent +csharp_style_namespace_declarations = block_scoped:silent +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_throw_expression = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion + +[*.{cs,vb}] + +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Naming styles + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case +dotnet_style_operator_placement_when_wrapping = beginning_of_line +tab_width = 4 +indent_size = 4 +end_of_line = crlf +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion +dotnet_style_namespace_match_folder = true:suggestion diff --git a/SqlBind/Maroontress/SqlBind/.namespace.xml b/SqlBind/Maroontress/SqlBind/.namespace.xml new file mode 100644 index 0000000..77d03c8 --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/.namespace.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8" ?> +<Namespace Name="Maroontress.SqlBind"> + <Docs> + <summary> + This namespace provides the wrapper of SQLite. + </summary> + </Docs> +</Namespace> diff --git a/SqlBind/Maroontress/SqlBind/AutoIncrementAttribute.cs b/SqlBind/Maroontress/SqlBind/AutoIncrementAttribute.cs new file mode 100644 index 0000000..b8ff31f --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/AutoIncrementAttribute.cs @@ -0,0 +1,25 @@ +namespace Maroontress.SqlBind; + +using System; + +/// <summary> +/// An attribute that qualifies any parameter in the constructor, making the +/// column associated with the parameter an <c>AUTOINCREMENT</c> field. +/// </summary> +/// <remarks> +/// <para> +/// This attribute must be specified together with the <see +/// cref="PrimaryKeyAttribute"/>. +/// </para> +/// <para> +/// See: <a href="https://www.sqlite.org/autoinc.html">SQLite +/// Autoincrement</a>. +/// </para> +/// </remarks> +[AttributeUsage( + AttributeTargets.Parameter, + Inherited = false, + AllowMultiple = false)] +public sealed class AutoIncrementAttribute : Attribute +{ +} diff --git a/SqlBind/Maroontress/SqlBind/ColumnAttribute.cs b/SqlBind/Maroontress/SqlBind/ColumnAttribute.cs new file mode 100644 index 0000000..dd0e2e3 --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/ColumnAttribute.cs @@ -0,0 +1,31 @@ +namespace Maroontress.SqlBind; + +using System; + +/// <summary> +/// An attribute that qualifies any parameter in the constructor, associating +/// the parameter with the column that has the specified name. +/// </summary> +/// <seealso cref="TableAttribute"/> +[AttributeUsage( + AttributeTargets.Parameter, + Inherited = false, + AllowMultiple = false)] +public sealed class ColumnAttribute : Attribute +{ + /// <summary> + /// Initializes a new instance of the <see cref="ColumnAttribute"/> class. + /// </summary> + /// <param name="name"> + /// The column name. + /// </param> + public ColumnAttribute(string name) + { + Name = name; + } + + /// <summary> + /// Gets the column name. + /// </summary> + public string Name { get; } +} diff --git a/SqlBind/Maroontress/SqlBind/DeleteFrom.cs b/SqlBind/Maroontress/SqlBind/DeleteFrom.cs new file mode 100644 index 0000000..d78bfe5 --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/DeleteFrom.cs @@ -0,0 +1,27 @@ +namespace Maroontress.SqlBind; + +using System.Collections.Generic; + +/// <summary> +/// Represents the <c>DELETE</c> statement in SQL. +/// </summary> +/// <typeparam name="T"> +/// The type of the class qualified with the <see cref="TableAttribute"/>. +/// </typeparam> +public interface DeleteFrom<T> +{ + /// <summary> + /// Executes the delete statement with the specified condition and + /// parameters. + /// </summary> + /// <param name="condition"> + /// The condition of the <c>WHERE</c> clause. + /// </param> + /// <param name="parameters"> + /// Immutable key-value pairs. The <paramref name="condition"/> must + /// contain all the keys. Each value must be of the appropriate type. + /// </param> + public void Where( + string condition, + IReadOnlyDictionary<string, object> parameters); +} diff --git a/SqlBind/Maroontress/SqlBind/IgnoredAttribute.cs b/SqlBind/Maroontress/SqlBind/IgnoredAttribute.cs new file mode 100644 index 0000000..c0a89f6 --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/IgnoredAttribute.cs @@ -0,0 +1,15 @@ +namespace Maroontress.SqlBind; + +using System; + +/// <summary> +/// An attribute that qualifies the constructor that SqlBind does not use to +/// instantiate when the type has two or more constructors. +/// </summary> +[AttributeUsage( + AttributeTargets.Constructor, + Inherited = false, + AllowMultiple = false)] +public sealed class IgnoredAttribute : Attribute +{ +} diff --git a/SqlBind/Maroontress/SqlBind/Impl/.namespace.xml b/SqlBind/Maroontress/SqlBind/Impl/.namespace.xml new file mode 100644 index 0000000..1ecd1b6 --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/Impl/.namespace.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8" ?> +<Namespace Name="Maroontress.SqlBind.Impl"> + <Docs> + <summary> + This namespace provides the default implementation of some interfaces. + </summary> + </Docs> +</Namespace> diff --git a/SqlBind/Maroontress/SqlBind/Impl/Committable.cs b/SqlBind/Maroontress/SqlBind/Impl/Committable.cs new file mode 100644 index 0000000..75e89c1 --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/Impl/Committable.cs @@ -0,0 +1,19 @@ +namespace Maroontress.SqlBind.Impl; + +using System; + +/// <summary> +/// Represents an atomic transaction. +/// </summary> +public interface Committable : IDisposable +{ + /// <summary> + /// Applies the changes made in the transaction. + /// </summary> + void Commit(); + + /// <summary> + /// Reverts the changes made in the transaction. + /// </summary> + void Rollback(); +} diff --git a/SqlBind/Maroontress/SqlBind/Impl/DatabaseLink.cs b/SqlBind/Maroontress/SqlBind/Impl/DatabaseLink.cs new file mode 100644 index 0000000..c966ccd --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/Impl/DatabaseLink.cs @@ -0,0 +1,28 @@ +namespace Maroontress.SqlBind.Impl; + +using System; + +/// <summary> +/// Represents connection to the database. +/// </summary> +public interface DatabaseLink : IDisposable +{ + /// <summary> + /// Begins the transaction. + /// </summary> + /// <returns> + /// The abstract transaction. + /// </returns> + Committable BeginTransaction(); + + /// <summary> + /// Gets a new siphon with the specified logger. + /// </summary> + /// <param name="logger"> + /// The logger to record statements. + /// </param> + /// <returns> + /// The new siphon. + /// </returns> + Siphon NewSiphon(Action<Func<string>> logger); +} diff --git a/SqlBind/Maroontress/SqlBind/Impl/DefaultToolkit.cs b/SqlBind/Maroontress/SqlBind/Impl/DefaultToolkit.cs new file mode 100644 index 0000000..f1d4b6d --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/Impl/DefaultToolkit.cs @@ -0,0 +1,17 @@ +namespace Maroontress.SqlBind.Impl; + +using Microsoft.Data.Sqlite; + +/// <summary> +/// The default implementation of the <see cref="Toolkit"/> interface. +/// </summary> +public sealed class DefaultToolkit : Toolkit +{ + /// <inheritdoc/> + public DatabaseLink NewDatabaseLink(string databasePath) + { + var c = new SqliteConnection($"Data Source={databasePath}"); + c.Open(); + return new SqliteDatabaseLink(c); + } +} diff --git a/SqlBind/Maroontress/SqlBind/Impl/DeleteFromImpl.cs b/SqlBind/Maroontress/SqlBind/Impl/DeleteFromImpl.cs new file mode 100644 index 0000000..0e4918d --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/Impl/DeleteFromImpl.cs @@ -0,0 +1,41 @@ +namespace Maroontress.SqlBind.Impl; + +using System.Collections.Generic; + +/// <summary> +/// The default implementation of <see cref="DeleteFrom{T}"/>. +/// </summary> +/// <typeparam name="T"> +/// The type of the class qualified with the <see cref="TableAttribute"/>. +/// </typeparam> +public sealed class DeleteFromImpl<T> : DeleteFrom<T> +{ + /// <summary> + /// Initializes a new instance of the <see cref="DeleteFromImpl{T}"/> + /// class. + /// </summary> + /// <param name="siphon"> + /// The <see cref="Siphon"/> object. + /// </param> + /// <param name="text"> + /// The prefix statement. + /// </param> + internal DeleteFromImpl(Siphon siphon, string text) + { + Siphon = siphon; + Text = text; + } + + private Siphon Siphon { get; } + + private string Text { get; } + + /// <inheritdoc/> + public void Where( + string condition, + IReadOnlyDictionary<string, object> parameters) + { + var text = Text + $" WHERE {condition}"; + Siphon.ExecuteNonQuery(text, parameters); + } +} diff --git a/SqlBind/Maroontress/SqlBind/Impl/Field.cs b/SqlBind/Maroontress/SqlBind/Impl/Field.cs new file mode 100644 index 0000000..8be162e --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/Impl/Field.cs @@ -0,0 +1,120 @@ +#pragma warning disable SingleTypeParameter + +namespace Maroontress.SqlBind.Impl; + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; + +/// <summary> +/// Represents a column of the table's row. Each instance corresponds to the +/// parameter in the constructor that the class qualified with <see +/// cref="TableAttribute"/> has. +/// </summary> +/// <typeparam name="T"> +/// The type qualified with <see cref="TableAttribute"/>. +/// </typeparam> +public sealed class Field<T> : WildField +{ + /// <summary> + /// Initializes a new instance of the <see cref="Field{T}"/> class. + /// </summary> + /// <param name="parameterInfo"> + /// The constructor's parameter corresponding to this field. + /// </param> + /// <exception cref="ArgumentException"> + /// Throws if the <paramref name="parameterInfo"/> is invalid. + /// </exception> + public Field(ParameterInfo parameterInfo) + { + var type = parameterInfo.Member.DeclaringType; + if (typeof(T) != type) + { + throw new ArgumentException( + "type mismatch", nameof(parameterInfo)); + } + var column = parameterInfo.GetCustomAttribute<ColumnAttribute>(); + if (column is null) + { + throw new ArgumentException( + "all parameters of the constructor must be annotated" + + $" with [Column]: {type}", + nameof(parameterInfo)); + } + ColumnName = column.Name; + IsAutoIncrement = parameterInfo.GetCustomAttribute< + AutoIncrementAttribute>() is not null; + + ParameterName = parameterInfo.Name; + var parameterType = parameterInfo.ParameterType; + if (!SqlTypeMap.TryGetValue(parameterType, out var sqlType)) + { + throw new ArgumentException( + "unsupported parameter type", ParameterName); + } + var propertyInfo = type.GetProperty(ParameterName); + if (propertyInfo is null) + { + throw new ArgumentException( + $"invalid type '{type}': no property found corresponding " + + $"to the paramter '{ParameterName}' of the " + + "constructor"); + } + PropertyInfo = propertyInfo; + var flags = SqlColumnFlags.Where( + p => (parameterInfo.GetCustomAttribute(p.Key) is not null)) + .Select(p => p.Value); + var list = ImmutableArray.Create(ColumnName, sqlType) + .Concat(flags); + ColumnDefinition = string.Join(' ', list); + } + + /// <inheritdoc/> + public string ParameterName { get; } + + /// <inheritdoc/> + public string ColumnDefinition { get; } + + /// <inheritdoc/> + public string ColumnName { get; } + + /// <inheritdoc/> + public PropertyInfo PropertyInfo { get; } + + /// <inheritdoc/> + public bool IsAutoIncrement { get; } + + private static IReadOnlyDictionary<Type, string> + SqlTypeMap + { get; } = ImmutableDictionary.CreateRange( + ImmutableArray.Create( + ToTypePair<string>("TEXT"), + ToTypePair<long>("INTEGER"))); + + private static IEnumerable<KeyValuePair<Type, string>> + SqlColumnFlags + { get; } = ImmutableArray.Create( + ToTypePair<PrimaryKeyAttribute>("PRIMARY KEY"), + ToTypePair<AutoIncrementAttribute>("AUTOINCREMENT"), + ToTypePair<UniqueAttribute>("UNIQUE")); + + /// <summary> + /// Gets the value of the property associated with the field that the + /// specified row object contains. + /// </summary> + /// <param name="row"> + /// The row object that contains this field. + /// </param> + /// <returns> + /// The property value. + /// </returns> + public object? GetPropertyValue(T row) + { + return PropertyInfo.GetValue(row); + } + + private static KeyValuePair<Type, string> ToTypePair<U>(string name) + => KeyValuePair.Create(typeof(U), name); +} diff --git a/SqlBind/Maroontress/SqlBind/Impl/Metadata.cs b/SqlBind/Maroontress/SqlBind/Impl/Metadata.cs new file mode 100644 index 0000000..e77767e --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/Impl/Metadata.cs @@ -0,0 +1,283 @@ +namespace Maroontress.SqlBind.Impl; + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; + +/// <summary> +/// The cache of the metadata for reflection. +/// </summary> +/// <typeparam name="T"> +/// The type qualified with <see cref="TableAttribute"/>. +/// </typeparam> +public sealed class Metadata<T> : WildMetadata + where T : notnull +{ + /// <summary> + /// Initializes a new instance of the <see cref="Metadata{T}"/> class. + /// </summary> + public Metadata() + { + var type = typeof(T); + var tableName = ToTableName(type); + var fields = ToFields(type).ToImmutableArray(); + var columnNameSet = new HashSet<string>(fields.Length) + { + fields[0].ColumnName, + }; + for (var k = 1; k < fields.Length; ++k) + { + var it = fields[k]; + var columnName = it.ColumnName; + if (columnNameSet.Add(columnName)) + { + continue; + } + throw new ArgumentException( + $"the column name '{columnName}' associated with " + + $"the parameter '{it.ParameterName}' is duplicated", + nameof(type)); + } + var insertColumns = fields.Where(p => !p.IsAutoIncrement) + .ToImmutableArray(); + var indexedColumns = ToIndexedColumns(type) + .Select(i => (Name: ToIndexName(tableName, i), + Columns: string.Join(", ", i))) + .ToImmutableArray(); + + string ToInsertStatement() + { + var all = insertColumns.Select(p => p.ColumnName) + .ToArray(); + var columns = string.Join(", ", all); + var args = string.Join(", ", all.Select(i => "$" + i)); + return $"INSERT INTO {tableName} ({columns}) VALUES ({args})"; + } + + Func<T, IReadOnlyDictionary<string, object>> + NewInsertParameterMap() + { + var all = insertColumns.Select( + i => (Name: "$" + i.ColumnName, ToValue: ToParameter(i))) + .ToImmutableArray(); + return o => + { + return all.ToImmutableDictionary( + i => i.Name, + i => i.ToValue(o)); + }; + } + + string ToCreateIndex(string name, string columns) + { + return $"CREATE INDEX {name} on {tableName} ({columns})"; + } + + string ToDropIndex(string name) + { + return $"DROP INDEX IF EXISTS {name}"; + } + + IEnumerable<string> ToCreateTableStatements() + { + var definitions = fields.Select(i => i.ColumnDefinition); + var columns = string.Join(", ", definitions); + var first = $"CREATE TABLE {tableName} ({columns})"; + var list = ImmutableArray.Create(first); + return !indexedColumns.Any() + ? list + : list.Concat( + indexedColumns.Select( + i => ToCreateIndex(i.Name, i.Columns))); + } + + IEnumerable<string> ToDropTableStatements() + { + var first = $"DROP TABLE IF EXISTS {tableName}"; + var list = ImmutableArray.Create(first); + return !indexedColumns.Any() + ? (IEnumerable<string>)list + : list.Concat( + indexedColumns.Select(i => ToDropIndex(i.Name))); + } + + string ToSelectAllStatement() + { + var columns = string.Join(", ", fields.Select(i => i.ColumnName)); + return $"SELECT {columns} FROM {tableName}"; + } + + TableName = tableName; + Fields = fields; + InsertStatement = ToInsertStatement(); + DeleteStatement = $"DELETE FROM {tableName}"; + CreateTableStatements = ToCreateTableStatements().ToImmutableArray(); + DropTableStatements = ToDropTableStatements().ToImmutableArray(); + ToInsertParameterMap = NewInsertParameterMap(); + SelectAllStatement = ToSelectAllStatement(); + ParameterToColumnNameMap + = fields.ToDictionary(i => i.ParameterName, i => i.ColumnName); + } + + /// <inheritdoc/> + public IEnumerable<string> CreateTableStatements { get; } + + /// <inheritdoc/> + public IEnumerable<string> DropTableStatements { get; } + + /// <inheritdoc/> + public string InsertStatement { get; } + + /// <inheritdoc/> + public string TableName { get; } + + /// <inheritdoc/> + public string DeleteStatement { get; } + + /// <inheritdoc/> + public string SelectAllStatement { get; } + + /// <summary> + /// Gets all the fields of <typeparamref name="T"/>. + /// </summary> + public IEnumerable<Field<T>> Fields { get; } + + private Func<T, IReadOnlyDictionary<string, object>> + ToInsertParameterMap { get; } + + private IReadOnlyDictionary<string, string> + ParameterToColumnNameMap { get; } + + /// <inheritdoc/> + public string NewSelectStatement(string columnName) + { + if (Fields.All(i => i.ColumnName != columnName)) + { + throw new ArgumentException( + "does not contain the field of that name", + nameof(columnName)); + } + var columns = string.Join( + ", ", + Fields.Select(i => i.ColumnName)); + return $"SELECT {columns} FROM {TableName} " + + $"WHERE {columnName} = ${columnName}"; + } + + /// <inheritdoc/> + public string NewSelectAllStatement(string alias) + { + var columns = string.Join( + ", ", + Fields.Select(i => $"{alias}.{i.ColumnName}")); + return string.Join( + ' ', + "SELECT", + columns, + "FROM", + TableName, + alias); + } + + /// <inheritdoc/> + public string ToColumnName(string parameterName) + { + if (!ParameterToColumnNameMap.TryGetValue( + parameterName, out var columnName)) + { + throw new ArgumentException( + $"parameter name '{parameterName}' is not found."); + } + return columnName; + } + + /// <summary> + /// Gets a new map of the parameter name to the parameter value. + /// </summary> + /// <param name="row"> + /// The row object containing the all field values. + /// </param> + /// <returns> + /// The new dictionary containing the parameter name-value pairs. + /// </returns> + public IReadOnlyDictionary<string, object> NewInsertParameterMap(T row) + { + return ToInsertParameterMap(row); + } + + private static string ToTableName(Type type) + { + var a = type.GetCustomAttribute<TableAttribute>(); + if (a is null) + { + throw new ArgumentException( + $"not annotated with {nameof(TableAttribute)}", + nameof(type)); + } + return a.Name; + } + + private static IEnumerable<Field<T>> ToFields(Type type) + { + var allPublicConstructors = type.GetConstructors(); + var ctors = allPublicConstructors.Where( + i => i.GetCustomAttribute<IgnoredAttribute>() is null) + .ToArray(); + if (ctors.Length is not 1) + { + throw new ArgumentException( + "must have the single public constructor without [Ignored] " + + "attribute.", + nameof(type)); + } + var ctor = ctors[0]; + var parameters = ctor.GetParameters(); + if (parameters.Length is 0) + { + throw new ArgumentException( + "the constructor has no parameters", + nameof(type)); + } + return parameters.Select(i => new Field<T>(i)); + } + + private static Func<T, object> ToParameter(Field<T> field) + { + return i => + { + var o = field.GetPropertyValue(i); + if (o is null) + { + throw new ArgumentException( + $"'{field.ParameterName}' must be non-null", + nameof(field)); + } + return o; + }; + } + + private static IEnumerable<IEnumerable<string>> ToIndexedColumns(Type type) + { + var a = type.GetCustomAttributes<IndexedColumnsAttribute>(); + foreach (var i in a) + { + if (i.Names.Count is 0) + { + var name = nameof(IndexedColumnsAttribute); + throw new ArgumentException( + $"missing parameters of {name}", type.ToString()); + } + } + return a.Select(i => i.Names.AsEnumerable()); + } + + private static string ToIndexName( + string tableName, IEnumerable<string> names) + { + var list = ImmutableArray.Create(tableName, "Index") + .Concat(names); + return string.Join('_', list); + } +} diff --git a/SqlBind/Maroontress/SqlBind/Impl/MetadataBank.cs b/SqlBind/Maroontress/SqlBind/Impl/MetadataBank.cs new file mode 100644 index 0000000..98c64d7 --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/Impl/MetadataBank.cs @@ -0,0 +1,61 @@ +namespace Maroontress.SqlBind.Impl; + +using System; +using System.Collections.Concurrent; + +/// <summary> +/// Provides cache for the reflection. +/// </summary> +public sealed class MetadataBank +{ + /// <summary> + /// Initializes a new instance of the <see cref="MetadataBank"/> class. + /// </summary> + public MetadataBank() + { + Cache = new(); + } + + private ConcurrentDictionary<Type, WildMetadata> Cache { get; } + + /// <summary> + /// Gets the metadata associated with the specified type. + /// </summary> + /// <param name="type"> + /// The type representing the table. + /// </param> + /// <returns> + /// The metadata. + /// </returns> + public WildMetadata GetMetadata(Type type) + { + /* + See: + https://learn.microsoft.com/en-us/dotnet/framework/reflection-and-codedom/how-to-examine-and-instantiate-generic-types-with-reflection#to-construct-an-instance-of-a-generic-type + */ + + static WildMetadata ToMetadata(Type t) + { + var u = typeof(Metadata<>).MakeGenericType(t); + return (WildMetadata)Activator.CreateInstance(u); + } + + return Cache.GetOrAdd(type, ToMetadata); + } + + /// <summary> + /// Gets the metadata associated with the specified type. + /// </summary> + /// <typeparam name="T"> + /// The type representing the table. + /// </typeparam> + /// <returns> + /// The metadata. + /// </returns> + public Metadata<T> GetMetadata<T>() + where T : notnull + { + var o = Cache.GetOrAdd(typeof(T), t => new Metadata<T>()); + return (Metadata<T>)o; + } +} diff --git a/SqlBind/Maroontress/SqlBind/Impl/QueryImpl.cs b/SqlBind/Maroontress/SqlBind/Impl/QueryImpl.cs new file mode 100644 index 0000000..4eab60f --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/Impl/QueryImpl.cs @@ -0,0 +1,157 @@ +namespace Maroontress.SqlBind.Impl; + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +/// <summary> +/// The default implementation of <see cref="Query"/>. +/// </summary> +public sealed class QueryImpl : Query +{ + /// <summary> + /// Initializes a new instance of the <see cref="QueryImpl"/> class. + /// </summary> + /// <param name="siphon"> + /// The abstraction of the database connection. + /// </param> + /// <param name="bank"> + /// The cache for the reflection. + /// </param> + public QueryImpl(Siphon siphon, MetadataBank bank) + { + Siphon = siphon; + Bank = bank; + } + + private Siphon Siphon { get; } + + private MetadataBank Bank { get; } + + /// <inheritdoc/> + public SelectFrom<T> SelectAllFrom<T>(string alias) + where T : notnull + { + var text = Bank.GetMetadata<T>() + .NewSelectAllStatement(alias); + return new SelectFromImpl<T>(Bank, Siphon, text); + } + + /// <inheritdoc/> + public Select<T> Select<T>(params string[] columns) + where T : notnull + { + var resultColumn = string.Join(", ", columns); + var text = $"SELECT {resultColumn}"; + return new SelectImpl<T>(Bank, Siphon, text); + } + + /// <inheritdoc/> + public DeleteFrom<T> DeleteFrom<T>() + where T : notnull + { + var text = Bank.GetMetadata<T>().DeleteStatement; + return new DeleteFromImpl<T>(Siphon, text); + } + + /// <inheritdoc/> + public void NewTables(params Type[] tables) + => NewTables(tables.AsEnumerable()); + + /// <inheritdoc/> + public void NewTables(IEnumerable<Type> allTables) + { + void Execute(Func<Type, IEnumerable<string>> typeToQuery) + { + var all = allTables.SelectMany(typeToQuery); + foreach (var s in all) + { + Siphon.ExecuteNonQuery(s); + } + } + + Execute(DropTableStatements); + Execute(CreateTableStatements); + } + + /// <inheritdoc/> + public void Insert<T>(T row) + where T : notnull + { + var (text, parameters) = InsertStatement(row); + Siphon.ExecuteNonQuery(text, parameters); + } + + /// <inheritdoc/> + public long InsertAndGetRowId<T>(T row) + where T : notnull + { + Insert(row); + return GetLastInsertRowId(); + } + + /// <inheritdoc/> + public T? SelectUnique<T>(string columnName, object value) + where T : class + { + var text = Bank.GetMetadata<T>() + .NewSelectStatement(columnName); + var parameters = ImmutableArray.Create( + KeyValuePair.Create("$" + columnName, value)) + .ToImmutableDictionary(); + using var reader = Siphon.ExecuteReader(text, parameters); + return reader.Read() + ? reader.NewInstance<T>() + : null; + } + + /// <inheritdoc/> + public IEnumerable<T> SelectAll<T>() + where T : notnull + { + var text = Bank.GetMetadata<T>() + .SelectAllStatement; + using var reader = Siphon.ExecuteReader(text); + while (reader.Read()) + { + yield return reader.NewInstance<T>(); + } + } + + /// <inheritdoc/> + public string ColumnName<T>(string parameterName) + where T : notnull + { + return Bank.GetMetadata<T>() + .ToColumnName(parameterName); + } + + private long GetLastInsertRowId() + { + var text = "select last_insert_rowid()"; + return Siphon.ExecuteLong(text); + } + + private IEnumerable<string> CreateTableStatements(Type type) + { + return Bank.GetMetadata(type) + .CreateTableStatements; + } + + private IEnumerable<string> DropTableStatements(Type type) + { + return Bank.GetMetadata(type) + .DropTableStatements; + } + + private (string Text, IReadOnlyDictionary<string, object> Parameters) + InsertStatement<T>(T row) + where T : notnull + { + var m = Bank.GetMetadata<T>(); + var text = m.InsertStatement; + var parameters = m.NewInsertParameterMap(row); + return (text, parameters); + } +} diff --git a/SqlBind/Maroontress/SqlBind/Impl/Reservoir.cs b/SqlBind/Maroontress/SqlBind/Impl/Reservoir.cs new file mode 100644 index 0000000..070a67b --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/Impl/Reservoir.cs @@ -0,0 +1,40 @@ +namespace Maroontress.SqlBind.Impl; + +using System; +using System.Collections.Generic; + +/// <summary> +/// Retrieves the result of the query. +/// </summary> +public interface Reservoir : IDisposable +{ + /// <summary> + /// Advances to the next row in the result set. + /// </summary> + /// <returns> + /// <c>true</c> if there are more rows; otherwise, <c>false</c>. + /// </returns> + bool Read(); + + /// <summary> + /// Creates an instance with the current row. + /// </summary> + /// <typeparam name="T"> + /// The type of the instance to be created. + /// </typeparam> + /// <returns> + /// The new instance. + /// </returns> + T NewInstance<T>(); + + /// <summary> + /// Creates new instances with the current and susequent rows. + /// </summary> + /// <typeparam name="T"> + /// The type of the instances to be created. + /// </typeparam> + /// <returns> + /// The new instances. + /// </returns> + IEnumerable<T> NewInstances<T>(); +} diff --git a/SqlBind/Maroontress/SqlBind/Impl/SelectFromImpl.cs b/SqlBind/Maroontress/SqlBind/Impl/SelectFromImpl.cs new file mode 100644 index 0000000..46a6052 --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/Impl/SelectFromImpl.cs @@ -0,0 +1,89 @@ +#pragma warning disable SingleTypeParameter + +namespace Maroontress.SqlBind.Impl; + +using System.Collections.Generic; +using System.Linq; + +/// <summary> +/// The default implementation of <see cref="SelectFrom{T}"/>. +/// </summary> +/// <typeparam name="T"> +/// The type of the class representing any row of the result of the query. +/// </typeparam> +public sealed class SelectFromImpl<T> : SelectFrom<T> + where T : notnull +{ + /// <summary> + /// Initializes a new instance of the <see cref="SelectFromImpl{T}"/> + /// class. + /// </summary> + /// <param name="bank"> + /// The <see cref="Bank"/> object. + /// </param> + /// <param name="siphon"> + /// The <see cref="Siphon"/> object. + /// </param> + /// <param name="text"> + /// The prefix statement. + /// </param> + internal SelectFromImpl(MetadataBank bank, Siphon siphon, string text) + { + Bank = bank; + Siphon = siphon; + Text = text; + } + + private MetadataBank Bank { get; } + + private Siphon Siphon { get; } + + private string Text { get; } + + /// <inheritdoc/> + public SelectFrom<T> InnerJoin<U>(string alias, string constraint) + where U : notnull + { + var tableName = Bank.GetMetadata<U>() + .TableName; + var newText = string.Join( + ' ', + Text, + "INNER JOIN", + tableName, + alias, + "ON", + constraint); + return new SelectFromImpl<T>(Bank, Siphon, newText); + } + + /// <inheritdoc/> + public Where<T> Where( + string condition, + IReadOnlyDictionary<string, object> parameters) + { + var text = Text + $" WHERE {condition}"; + return new WhereImpl<T>(Siphon, text, parameters); + } + + /// <inheritdoc/> + public IEnumerable<T> Execute() + { + return Execute(Text); + } + + /// <inheritdoc/> + public IEnumerable<T> OrderBy(params string[] columns) + { + var orderBy = string.Join(", ", columns); + var text = $"{Text} ORDER BY {orderBy}"; + return Execute(text); + } + + private IEnumerable<T> Execute(string text) + { + using var reader = Siphon.ExecuteReader(text); + return reader.NewInstances<T>() + .ToList(); + } +} diff --git a/SqlBind/Maroontress/SqlBind/Impl/SelectImpl.cs b/SqlBind/Maroontress/SqlBind/Impl/SelectImpl.cs new file mode 100644 index 0000000..54e7af7 --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/Impl/SelectImpl.cs @@ -0,0 +1,54 @@ +#pragma warning disable SingleTypeParameter + +namespace Maroontress.SqlBind.Impl; + +/// <summary> +/// The default implementation of <see cref="Select{T}"/>. +/// </summary> +/// <typeparam name="T"> +/// The type of the class representing any row of the result of the query. +/// </typeparam> +public sealed class SelectImpl<T> : Select<T> + where T : notnull +{ + /// <summary> + /// Initializes a new instance of the <see cref="SelectImpl{T}"/> + /// class. + /// </summary> + /// <param name="bank"> + /// The <see cref="Bank"/> object. + /// </param> + /// <param name="siphon"> + /// The <see cref="Siphon"/> object. + /// </param> + /// <param name="text"> + /// The prefix statement. + /// </param> + internal SelectImpl(MetadataBank bank, Siphon siphon, string text) + { + Bank = bank; + Siphon = siphon; + Text = text; + } + + private MetadataBank Bank { get; } + + private Siphon Siphon { get; } + + private string Text { get; } + + /// <inheritdoc/> + public SelectFrom<T> From<U>(string alias) + where U : notnull + { + var tableName = Bank.GetMetadata<U>() + .TableName; + var newText = string.Join( + ' ', + Text, + "FROM", + tableName, + alias); + return new SelectFromImpl<T>(Bank, Siphon, newText); + } +} diff --git a/SqlBind/Maroontress/SqlBind/Impl/Siphon.cs b/SqlBind/Maroontress/SqlBind/Impl/Siphon.cs new file mode 100644 index 0000000..d4e145d --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/Impl/Siphon.cs @@ -0,0 +1,61 @@ +namespace Maroontress.SqlBind.Impl; + +using System; +using System.Collections.Generic; + +/// <summary> +/// Abstraction of the database connection. +/// </summary> +public interface Siphon +{ + /// <summary> + /// Executes the SQL statement without the results. + /// </summary> + /// <param name="text"> + /// The SQL statement. + /// </param> + /// <param name="parameters"> + /// The parameters of the statement. This can be <c>null</c> + /// if the statement contains no parameters. + /// </param> + void ExecuteNonQuery( + string text, + IReadOnlyDictionary<string, object>? parameters = null); + + /// <summary> + /// Executes the SQL statement and returns the reader of the results. + /// </summary> + /// <param name="text"> + /// The SQL statement. + /// </param> + /// <param name="parameters"> + /// The parameters of the statement. This can be <c>null</c> + /// if the statement contains no parameters. + /// </param> + /// <returns> + /// The reader of the results. + /// </returns> + public Reservoir ExecuteReader( + string text, + IReadOnlyDictionary<string, object>? parameters = null); + + /// <summary> + /// Executes the SQL statement and returns the scalar result. + /// </summary> + /// <param name="text"> + /// The SQL statement. + /// </param> + /// <param name="parameters"> + /// The parameters of the statement. This can be <c>null</c> + /// if the statement contains no parameters. + /// </param> + /// <returns> + /// The scalar result. + /// </returns> + /// <exception cref="NullReferenceException"> + /// If no results. + /// </exception> + public long ExecuteLong( + string text, + IReadOnlyDictionary<string, object>? parameters = null); +} diff --git a/SqlBind/Maroontress/SqlBind/Impl/SqliteCommittable.cs b/SqlBind/Maroontress/SqlBind/Impl/SqliteCommittable.cs new file mode 100644 index 0000000..1e34238 --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/Impl/SqliteCommittable.cs @@ -0,0 +1,35 @@ +namespace Maroontress.SqlBind.Impl; + +using Microsoft.Data.Sqlite; + +/// <summary> +/// The implementation with Sqlite. +/// </summary> +public sealed class SqliteCommittable : Committable +{ + /// <summary> + /// Initializes a new instance of the <see cref="SqliteCommittable"/> + /// class. + /// </summary> + /// <param name="transaction"> + /// The Sqlite's transaction. + /// </param> + public SqliteCommittable(SqliteTransaction transaction) + { + Transaction = transaction; + } + + private SqliteTransaction Transaction { get; } + + /// <inheritdoc/> + public void Commit() + => Transaction.Commit(); + + /// <inheritdoc/> + public void Dispose() + => Transaction.Dispose(); + + /// <inheritdoc/> + public void Rollback() + => Transaction.Rollback(); +} diff --git a/SqlBind/Maroontress/SqlBind/Impl/SqliteDatabaseLink.cs b/SqlBind/Maroontress/SqlBind/Impl/SqliteDatabaseLink.cs new file mode 100644 index 0000000..d06eb67 --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/Impl/SqliteDatabaseLink.cs @@ -0,0 +1,43 @@ +namespace Maroontress.SqlBind.Impl; + +using System; +using Microsoft.Data.Sqlite; + +/// <summary> +/// The implementation with Sqlite. +/// </summary> +public sealed class SqliteDatabaseLink : DatabaseLink +{ + /// <summary> + /// Initializes a new instance of the <see cref="SqliteDatabaseLink"/> + /// class. + /// </summary> + /// <param name="connection"> + /// The Sqlite's connection. + /// </param> + public SqliteDatabaseLink(SqliteConnection connection) + { + Connection = connection; + } + + private SqliteConnection Connection { get; } + + /// <inheritdoc/> + public Committable BeginTransaction() + { + var t = Connection.BeginTransaction(); + return new SqliteCommittable(t); + } + + /// <inheritdoc/> + public void Dispose() + { + Connection.Dispose(); + } + + /// <inheritdoc/> + public Siphon NewSiphon(Action<Func<string>> logger) + { + return new SqliteSiphon(Connection, logger); + } +} diff --git a/SqlBind/Maroontress/SqlBind/Impl/SqliteReservoir.cs b/SqlBind/Maroontress/SqlBind/Impl/SqliteReservoir.cs new file mode 100644 index 0000000..1323c15 --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/Impl/SqliteReservoir.cs @@ -0,0 +1,67 @@ +namespace Maroontress.SqlBind.Impl; + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Data.Sqlite; + +/// <summary> +/// The <see cref="Reservoir"/> implementation wrapping SQLite. +/// </summary> +internal sealed class SqliteReservoir : Reservoir +{ + /// <summary> + /// Initializes a new instance of the <see cref="SqliteReservoir"/> class. + /// </summary> + /// <param name="reader"> + /// The <see cref="SqliteDataReader"/> object. + /// </param> + public SqliteReservoir(SqliteDataReader reader) + { + Reader = reader; + } + + private SqliteDataReader Reader { get; } + + /// <inheritdoc/> + public void Dispose() + { + Reader.Dispose(); + } + + /// <inheritdoc/> + public T NewInstance<T>() + { + var type = typeof(T); + var ctor = type.GetConstructors().First(); + var n = Reader.FieldCount; + var args = new object[n]; + Reader.GetValues(args); + return (T)ctor.Invoke(args); + } + + /// <inheritdoc/> + public IEnumerable<T> NewInstances<T>() + { + var type = typeof(T); + var ctor = type.GetConstructors().First(); + var n = ctor.GetParameters().Length; + var args = new object[n]; + while (Reader.Read()) + { + if (Reader.FieldCount != n) + { + throw new ArgumentException("T"); + } + Reader.GetValues(args); + var instance = (T)ctor.Invoke(args); + yield return instance; + } + } + + /// <inheritdoc/> + public bool Read() + { + return Reader.Read(); + } +} diff --git a/SqlBind/Maroontress/SqlBind/Impl/SqliteSiphon.cs b/SqlBind/Maroontress/SqlBind/Impl/SqliteSiphon.cs new file mode 100644 index 0000000..fccdc8c --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/Impl/SqliteSiphon.cs @@ -0,0 +1,81 @@ +namespace Maroontress.SqlBind.Impl; + +using System; +using System.Collections.Generic; +using Microsoft.Data.Sqlite; + +/// <summary> +/// The implementation with Sqlite that has the logging facility. +/// </summary> +internal sealed class SqliteSiphon : Siphon +{ + /// <summary> + /// Initializes a new instance of the <see cref="SqliteSiphon"/> class. + /// </summary> + /// <param name="connection"> + /// The connection to the Sqlite. + /// </param> + /// <param name="logger"> + /// The logger. + /// </param> + public SqliteSiphon( + SqliteConnection connection, + Action<Func<string>> logger) + { + Connection = connection; + Logger = logger; + } + + private SqliteConnection Connection { get; } + + private Action<Func<string>> Logger { get; } + + /// <inheritdoc/> + public void ExecuteNonQuery( + string text, + IReadOnlyDictionary<string, object>? parameters = null) + { + var command = NewCommand(text, parameters); + command.ExecuteNonQuery(); + } + + /// <inheritdoc/> + public Reservoir ExecuteReader( + string text, + IReadOnlyDictionary<string, object>? parameters = null) + { + var command = NewCommand(text, parameters); + return new SqliteReservoir(command.ExecuteReader()); + } + + /// <inheritdoc/> + public long ExecuteLong( + string text, + IReadOnlyDictionary<string, object>? parameters = null) + { + var command = NewCommand(text, parameters); + if (!(command.ExecuteScalar() is {} rowId)) + { + throw new NullReferenceException(); + } + return (long)rowId; + } + + private SqliteCommand NewCommand( + string text, + IReadOnlyDictionary<string, object>? parameters = null) + { + var command = Connection.CreateCommand(); + Logger(() => text); + command.CommandText = text; + if (parameters is not null) + { + foreach (var (key, value) in parameters) + { + command.Parameters.AddWithValue(key, value); + Logger(() => $" ({key}, {value})"); + } + } + return command; + } +} diff --git a/SqlBind/Maroontress/SqlBind/Impl/Toolkit.cs b/SqlBind/Maroontress/SqlBind/Impl/Toolkit.cs new file mode 100644 index 0000000..19a8bf1 --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/Impl/Toolkit.cs @@ -0,0 +1,23 @@ +namespace Maroontress.SqlBind.Impl; + +/// <summary> +/// Provides the database connection. +/// </summary> +public interface Toolkit +{ + /// <summary> + /// Gets or sets the toolkit instance. + /// </summary> + static Toolkit Instance { get; set; } = new DefaultToolkit(); + + /// <summary> + /// Gets a new database connection. + /// </summary> + /// <param name="databasePath"> + /// The database path. + /// </param> + /// <returns> + /// The new database connection. + /// </returns> + DatabaseLink NewDatabaseLink(string databasePath); +} diff --git a/SqlBind/Maroontress/SqlBind/Impl/WhereImpl.cs b/SqlBind/Maroontress/SqlBind/Impl/WhereImpl.cs new file mode 100644 index 0000000..cafb0ff --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/Impl/WhereImpl.cs @@ -0,0 +1,63 @@ +namespace Maroontress.SqlBind.Impl; + +using System.Collections.Generic; +using System.Linq; + +/// <summary> +/// The default implementation of <see cref="Where{T}"/>. +/// </summary> +/// <typeparam name="T"> +/// The type of the class representing any row of the result of the query. +/// </typeparam> +public sealed class WhereImpl<T> : Where<T> + where T : notnull +{ + /// <summary> + /// Initializes a new instance of the <see cref="WhereImpl{T}"/> class. + /// </summary> + /// <param name="siphon"> + /// The <see cref="Siphon"/> object. + /// </param> + /// <param name="text"> + /// The prefix statement. + /// </param> + /// <param name="parameters"> + /// Immutable key-value pairs. + /// </param> + internal WhereImpl( + Siphon siphon, + string text, + IReadOnlyDictionary<string, object> parameters) + { + Siphon = siphon; + Text = text; + Parameters = parameters; + } + + private Siphon Siphon { get; } + + private string Text { get; } + + private IReadOnlyDictionary<string, object> Parameters { get; } + + /// <inheritdoc/> + public IEnumerable<T> Execute() + { + return Execute(Text); + } + + /// <inheritdoc/> + public IEnumerable<T> OrderBy(params string[] columns) + { + var orderBy = string.Join(", ", columns); + var text = $"{Text} ORDER BY {orderBy}"; + return Execute(text); + } + + private IEnumerable<T> Execute(string text) + { + using var reader = Siphon.ExecuteReader(text, Parameters); + return reader.NewInstances<T>() + .ToList(); + } +} diff --git a/SqlBind/Maroontress/SqlBind/Impl/WildField.cs b/SqlBind/Maroontress/SqlBind/Impl/WildField.cs new file mode 100644 index 0000000..ba63108 --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/Impl/WildField.cs @@ -0,0 +1,36 @@ +namespace Maroontress.SqlBind.Impl; + +using System.Reflection; + +/// <summary> +/// The interface of <see cref="Field{T}"/> that provides non-generic +/// properties. +/// </summary> +public interface WildField +{ + /// <summary> + /// Gets the parameter name. + /// </summary> + string ParameterName { get; } + + /// <summary> + /// Gets a string representing the column definition. + /// </summary> + string ColumnDefinition { get; } + + /// <summary> + /// Gets the column definition. + /// </summary> + string ColumnName { get; } + + /// <summary> + /// Gets the property. + /// </summary> + PropertyInfo PropertyInfo { get; } + + /// <summary> + /// Gets a value indicating whether the field is qualified with + /// <c>AUTOINCREMENT</c>. + /// </summary> + bool IsAutoIncrement { get; } +} diff --git a/SqlBind/Maroontress/SqlBind/Impl/WildMetadata.cs b/SqlBind/Maroontress/SqlBind/Impl/WildMetadata.cs new file mode 100644 index 0000000..ad3d7fc --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/Impl/WildMetadata.cs @@ -0,0 +1,86 @@ +namespace Maroontress.SqlBind.Impl; + +using System; +using System.Collections.Generic; + +/// <summary> +/// The interface of <see cref="Metadata{T}"/> that provides non-generic +/// properties. +/// </summary> +public interface WildMetadata +{ + /// <summary> + /// Gets the SQL statements to create the table and indexes. + /// </summary> + IEnumerable<string> CreateTableStatements { get; } + + /// <summary> + /// Gets the SQL statements to drop the table and indexes. + /// </summary> + IEnumerable<string> DropTableStatements { get; } + + /// <summary> + /// Gets the insert statement. + /// </summary> + string InsertStatement { get; } + + /// <summary> + /// Gets the name of the table. + /// </summary> + string TableName { get; } + + /// <summary> + /// Gets the delete statement. + /// </summary> + string DeleteStatement { get; } + + /// <summary> + /// Gets a complete <c>SELECT</c> statement like <c>SELECT</c> ... + /// <c>FROM</c> ... for all rows. + /// </summary> + string SelectAllStatement { get; } + + /// <summary> + /// Gets a new string representing the <c>SELECT</c> statement to find the + /// row where the specified column is equal to the specific value. + /// </summary> + /// <param name="columnName"> + /// The column name. This must contain the <see cref="Field{T}"/> object + /// that has the same name. + /// </param> + /// <returns> + /// The new string: <c>SELECT</c> ... <c>FROM</c> ... <c>WHERE columnName = + /// $columnName</c>. + /// </returns> + /// <exception cref="ArgumentException"> + /// Throws if this does not contain the <see cref="Field{T}"/> object where + /// the column name is equal to <paramref name="columnName"/>. + /// </exception> + string NewSelectStatement(string columnName); + + /// <summary> + /// Gets a new string representing the incomplete <c>select</c> statement. + /// </summary> + /// <param name="alias"> + /// The alias name for the table with which <c>this</c> is associated. + /// represents. + /// </param> + /// <returns> + /// The new string: <c>SELECT</c> ... <c>FROM</c> ... <c>alias</c>. + /// </returns> + string NewSelectAllStatement(string alias); + + /// <summary> + /// Gets the column name associated with the specified parameter name. + /// </summary> + /// <param name="parameterName"> + /// The parameter name that the primary constructor of <c>T</c> contains. + /// </param> + /// <returns> + /// The column name. + /// </returns> + /// <exception cref="ArgumentException"> + /// Throws if <paramref name="parameterName"/> is not found. + /// </exception> + string ToColumnName(string parameterName); +} diff --git a/SqlBind/Maroontress/SqlBind/IndexedColumnsAttribute.cs b/SqlBind/Maroontress/SqlBind/IndexedColumnsAttribute.cs new file mode 100644 index 0000000..0d973f3 --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/IndexedColumnsAttribute.cs @@ -0,0 +1,58 @@ +namespace Maroontress.SqlBind; + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +/// <summary> +/// An attribute that qualifies any class representing a table, creating the +/// index of the table and using the specified column names for the index key. +/// </summary> +/// <remarks> +/// See: <a href="https://www.sqlite.org/lang_createindex.html">SQLite CREATE +/// INDEX</a>. +/// </remarks> +/// <example> +/// <code> +/// [Table("persons")] +/// [IndexedColumns("firstNameId")] +/// [IndexedColumns("lastNameId")] +/// [IndexedColumns("firstNameId", "lastNameId")] +/// public record class PersonRow( +/// [Column("id")][PrimaryKey][AutoIncrement] long Id, +/// [Column("firstNameId")] long firstNameId, +/// [Column("lastNameId")] long lastNameId) +/// { +/// } +/// +/// [Table("string")] +/// public record class StringRow( +/// [Column("id")][PrimaryKey][AutoIncrement] long Id, +/// [Column("value")][Unique] string Value) +/// { +/// }{ +/// </code> +/// </example> +[AttributeUsage( + AttributeTargets.Class, + Inherited = false, + AllowMultiple = true)] +public sealed class IndexedColumnsAttribute : Attribute +{ + /// <summary> + /// Initializes a new instance of the <see cref="IndexedColumnsAttribute"/> + /// class. + /// </summary> + /// <param name="names"> + /// The column names used for the index key. + /// </param> + public IndexedColumnsAttribute(params string[] names) + { + Names = names.ToImmutableArray(); + } + + /// <summary> + /// Gets the column names used for the index key. + /// </summary> + public IReadOnlyList<string> Names { get; } +} diff --git a/SqlBind/Maroontress/SqlBind/PrimaryKeyAttribute.cs b/SqlBind/Maroontress/SqlBind/PrimaryKeyAttribute.cs new file mode 100644 index 0000000..68cc8fa --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/PrimaryKeyAttribute.cs @@ -0,0 +1,26 @@ +namespace Maroontress.SqlBind; + +using System; + +/// <summary> +/// An attribute that qualifies any parameter in the constructor, making the +/// column associated with the parameter a <c>PRIMARY KEY</c> field. +/// </summary> +/// <remarks> +/// <para> +/// This attribute can be specified for at most one of the parameters of the +/// constructor. +/// </para> +/// <para> +/// See: <a +/// href="https://www.sqlite.org/lang_createtable.html#primkeyconst">SQLite +/// CREATE TABLE</a>. +/// </para> +/// </remarks> +[AttributeUsage( + AttributeTargets.Parameter, + Inherited = false, + AllowMultiple = false)] +public sealed class PrimaryKeyAttribute : Attribute +{ +} diff --git a/SqlBind/Maroontress/SqlBind/Query.cs b/SqlBind/Maroontress/SqlBind/Query.cs new file mode 100644 index 0000000..e3abb4d --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/Query.cs @@ -0,0 +1,173 @@ +namespace Maroontress.SqlBind; + +using System; +using System.Collections.Generic; +using System.Linq; + +/// <summary> +/// Represents a query. +/// </summary> +public interface Query +{ + /// <summary> + /// Selects all columns of the specified table. + /// </summary> + /// <typeparam name="T"> + /// The type qualified with <see cref="TableAttribute"/>. + /// </typeparam> + /// <param name="alias"> + /// The alias of the table name. + /// </param> + /// <returns> + /// The <see cref="SelectFrom{T}"/> object. + /// </returns> + SelectFrom<T> SelectAllFrom<T>(string alias) + where T : notnull; + + /// <summary> + /// Gets the <see cref="Select{T}"/> object to select the specified columns + /// of the specified table. + /// </summary> + /// <typeparam name="T"> + /// The type representing the result of this query, which must have the + /// single constructor accepting the parameters corresponding to <paramref + /// name="columns"/>. + /// </typeparam> + /// <param name="columns"> + /// One or more column names. Each consists of the alias followed by a + /// period (<c>.</c>) and the column name. + /// </param> + /// <returns> + /// The <see cref="Select{T}"/> object. + /// </returns> + Select<T> Select<T>(params string[] columns) + where T : notnull; + + /// <summary> + /// Gets the <see cref="DeleteFrom{T}"/> object to delete the specified + /// rows of the specified table. + /// </summary> + /// <typeparam name="T"> + /// The type qualified with <see cref="TableAttribute"/>. + /// </typeparam> + /// <returns> + /// The <see cref="DeleteFrom{T}"/> object. + /// </returns> + DeleteFrom<T> DeleteFrom<T>() + where T : notnull; + + /// <summary> + /// Creates new tables. + /// </summary> + /// <remarks> + /// This method drops the tables if they exist and then creates them newly. + /// </remarks> + /// <param name="tables"> + /// The types qualified with <see cref="TableAttribute"/> representing + /// the tables to create. + /// </param> + void NewTables(params Type[] tables); + + /// <summary> + /// Creates new tables. + /// </summary> + /// <remarks> + /// This method drops the tables if they exist and then creates them newly. + /// </remarks> + /// <param name="allTables"> + /// The types qualified with <see cref="TableAttribute"/> representing + /// the tables to create. + /// </param> + void NewTables(IEnumerable<Type> allTables); + + /// <summary> + /// Inserts a new row into the table. + /// </summary> + /// <typeparam name="T"> + /// The type qualified with <see cref="TableAttribute"/> representing + /// the table. + /// </typeparam> + /// <param name="row"> + /// The row of the table to add. + /// </param> + void Insert<T>(T row) + where T : notnull; + + /// <summary> + /// Inserts a new row into the table and gets its ID. + /// </summary> + /// <typeparam name="T"> + /// The type qualified with <see cref="TableAttribute"/> representing the + /// table. It must have the field qualified with <see + /// cref="AutoIncrementAttribute"/>. + /// </typeparam> + /// <param name="row"> + /// The row of the table to add. + /// </param> + /// <returns> + /// The ID of the row newly inserted. + /// </returns> + long InsertAndGetRowId<T>(T row) + where T : notnull; + + /// <summary> + /// Gets the single row of the table in which the specified unique field + /// matches the specified value. + /// </summary> + /// <typeparam name="T"> + /// The type qualified with <see cref="TableAttribute"/> representing the + /// table. It must have the field qualified with <see + /// cref="UniqueAttribute"/>. + /// </typeparam> + /// <param name="columnName"> + /// The name of the unique field. + /// </param> + /// <param name="value"> + /// The value that the unique field matches. + /// </param> + /// <returns> + /// The row in which the field specified with <paramref name="columnName"/> + /// matches the specified <paramref name="value"/> if it exists, + /// <c>null</c> otherwise. + /// </returns> + T? SelectUnique<T>(string columnName, object value) + where T : class; + + /// <summary> + /// Gets all the rows of the specified table. + /// </summary> + /// <remarks> + /// The <see cref="IEnumerable{T}"/> object returned by this method is + /// valid as long as the transaction is in progress. If you need access to + /// it after the transaction, you must use something like the + /// <see cref="Enumerable.ToList{TSource}(IEnumerable{TSource})"/> + /// method to get the list in the meantime and then use it afterward. + /// </remarks> + /// <typeparam name="T"> + /// The type represents the row of the table. + /// </typeparam> + /// <returns> + /// All the rows of the specified table. + /// </returns> + IEnumerable<T> SelectAll<T>() + where T : notnull; + + /// <summary> + /// Gets the column name associated with the specified parameter. + /// </summary> + /// <typeparam name="T"> + /// The type represents the row of the table. + /// </typeparam> + /// <param name="parameterName"> + /// The parameter name that the constructor of <typeparamref name="T"/> + /// contains. + /// </param> + /// <returns> + /// The column name. + /// </returns> + /// <exception cref="ArgumentException"> + /// Throws if <paramref name="parameterName"/> is not found. + /// </exception> + string ColumnName<T>(string parameterName) + where T : notnull; +} diff --git a/SqlBind/Maroontress/SqlBind/Select.cs b/SqlBind/Maroontress/SqlBind/Select.cs new file mode 100644 index 0000000..4f76713 --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/Select.cs @@ -0,0 +1,28 @@ +#pragma warning disable SingleTypeParameter + +namespace Maroontress.SqlBind; + +/// <summary> +/// Represents the <c>SELECT</c> statement in SQL. +/// </summary> +/// <typeparam name="T"> +/// The type of the class representing any row of the result of the query. +/// </typeparam> +public interface Select<T> + where T : notnull +{ + /// <summary> + /// Gets a new <see cref="SelectFrom{T}"/> object. + /// </summary> + /// <typeparam name="U"> + /// The type of the class qualified with the <see cref="TableAttribute"/>. + /// </typeparam> + /// <param name="alias"> + /// The alias name of the table that <typeparamref name="U"/> represents. + /// </param> + /// <returns> + /// The new <see cref="SelectFrom{T}"/> object. + /// </returns> + SelectFrom<T> From<U>(string alias) + where U : notnull; +} diff --git a/SqlBind/Maroontress/SqlBind/SelectFrom.cs b/SqlBind/Maroontress/SqlBind/SelectFrom.cs new file mode 100644 index 0000000..ecd0451 --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/SelectFrom.cs @@ -0,0 +1,66 @@ +#pragma warning disable SingleTypeParameter + +namespace Maroontress.SqlBind; + +using System.Collections.Generic; +using Maroontress.SqlBind.Impl; + +/// <summary> +/// Represents the <c>SELECT</c> statement in SQL without a <c>WHERE</c> +/// clause. It can end with a <c>INNER JOIN</c> clause. +/// </summary> +/// <typeparam name="T"> +/// The type of the class representing any row of the result of the query. +/// </typeparam> +public interface SelectFrom<T> : TerminalOperation<T> + where T : notnull +{ + /// <summary> + /// Gets a new <see cref="SelectFrom{T}"/> object, which represents + /// the combination of <c>this</c> (<c>SELECT</c> ... <c>From</c> ...) + /// and the <c>INNER JOIN</c> ... <c>ON</c> ... clause with the table that + /// <typeparamref name="U"/> represents. + /// </summary> + /// <remarks> + /// The object that this method returns represents <c>SELECT ... FROM ... + /// INNER JOIN</c> "the table name of U" <paramref name="alias"/> <c>ON</c> + /// <paramref name="constraint"/>. + /// </remarks> + /// <typeparam name="U"> + /// The type of the class qualified with the <see cref="TableAttribute"/>. + /// </typeparam> + /// <param name="alias"> + /// The alias name of the table that <typeparamref name="U"/> represents. + /// </param> + /// <param name="constraint"> + /// The expression following <c>ON</c>. + /// </param> + /// <returns> + /// The new <see cref="SelectFrom{T}"/> object. + /// </returns> + SelectFrom<T> InnerJoin<U>(string alias, string constraint) + where U : notnull; + + /// <summary> + /// Gets a new <see cref="WhereImpl{T}"/> object, which represents + /// the combination of <c>this</c> (<c>SELECT</c> ... <c>From</c> ...) + /// and the <c>WHERE</c> ... clause. + /// </summary> + /// <remarks> + /// The object that this method returns represents <c>SELECT ... FROM ... + /// WHERE</c> <paramref name="condition"/>. + /// </remarks> + /// <param name="condition"> + /// The condition of the <c>WHERE</c> clause. + /// </param> + /// <param name="parameters"> + /// Immutable key-value pairs. The <paramref name="condition"/> must + /// contain all the keys. Each value must be of the appropriate type. + /// </param> + /// <returns> + /// The new <see cref="WhereImpl{T}"/> object. + /// </returns> + Where<T> Where( + string condition, + IReadOnlyDictionary<string, object> parameters); +} diff --git a/SqlBind/Maroontress/SqlBind/TableAttribute.cs b/SqlBind/Maroontress/SqlBind/TableAttribute.cs new file mode 100644 index 0000000..dc4b553 --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/TableAttribute.cs @@ -0,0 +1,55 @@ +namespace Maroontress.SqlBind; + +using System; + +/// <summary> +/// An attribute that qualifies any class representing a row of a table, +/// associating the class with the table that has the specified name. +/// </summary> +/// <remarks> +/// <para>The class that this attribute qualifies must have a single +/// constructor, every parameter of which must be qualified with <see +/// cref="ColumnAttribute"/>. And the class must have the properties +/// corresponding to those parameters. Each property must have the same name +/// as the corresponding parameter, in the simular way of a <c>record</c> +/// class.</para> +/// <para>See: +/// <a href="https://www.sqlite.org/lang_createtable.html">SQLite CREATE +/// TABLE</a>. +/// </para> +/// </remarks> +/// <example> +/// <code> +/// using Maroontress.SqlBind; +/// +/// [Table("properties")] +/// public record class PropertyRow( +/// [Column("key")][Unique] string Key, +/// [Column("value")] string Value) +/// { +/// } +/// </code> +/// </example> +/// <seealso cref="ColumnAttribute"/> +[AttributeUsage( + AttributeTargets.Class, + Inherited = false, + AllowMultiple = false)] +public sealed class TableAttribute : Attribute +{ + /// <summary> + /// Initializes a new instance of the <see cref="TableAttribute"/> class. + /// </summary> + /// <param name="name"> + /// The table name. + /// </param> + public TableAttribute(string name) + { + Name = name; + } + + /// <summary> + /// Gets the table name. + /// </summary> + public string Name { get; } +} diff --git a/SqlBind/Maroontress/SqlBind/TerminalOperation.cs b/SqlBind/Maroontress/SqlBind/TerminalOperation.cs new file mode 100644 index 0000000..a984623 --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/TerminalOperation.cs @@ -0,0 +1,39 @@ +namespace Maroontress.SqlBind; + +using System.Collections.Generic; + +/// <summary> +/// Represents the executable <c>SELECT</c> statement in SQL. +/// </summary> +/// <typeparam name="T"> +/// The type of the class representing any row of the result of the query. +/// </typeparam> +public interface TerminalOperation<T> + where T : notnull +{ + /// <summary> + /// Executes the query and gets the result. + /// </summary> + /// <returns> + /// The <typeparamref name="T"/> objects representing the result of the + /// query. + /// </returns> + IEnumerable<T> Execute(); + + /// <summary> + /// Executes the query and gets the result in the order sorted by the + /// specified columns. + /// </summary> + /// <remarks> + /// The result that this method returns represents that of <c>SELECT + /// ... FROM ... ORDER BY</c> <paramref name="columns"/>. + /// </remarks> + /// <param name="columns"> + /// The columns to sort the rows of the result by. + /// </param> + /// <returns> + /// The <typeparamref name="T"/> objects representing the result of the + /// query. + /// </returns> + IEnumerable<T> OrderBy(params string[] columns); +} diff --git a/SqlBind/Maroontress/SqlBind/TransactionKit.cs b/SqlBind/Maroontress/SqlBind/TransactionKit.cs new file mode 100644 index 0000000..05acc84 --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/TransactionKit.cs @@ -0,0 +1,96 @@ +namespace Maroontress.SqlBind; + +using System; +using Maroontress.SqlBind.Impl; + +/// <summary> +/// The factory that creates queries. +/// </summary> +public sealed class TransactionKit +{ + /// <summary> + /// Initializes a new instance of the <see cref="TransactionKit"/> class. + /// </summary> + /// <param name="databasePath"> + /// The path of the database file. + /// </param> + /// <param name="logger"> + /// The logger. + /// </param> + public TransactionKit(string databasePath, Action<Func<string>> logger) + { + DatabasePath = databasePath; + Logger = logger; + Cache = new(); + } + + private string DatabasePath { get; } + + private Action<Func<string>> Logger { get; } + + private MetadataBank Cache { get; } + + /// <summary> + /// Executes queries within a single transaction. + /// </summary> + /// <remarks> + /// If the <paramref name="action"/> throws an exception, this method + /// performs the rollback. + /// </remarks> + /// <param name="action"> + /// The action that takes a <see cref="Query"/> object. + /// </param> + public void Execute(Action<Query> action) + { + var kit = Toolkit.Instance; + using var link = kit.NewDatabaseLink(DatabasePath); + using var x = link.BeginTransaction(); + try + { + var s = link.NewSiphon(Logger); + action(new QueryImpl(s, Cache)); + x.Commit(); + } + catch (Exception) + { + x.Rollback(); + throw; + } + } + + /// <summary> + /// Executes queries within a single transaction and returns the result. + /// </summary> + /// <remarks> + /// If the <paramref name="apply"/> throws an exception, this method + /// performs the rollback. + /// </remarks> + /// <typeparam name="T"> + /// The type of the result. + /// </typeparam> + /// <param name="apply"> + /// The function that takes a <see cref="Query"/> object and returns the + /// result. + /// </param> + /// <returns> + /// The result. + /// </returns> + public T Execute<T>(Func<Query, T> apply) + { + var kit = Toolkit.Instance; + using var link = kit.NewDatabaseLink(DatabasePath); + using var x = link.BeginTransaction(); + try + { + var s = link.NewSiphon(Logger); + var o = apply(new QueryImpl(s, Cache)); + x.Commit(); + return o; + } + catch (Exception) + { + x.Rollback(); + throw; + } + } +} diff --git a/SqlBind/Maroontress/SqlBind/UniqueAttribute.cs b/SqlBind/Maroontress/SqlBind/UniqueAttribute.cs new file mode 100644 index 0000000..b57e147 --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/UniqueAttribute.cs @@ -0,0 +1,26 @@ +namespace Maroontress.SqlBind; + +using System; + +/// <summary> +/// An attribute that qualifies any parameter in the constructor, making the +/// column associated with the parameter a <c>UNIQUE</c> field. +/// </summary> +/// <remarks> +/// <para> +/// This attribute can be specified for at most one of the parameters of the +/// constructor. +/// </para> +/// <para> +/// See: <a +/// href="https://www.sqlite.org/lang_createtable.html#unique_constraints"> +/// SQLite CREATE TABLE</a>. +/// </para> +/// </remarks> +[AttributeUsage( + AttributeTargets.Parameter, + Inherited = false, + AllowMultiple = false)] +public sealed class UniqueAttribute : Attribute +{ +} diff --git a/SqlBind/Maroontress/SqlBind/Where.cs b/SqlBind/Maroontress/SqlBind/Where.cs new file mode 100644 index 0000000..e040cba --- /dev/null +++ b/SqlBind/Maroontress/SqlBind/Where.cs @@ -0,0 +1,12 @@ +namespace Maroontress.SqlBind; + +/// <summary> +/// Represents the <c>SELECT</c> ... <c>WHERE</c> ... statement in SQL. +/// </summary> +/// <typeparam name="T"> +/// The type of the class representing any row of the result of the query. +/// </typeparam> +public interface Where<T> : TerminalOperation<T> + where T : notnull +{ +} diff --git a/SqlBind/SqlBind.csproj b/SqlBind/SqlBind.csproj new file mode 100644 index 0000000..1e77733 --- /dev/null +++ b/SqlBind/SqlBind.csproj @@ -0,0 +1,67 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>netstandard2.1</TargetFramework> + <LangVersion>10</LangVersion> + <Nullable>enable</Nullable> + <EnforceCodeStyleInBuild>True</EnforceCodeStyleInBuild> + <EnableNETAnalyzers>True</EnableNETAnalyzers> + <GenerateDocumentationFile>True</GenerateDocumentationFile> + <DocumentationFile>dcx/Maroontress.SqlBind.xml</DocumentationFile> + <AssemblyName>Maroontress.SqlBind</AssemblyName> + <RootNamespace>Maroontress.SqlBind</RootNamespace> + </PropertyGroup> + + <PropertyGroup> + <PackageId>Maroontress.SqlBind</PackageId> + <PackageVersion>$(Version)</PackageVersion> + <Authors>Tomohisa Tanaka</Authors> + <PackageProjectUrl>https://maroontress.github.io/SqlBind-CSharp/</PackageProjectUrl> + <RepositoryUrl>https://github.com/maroontress/SqlBind.CSharp</RepositoryUrl> + <PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance> + <Description>Maroontress.SqlBind is a C# class library that is a wrapper for SQLite.</Description> + <PackageReleaseNotes>See https://maroontress.github.io/SqlBind-CSharp/releasenotes.html</PackageReleaseNotes> + <Copyright>Copyright (c) 2023 Maroontress Fast Software</Copyright> + <PackageTags>sqlite</PackageTags> + <NoPackageAnalysis>true</NoPackageAnalysis> + <Version>1.0.0.0</Version> + <RepositoryType /> + <Company>Maroontress Fast Software</Company> + <PackageReadmeFile>README.md</PackageReadmeFile> + <PackageLicenseFile>COPYRIGHT.txt</PackageLicenseFile> + </PropertyGroup> + + <ItemGroup> + <None Include="..\README.md" Pack="true" PackagePath="\" /> + <None Remove="Maroontress\SqlBind\.namespace.xml" /> + <None Remove="Maroontress\SqlBind\Impl\.namespace.xml" /> + </ItemGroup> + + <ItemGroup> + <AdditionalFiles Include="Maroontress\SqlBind\.namespace.xml" /> + <AdditionalFiles Include="Maroontress\SqlBind\Impl\.namespace.xml" /> + </ItemGroup> + + <ItemGroup> + <Content Include="nuget\readme.txt"> + <Pack>true</Pack> + <PackagePath>\</PackagePath> + </Content> + <Content Include="nuget\COPYRIGHT.txt"> + <Pack>true</Pack> + <PackagePath>\</PackagePath> + </Content> + <Content Include="nuget\LEGAL_NOTICES.txt"> + <Pack>true</Pack> + <PackagePath>\</PackagePath> + </Content> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="StyleChecker" Version="1.0.27" PrivateAssets="all" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="all" /> + <PackageReference Include="System.Collections.Immutable" Version="7.0.0" /> + <PackageReference Include="Microsoft.Data.Sqlite" Version="7.0.5" /> + </ItemGroup> + +</Project> diff --git a/SqlBind/namespaces.aaf b/SqlBind/namespaces.aaf new file mode 100644 index 0000000..ccd9340 --- /dev/null +++ b/SqlBind/namespaces.aaf @@ -0,0 +1,2 @@ +Maroontress/SqlBind/.namespace.xml +Maroontress/SqlBind/Impl/.namespace.xml diff --git a/SqlBind/nuget/COPYRIGHT.txt b/SqlBind/nuget/COPYRIGHT.txt new file mode 100644 index 0000000..8aedb33 --- /dev/null +++ b/SqlBind/nuget/COPYRIGHT.txt @@ -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/SqlBind/nuget/LEGAL_NOTICES.txt b/SqlBind/nuget/LEGAL_NOTICES.txt new file mode 100644 index 0000000..fe2d7b9 --- /dev/null +++ b/SqlBind/nuget/LEGAL_NOTICES.txt @@ -0,0 +1,4 @@ +Acknowledgments: + +Portions of this software may utilize the following copyrighted materials, +the use of which is hereby acknowledged. diff --git a/SqlBind/nuget/readme.txt b/SqlBind/nuget/readme.txt new file mode 100644 index 0000000..89b34d8 --- /dev/null +++ b/SqlBind/nuget/readme.txt @@ -0,0 +1,3 @@ +Maroontress.SqlBind NuGet README + +See https://maroontress.github.io/SqlBind-CSharp/