diff --git a/source/Nevermore.IntegrationTests/Model/Endpoint.cs b/source/Nevermore.IntegrationTests/Model/Endpoint.cs index 5cc9de88..9a71923d 100644 --- a/source/Nevermore.IntegrationTests/Model/Endpoint.cs +++ b/source/Nevermore.IntegrationTests/Model/Endpoint.cs @@ -5,5 +5,7 @@ public abstract class Endpoint public string Name { get; set; } public abstract string Type { get; } + + public bool IsEnabled { get; set; } } } \ No newline at end of file diff --git a/source/Nevermore.IntegrationTests/QueryableIntegrationFixture.cs b/source/Nevermore.IntegrationTests/QueryableIntegrationFixture.cs index 56f628ba..b9788095 100644 --- a/source/Nevermore.IntegrationTests/QueryableIntegrationFixture.cs +++ b/source/Nevermore.IntegrationTests/QueryableIntegrationFixture.cs @@ -13,6 +13,124 @@ namespace Nevermore.IntegrationTests { public class QueryableIntegrationFixture : FixtureWithRelationalStore { + [Test] + public void SelectColumn() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = "Alice", LastName = "Apple" }, + new Customer { FirstName = "Bob", LastName = "Banana" }, + new Customer { FirstName = "Charlie", LastName = "Cherry" } + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var customers = t.Queryable() + .Select(c => c.FirstName) + .ToList(); + + customers.Should().BeEquivalentTo("Alice", "Bob", "Charlie"); + } + + [Test] + public async Task SelectJsonField() + { + using var t = Store.BeginTransaction(); + + var testMachines = new[] + { + new Machine { Name = "Machine A", Endpoint = new PassiveTentacleEndpoint { Name = "Tentacle A" } }, + new Machine { Name = "Machine B", }, + new Machine { Name = "Machine C", Endpoint = new PassiveTentacleEndpoint { Name = "Tentacle C" } }, + }; + + foreach (var c in testMachines) + { + t.Insert(c); + } + + await t.CommitAsync(); + + var machines = t.Queryable() + .Select(c => c.Endpoint.Name) + .ToList(); + + machines.Should().BeEquivalentTo("Tentacle A", null, "Tentacle C"); + } + + [Test] + public async Task SelectWithParameterizedConstructor() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = "Alice", LastName = "Apple", IsEmployee = true }, + new Customer { FirstName = "Bob", LastName = "Banana", IsEmployee = false }, + new Customer { FirstName = "Charlie", LastName = "Cherry", IsEmployee = true } + }; + + await t.InsertManyAsync(testCustomers); + await t.CommitAsync(); + + var customers = await t.Queryable() + .Select(c => new CustomerProjection(c.FirstName, c.IsEmployee)) + .ToListAsync(); + + customers.Should().BeEquivalentTo(new[] + { + new CustomerProjection("Alice", true), + new CustomerProjection("Bob", false), + new CustomerProjection("Charlie", true) + }); + } + + class CustomerProjection + { + public CustomerProjection(string firstName, bool isEmployee) + { + FirstName = firstName; + IsEmployee = isEmployee; + } + + public string FirstName { get; } + public bool IsEmployee { get; } + } + + [Test] + public async Task SelectWithAnonymousType() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = "Alice", LastName = "Apple", IsEmployee = true }, + new Customer { FirstName = "Bob", LastName = "Banana", IsEmployee = false }, + new Customer { FirstName = "Charlie", LastName = "Cherry", IsEmployee = true } + }; + + await t.InsertManyAsync(testCustomers); + await t.CommitAsync(); + + var customers = await t.Queryable() + .Select(c => new { Name = c.FirstName, c.IsEmployee }) + .ToListAsync(); + + customers.Should().BeEquivalentTo(new[] + { + new {Name = "Alice", IsEmployee = true}, + new {Name = "Bob", IsEmployee = false}, + new {Name = "Charlie", IsEmployee = true}, + }); + } + [Test] public void WhereEqual() { @@ -39,6 +157,136 @@ public void WhereEqual() customers.Select(c => c.LastName).Should().BeEquivalentTo("Apple"); } + [Test] + public void WhereEqualStringEquals() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = "Alice", LastName = "Apple" }, + new Customer { FirstName = "Bob", LastName = "Banana" }, + new Customer { FirstName = "Charlie", LastName = "Cherry" } + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var customers = t.Queryable() + .Where(c => c.FirstName.Equals("Alice")) + .ToList(); + + customers.Select(c => c.LastName).Should().BeEquivalentTo("Apple"); + } + + [Test] + public void WhereEqualIgnoreCase() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = "Alice", LastName = "Apple" }, + new Customer { FirstName = "Bob", LastName = "Banana" }, + new Customer { FirstName = "Charlie", LastName = "Cherry" } + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var customers = t.Queryable() + .Where(c => c.FirstName.Equals("alice", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + customers.Select(c => c.LastName).Should().BeEquivalentTo("Apple"); + } + + [Test] + public void WhereEqualLower() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = "Alice", LastName = "Apple" }, + new Customer { FirstName = "Bob", LastName = "Banana" }, + new Customer { FirstName = "Charlie", LastName = "Cherry" } + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var customers = t.Queryable() + .Where(c => c.FirstName.ToLower() == "alice") + .ToList(); + + customers.Select(c => c.LastName).Should().BeEquivalentTo("Apple"); + } + + [Test] + public void WhereEqualUpper() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = "Alice", LastName = "Apple" }, + new Customer { FirstName = "Bob", LastName = "Banana" }, + new Customer { FirstName = "Charlie", LastName = "Cherry" } + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var customers = t.Queryable() + .Where(c => c.FirstName.ToUpper() == "ALICE") + .ToList(); + + customers.Select(c => c.LastName).Should().BeEquivalentTo("Apple"); + } + + [Test] + public void WhereEqualTrim() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = " Alice ", LastName = "Apple" }, + new Customer { FirstName = "Bob", LastName = "Banana" }, + new Customer { FirstName = "Charlie", LastName = "Cherry" } + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var customers = t.Queryable() + .Where(c => c.FirstName.Trim() == "Alice") + .ToList(); + + customers.Select(c => c.LastName).Should().BeEquivalentTo("Apple"); + } + [Test] public void WhereEqualIdColumn() { @@ -458,6 +706,58 @@ public void WhereNotUnaryBoolJson() customers.Select(c => c.LastName).Should().BeEquivalentTo("Apple", "Cherry"); } + [Test] + public void WhereUnaryBoolNestedJson() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Machine { Name = "Machine A", Endpoint = new PassiveTentacleEndpoint { Name = "Tentacle A", IsEnabled = true} }, + new Machine { Name = "Machine B", Endpoint = new PassiveTentacleEndpoint { Name = "Tentacle B", IsEnabled = true } }, + new Machine { Name = "Machine C", Endpoint = new PassiveTentacleEndpoint { Name = "Tentacle C", IsEnabled = false } }, + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var customers = t.Queryable() + .Where(c => c.Endpoint.IsEnabled) + .ToList(); + + customers.Select(c => c.Name).Should().BeEquivalentTo("Machine A", "Machine B"); + } + + [Test] + public void WhereNotUnaryBoolNestedJson() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Machine { Name = "Machine A", Endpoint = new PassiveTentacleEndpoint { Name = "Tentacle A", IsEnabled = true} }, + new Machine { Name = "Machine B", Endpoint = new PassiveTentacleEndpoint { Name = "Tentacle B", IsEnabled = true } }, + new Machine { Name = "Machine C", Endpoint = new PassiveTentacleEndpoint { Name = "Tentacle C", IsEnabled = false } }, + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var customers = t.Queryable() + .Where(c => !c.Endpoint.IsEnabled) + .ToList(); + + customers.Select(c => c.Name).Should().BeEquivalentTo("Machine C"); + } + [Test] public void WhereCompositeAnd() { @@ -507,19 +807,516 @@ public void WhereCompositeOr() .Where(c => c.Balance < 40m || c.IsEmployee || c.LastName.Contains("n")) .ToList(); - customers.Select(c => c.LastName).Should().BeEquivalentTo("Apple", "Banana"); + customers.Select(c => c.LastName).Should().BeEquivalentTo("Apple", "Banana"); + } + + [Test] + public void WhereContains() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = "Alice", LastName = "Apple", Balance = 987.4m }, + new Customer { FirstName = "Bob", LastName = "Banana", Balance = 56.3m }, + new Customer { FirstName = "Charlie", LastName = "Cherry", Balance = 301.4m } + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var names = new[] { "Apple", "Orange", "Peach" }; + var customers = t.Queryable() + .Where(c => names.Contains(c.LastName)) + .ToList(); + + customers.Select(c => c.FirstName).Should().BeEquivalentTo("Alice"); + } + + [Test] + public void WhereLowerContains() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = "Alice", LastName = "Apple", Balance = 987.4m }, + new Customer { FirstName = "Bob", LastName = "Banana", Balance = 56.3m }, + new Customer { FirstName = "Charlie", LastName = "Cherry", Balance = 301.4m } + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var names = new[] { "apple", "orange", "peach" }; + var customers = t.Queryable() + .Where(c => names.Contains(c.LastName.ToLower())) + .ToList(); + + customers.Select(c => c.FirstName).Should().BeEquivalentTo("Alice"); + } + + [Test] + public void WhereLowerStringContains() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = "Alice", LastName = "Apple", Balance = 987.4m }, + new Customer { FirstName = "Bob", LastName = "Banana", Balance = 56.3m }, + new Customer { FirstName = "Charlie", LastName = "Cherry", Balance = 301.4m } + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var customers = t.Queryable() + .Where(c => c.FirstName.ToLower().Contains("a")) + .ToList(); + + customers.Select(c => c.FirstName).Should().BeEquivalentTo("Alice", "Charlie"); + } + + [Test] + public void WhereUpperStringContains() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = "Alice", LastName = "Apple", Balance = 987.4m }, + new Customer { FirstName = "Bob", LastName = "Banana", Balance = 56.3m }, + new Customer { FirstName = "Charlie", LastName = "Cherry", Balance = 301.4m } + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var customers = t.Queryable() + .Where(c => c.FirstName.ToUpper().Contains("A")) + .ToList(); + + customers.Select(c => c.FirstName).Should().BeEquivalentTo("Alice", "Charlie"); + } + + [Test] + public void WhereTrimStringContains() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = " Alice ", LastName = "Apple", Balance = 987.4m }, + new Customer { FirstName = " Bob ", LastName = "Banana", Balance = 56.3m }, + new Customer { FirstName = "Charlie ", LastName = "Cherry", Balance = 301.4m } + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var customers = t.Queryable() + .Where(c => c.FirstName.Trim().Contains("e")) + .ToList(); + + customers.Select(c => c.FirstName).Should().BeEquivalentTo(" Alice ", "Charlie "); + } + + [Test] + public void WhereContainsEmpty() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = "Alice", LastName = "Apple", Balance = 987.4m }, + new Customer { FirstName = "Bob", LastName = "Banana", Balance = 56.3m }, + new Customer { FirstName = "Charlie", LastName = "Cherry", Balance = 301.4m } + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var names = Array.Empty(); + var customers = t.Queryable() + .Where(c => names.Contains(c.LastName)) + .ToList(); + + customers.Should().BeEmpty(); + } + + [Test] + public void WhereContainsOnDocument() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = "Alice", LastName = "Apple", Roles = { "RoleA", "RoleB" } }, + new Customer { FirstName = "Bob", LastName = "Banana", Roles = { "RoleA", "RoleC" } }, + new Customer { FirstName = "Charlie", LastName = "Cherry", Roles = { "RoleB", "RoleC" } } + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var customers = t.Queryable() + .Where(c => c.Roles.Contains("RoleC")) + .ToList(); + + customers.Select(c => c.FirstName).Should().BeEquivalentTo("Bob", "Charlie"); + } + + [Test] + public void WhereContainsOnDocumentJson() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = "Alice", LastName = "Apple", LuckyNumbers = new[] { 78, 321 } }, + new Customer { FirstName = "Bob", LastName = "Banana", LuckyNumbers = new[] { 662, 91 } }, + new Customer { FirstName = "Charlie", LastName = "Cherry", LuckyNumbers = new[] { 4, 18 } } + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var customers = t.Queryable() + .Where(c => c.LuckyNumbers.Contains(4)) + .ToList(); + + customers.Select(c => c.FirstName).Should().BeEquivalentTo("Charlie"); + } + + [Test] + public void WhereNotContains() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = "Alice", LastName = "Apple", Balance = 987.4m }, + new Customer { FirstName = "Bob", LastName = "Banana", Balance = 56.3m }, + new Customer { FirstName = "Charlie", LastName = "Cherry", Balance = 301.4m } + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var names = new[] { "Apple", "Orange", "Peach" }; + var customers = t.Queryable() + .Where(c => !names.Contains(c.LastName)) + .ToList(); + + customers.Select(c => c.FirstName).Should().BeEquivalentTo("Bob", "Charlie"); + } + + [Test] + public void WhereNotContainsEmpty() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = "Alice", LastName = "Apple", Balance = 987.4m }, + new Customer { FirstName = "Bob", LastName = "Banana", Balance = 56.3m }, + new Customer { FirstName = "Charlie", LastName = "Cherry", Balance = 301.4m } + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var names = Array.Empty(); + var customers = t.Queryable() + .Where(c => !names.Contains(c.LastName)) + .ToList(); + + customers.Select(c => c.FirstName).Should().BeEquivalentTo("Alice", "Bob", "Charlie"); + } + + [Test] + public void WhereNotStringContains() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = "Alice", LastName = "Apple", Nickname = "Bear" }, + new Customer { FirstName = "Bob", LastName = "Banana", Nickname = "Beets" }, + new Customer { FirstName = "Charlie", LastName = "Cherry", Nickname = "Chicken" } + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var customers = t.Queryable() + .Where(c => !c.Nickname.Contains("hi")) + .ToList(); + + customers.Select(c => c.FirstName).Should().BeEquivalentTo("Alice", "Bob"); + } + + [Test] + public void WhereParens() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = "Alice", LastName = "Apple" }, + new Customer { FirstName = "Alice", LastName = "Banana" }, + new Customer { FirstName = "Bob", LastName = "Apple" }, + new Customer { FirstName = "Bob", LastName = "Banana" } + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var customers = t.Queryable() + .Where(c => (c.FirstName == "Alice" && c.LastName == "Apple") || (c.FirstName == "Bob" && c.LastName == "Banana")) + .ToList(); + + customers.Select(c => c.FirstName).Should().BeEquivalentTo("Alice", "Bob"); + customers.Select(c => c.LastName).Should().BeEquivalentTo("Apple", "Banana"); + } + + [Test] + public void WhereNotParens() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = "Alice", LastName = "Apple" }, + new Customer { FirstName = "Alice", LastName = "Banana" }, + new Customer { FirstName = "Bob", LastName = "Apple" }, + new Customer { FirstName = "Bob", LastName = "Banana" } + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var customers = t.Queryable() + .Where(c => !(c.FirstName == "Alice" && c.LastName == "Apple") || !(c.FirstName == "Bob" && c.LastName == "Banana")) + .ToList(); + + customers.Select(c => c.FirstName).Should().BeEquivalentTo("Alice", "Bob"); + customers.Select(c => c.LastName).Should().BeEquivalentTo("Banana", "Apple"); + } + + [Test] + public void First() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = "Alice", LastName = "Apple" }, + new Customer { FirstName = "Bob", LastName = "Banana" }, + new Customer { FirstName = "Charlie", LastName = "Cherry" } + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var customer = t.Queryable() + .First(); + + customer.LastName.Should().BeEquivalentTo("Apple"); + } + + [Test] + public void FirstWithPredicate() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = "Alice", LastName = "Apple" }, + new Customer { FirstName = "Bob", LastName = "Banana" }, + new Customer { FirstName = "Charlie", LastName = "Cherry" } + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var customer = t.Queryable() + .First(c => c.FirstName == "Alice"); + + customer.LastName.Should().BeEquivalentTo("Apple"); + } + + [Test] + public void FirstWithNoMatches() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = "Alice", LastName = "Apple" }, + new Customer { FirstName = "Bob", LastName = "Banana" }, + new Customer { FirstName = "Charlie", LastName = "Cherry" } + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var fn = () => t.Queryable() + .First(c => c.FirstName == "Jim"); + + fn.Should().Throw().WithMessage("Sequence contains no elements"); + } + + [Test] + public void FirstOrDefault() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = "Alice", LastName = "Apple" }, + new Customer { FirstName = "Bob", LastName = "Banana" }, + new Customer { FirstName = "Charlie", LastName = "Cherry" } + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var customer = t.Queryable() + .FirstOrDefault(); + + customer.LastName.Should().BeEquivalentTo("Apple"); + } + + [Test] + public async Task FirstOrDefaultAsync() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = "Alice", LastName = "Apple" }, + new Customer { FirstName = "Bob", LastName = "Banana" }, + new Customer { FirstName = "Charlie", LastName = "Cherry" } + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + await t.CommitAsync(); + + var customer = await t.Queryable() + .FirstOrDefaultAsync(); + + customer.LastName.Should().BeEquivalentTo("Apple"); + } + + [Test] + public void FirstOrDefaultWithPredicate() + { + using var t = Store.BeginTransaction(); + + var testCustomers = new[] + { + new Customer { FirstName = "Alice", LastName = "Apple" }, + new Customer { FirstName = "Bob", LastName = "Banana" }, + new Customer { FirstName = "Charlie", LastName = "Cherry" } + }; + + foreach (var c in testCustomers) + { + t.Insert(c); + } + + t.Commit(); + + var customer = t.Queryable() + .FirstOrDefault(c => c.FirstName.EndsWith("y")); + + customer.Should().BeNull(); } [Test] - public void WhereContains() + public async Task FirstOrDefaultWithPredicateAsync() { using var t = Store.BeginTransaction(); var testCustomers = new[] { - new Customer { FirstName = "Alice", LastName = "Apple", Balance = 987.4m }, - new Customer { FirstName = "Bob", LastName = "Banana", Balance = 56.3m }, - new Customer { FirstName = "Charlie", LastName = "Cherry", Balance = 301.4m } + new Customer { FirstName = "Alice", LastName = "Apple" }, + new Customer { FirstName = "Bob", LastName = "Banana" }, + new Customer { FirstName = "Charlie", LastName = "Cherry" } }; foreach (var c in testCustomers) @@ -527,26 +1324,22 @@ public void WhereContains() t.Insert(c); } - t.Commit(); + await t.CommitAsync(); - var names = new[] { "Apple", "Orange", "Peach" }; - var customers = t.Queryable() - .Where(c => names.Contains(c.LastName)) - .ToList(); + var customer = await t.Queryable() + .FirstOrDefaultAsync(c => c.FirstName.EndsWith("y")); - customers.Select(c => c.FirstName).Should().BeEquivalentTo("Alice"); + customer.Should().BeNull(); } [Test] - public void WhereContainsEmpty() + public void Single() { using var t = Store.BeginTransaction(); var testCustomers = new[] { - new Customer { FirstName = "Alice", LastName = "Apple", Balance = 987.4m }, - new Customer { FirstName = "Bob", LastName = "Banana", Balance = 56.3m }, - new Customer { FirstName = "Charlie", LastName = "Cherry", Balance = 301.4m } + new Customer { FirstName = "Alice", LastName = "Apple" } }; foreach (var c in testCustomers) @@ -556,24 +1349,22 @@ public void WhereContainsEmpty() t.Commit(); - var names = Array.Empty(); - var customers = t.Queryable() - .Where(c => names.Contains(c.LastName)) - .ToList(); + var customer = t.Queryable() + .Single(); - customers.Should().BeEmpty(); + customer.LastName.Should().BeEquivalentTo("Apple"); } [Test] - public void WhereContainsOnDocument() + public void SingleWithPredicate() { using var t = Store.BeginTransaction(); var testCustomers = new[] { - new Customer { FirstName = "Alice", LastName = "Apple", Roles = { "RoleA", "RoleB" } }, - new Customer { FirstName = "Bob", LastName = "Banana", Roles = { "RoleA", "RoleC" } }, - new Customer { FirstName = "Charlie", LastName = "Cherry", Roles = { "RoleB", "RoleC" } } + new Customer { FirstName = "Alice", LastName = "Apple" }, + new Customer { FirstName = "Bob", LastName = "Banana" }, + new Customer { FirstName = "Charlie", LastName = "Cherry" } }; foreach (var c in testCustomers) @@ -583,23 +1374,22 @@ public void WhereContainsOnDocument() t.Commit(); - var customers = t.Queryable() - .Where(c => c.Roles.Contains("RoleC")) - .ToList(); + var customer = t.Queryable() + .Single(c => c.FirstName == "Alice"); - customers.Select(c => c.FirstName).Should().BeEquivalentTo("Bob", "Charlie"); + customer.LastName.Should().BeEquivalentTo("Apple"); } [Test] - public void WhereContainsOnDocumentJson() + public void SingleWithNoMatches() { using var t = Store.BeginTransaction(); var testCustomers = new[] { - new Customer { FirstName = "Alice", LastName = "Apple", LuckyNumbers = new[] { 78, 321 } }, - new Customer { FirstName = "Bob", LastName = "Banana", LuckyNumbers = new[] { 662, 91 } }, - new Customer { FirstName = "Charlie", LastName = "Cherry", LuckyNumbers = new[] { 4, 18 } } + new Customer { FirstName = "Alice", LastName = "Apple" }, + new Customer { FirstName = "Bob", LastName = "Banana" }, + new Customer { FirstName = "Charlie", LastName = "Cherry" } }; foreach (var c in testCustomers) @@ -609,23 +1399,22 @@ public void WhereContainsOnDocumentJson() t.Commit(); - var customers = t.Queryable() - .Where(c => c.LuckyNumbers.Contains(4)) - .ToList(); + var fn = () => t.Queryable() + .First(c => c.FirstName == "Jim"); - customers.Select(c => c.FirstName).Should().BeEquivalentTo("Charlie"); + fn.Should().Throw().WithMessage("Sequence contains no elements"); } [Test] - public void WhereNotContains() + public void SingleWithMultipleMatches() { using var t = Store.BeginTransaction(); var testCustomers = new[] { - new Customer { FirstName = "Alice", LastName = "Apple", Balance = 987.4m }, - new Customer { FirstName = "Bob", LastName = "Banana", Balance = 56.3m }, - new Customer { FirstName = "Charlie", LastName = "Cherry", Balance = 301.4m } + new Customer { FirstName = "Alice", LastName = "Apple" }, + new Customer { FirstName = "Bob", LastName = "Banana" }, + new Customer { FirstName = "Charlie", LastName = "Cherry" } }; foreach (var c in testCustomers) @@ -635,24 +1424,20 @@ public void WhereNotContains() t.Commit(); - var names = new[] { "Apple", "Orange", "Peach" }; - var customers = t.Queryable() - .Where(c => !names.Contains(c.LastName)) - .ToList(); + var fn = () => t.Queryable() + .Single(c => c.LastName.Contains("e")); - customers.Select(c => c.FirstName).Should().BeEquivalentTo("Bob", "Charlie"); + fn.Should().Throw().WithMessage("Sequence contains more than one element"); } [Test] - public void WhereNotContainsEmpty() + public void SingleOrDefault() { using var t = Store.BeginTransaction(); var testCustomers = new[] { - new Customer { FirstName = "Alice", LastName = "Apple", Balance = 987.4m }, - new Customer { FirstName = "Bob", LastName = "Banana", Balance = 56.3m }, - new Customer { FirstName = "Charlie", LastName = "Cherry", Balance = 301.4m } + new Customer { FirstName = "Alice", LastName = "Apple" } }; foreach (var c in testCustomers) @@ -662,24 +1447,20 @@ public void WhereNotContainsEmpty() t.Commit(); - var names = Array.Empty(); - var customers = t.Queryable() - .Where(c => !names.Contains(c.LastName)) - .ToList(); + var customer = t.Queryable() + .SingleOrDefault(); - customers.Select(c => c.FirstName).Should().BeEquivalentTo("Alice", "Bob", "Charlie"); + customer.LastName.Should().BeEquivalentTo("Apple"); } [Test] - public void WhereNotStringContains() + public async Task SingleOrDefaultAsync() { using var t = Store.BeginTransaction(); var testCustomers = new[] { - new Customer { FirstName = "Alice", LastName = "Apple", Nickname = "Bear" }, - new Customer { FirstName = "Bob", LastName = "Banana", Nickname = "Beets" }, - new Customer { FirstName = "Charlie", LastName = "Cherry", Nickname = "Chicken" } + new Customer { FirstName = "Alice", LastName = "Apple" } }; foreach (var c in testCustomers) @@ -687,17 +1468,16 @@ public void WhereNotStringContains() t.Insert(c); } - t.Commit(); + await t.CommitAsync(); - var customers = t.Queryable() - .Where(c => !c.Nickname.Contains("hi")) - .ToList(); + var customer = await t.Queryable() + .SingleOrDefaultAsync(); - customers.Select(c => c.FirstName).Should().BeEquivalentTo("Alice", "Bob"); + customer.LastName.Should().BeEquivalentTo("Apple"); } [Test] - public void First() + public void SingleOrDefaultWithPredicate() { using var t = Store.BeginTransaction(); @@ -716,13 +1496,13 @@ public void First() t.Commit(); var customer = t.Queryable() - .First(); + .SingleOrDefault(c => c.FirstName.EndsWith("y")); - customer.LastName.Should().BeEquivalentTo("Apple"); + customer.Should().BeNull(); } [Test] - public void FirstWithPredicate() + public async Task SingleOrDefaultWithPredicateAsync() { using var t = Store.BeginTransaction(); @@ -738,16 +1518,16 @@ public void FirstWithPredicate() t.Insert(c); } - t.Commit(); + await t.CommitAsync(); - var customer = t.Queryable() - .First(c => c.FirstName == "Alice"); + var customer = await t.Queryable() + .SingleOrDefaultAsync(c => c.FirstName.EndsWith("y")); - customer.LastName.Should().BeEquivalentTo("Apple"); + customer.Should().BeNull(); } [Test] - public void FirstOrDefault() + public void SingleOrDefaultWithMultipleMatches() { using var t = Store.BeginTransaction(); @@ -765,14 +1545,14 @@ public void FirstOrDefault() t.Commit(); - var customer = t.Queryable() - .FirstOrDefault(); + var fn = () => t.Queryable() + .SingleOrDefault(c => c.LastName.Contains("e")); - customer.LastName.Should().BeEquivalentTo("Apple"); + fn.Should().ThrowExactly().WithMessage("Sequence contains more than one element"); } [Test] - public async Task FirstOrDefaultAsync() + public async Task SingleOrDefaultAsyncWithMultipleMatches() { using var t = Store.BeginTransaction(); @@ -790,14 +1570,14 @@ public async Task FirstOrDefaultAsync() await t.CommitAsync(); - var customer = await t.Queryable() - .FirstOrDefaultAsync(); + var fn = async () => await t.Queryable() + .SingleOrDefaultAsync(c => c.LastName.Contains("e")); - customer.LastName.Should().BeEquivalentTo("Apple"); + await fn.Should().ThrowExactlyAsync().WithMessage("Sequence contains more than one element."); } [Test] - public void FirstOrDefaultWithPredicate() + public void Skip() { using var t = Store.BeginTransaction(); @@ -815,14 +1595,15 @@ public void FirstOrDefaultWithPredicate() t.Commit(); - var customer = t.Queryable() - .FirstOrDefault(c => c.FirstName.EndsWith("y")); + var customers = t.Queryable() + .Skip(2) + .ToList(); - customer.Should().BeNull(); + customers.Select(c => c.LastName).Should().BeEquivalentTo("Cherry"); } [Test] - public async Task FirstOrDefaultWithPredicateAsync() + public void Take() { using var t = Store.BeginTransaction(); @@ -838,16 +1619,17 @@ public async Task FirstOrDefaultWithPredicateAsync() t.Insert(c); } - await t.CommitAsync(); + t.Commit(); - var customer = await t.Queryable() - .FirstOrDefaultAsync(c => c.FirstName.EndsWith("y")); + var customers = t.Queryable() + .Take(2) + .ToList(); - customer.Should().BeNull(); + customers.Select(c => c.LastName).Should().BeEquivalentTo("Apple", "Banana"); } [Test] - public void Skip() + public void SkipAndTake() { using var t = Store.BeginTransaction(); @@ -855,7 +1637,9 @@ public void Skip() { new Customer { FirstName = "Alice", LastName = "Apple" }, new Customer { FirstName = "Bob", LastName = "Banana" }, - new Customer { FirstName = "Charlie", LastName = "Cherry" } + new Customer { FirstName = "Charlie", LastName = "Cherry" }, + new Customer { FirstName = "Dan", LastName = "Durian" }, + new Customer { FirstName = "Erin", LastName = "Eggplant" } }; foreach (var c in testCustomers) @@ -867,21 +1651,24 @@ public void Skip() var customers = t.Queryable() .Skip(2) + .Take(2) .ToList(); - customers.Select(c => c.LastName).Should().BeEquivalentTo("Cherry"); + customers.Select(c => c.LastName).Should().BeEquivalentTo("Cherry", "Durian"); } [Test] - public void Take() + public void Distinct() { using var t = Store.BeginTransaction(); var testCustomers = new[] { new Customer { FirstName = "Alice", LastName = "Apple" }, - new Customer { FirstName = "Bob", LastName = "Banana" }, - new Customer { FirstName = "Charlie", LastName = "Cherry" } + new Customer { FirstName = "Bob", LastName = "Apple" }, + new Customer { FirstName = "Charlie", LastName = "Banana" }, + new Customer { FirstName = "Dan", LastName = "Cherry" }, + new Customer { FirstName = "Erin", LastName = "Cherry" } }; foreach (var c in testCustomers) @@ -892,24 +1679,25 @@ public void Take() t.Commit(); var customers = t.Queryable() - .Take(2) + .Select(c => c.LastName) + .Distinct() .ToList(); - customers.Select(c => c.LastName).Should().BeEquivalentTo("Apple", "Banana"); + customers.Should().BeEquivalentTo("Apple", "Banana", "Cherry"); } [Test] - public void SkipAndTake() + public void DistinctBy() { using var t = Store.BeginTransaction(); var testCustomers = new[] { new Customer { FirstName = "Alice", LastName = "Apple" }, - new Customer { FirstName = "Bob", LastName = "Banana" }, - new Customer { FirstName = "Charlie", LastName = "Cherry" }, - new Customer { FirstName = "Dan", LastName = "Durian" }, - new Customer { FirstName = "Erin", LastName = "Eggplant" } + new Customer { FirstName = "Bob", LastName = "Apple" }, + new Customer { FirstName = "Charlie", LastName = "Banana" }, + new Customer { FirstName = "Dan", LastName = "Cherry" }, + new Customer { FirstName = "Erin", LastName = "Cherry" } }; foreach (var c in testCustomers) @@ -920,11 +1708,36 @@ public void SkipAndTake() t.Commit(); var customers = t.Queryable() - .Skip(2) - .Take(2) + .DistinctBy(c => c.LastName) .ToList(); - customers.Select(c => c.LastName).Should().BeEquivalentTo("Cherry", "Durian"); + customers.Select(c => c.LastName).Should().BeEquivalentTo("Apple", "Banana", "Cherry"); + } + + [Test] + public void DistinctByJson() + { + using var t = Store.BeginTransaction(); + + var testMachines = new[] + { + new Machine { Name = "Machine A", Endpoint = new PassiveTentacleEndpoint { Name = "Tentacle A" } }, + new Machine { Name = "Machine B", Endpoint = new PassiveTentacleEndpoint { Name = "Tentacle A" } }, + new Machine { Name = "Machine C", Endpoint = new PassiveTentacleEndpoint { Name = "Tentacle C" } }, + }; + + foreach (var c in testMachines) + { + t.Insert(c); + } + + t.Commit(); + + var customers = t.Queryable() + .DistinctBy(m => m.Endpoint.Name) + .ToList(); + + customers.Select(m => m.Endpoint.Name).Should().BeEquivalentTo("Tentacle A", "Tentacle C"); } [Test] @@ -1144,6 +1957,32 @@ public void OrderBy() customers.Select(c => c.LastName).Should().BeEquivalentTo("Banana", "Cherry", "Apple"); } + [Test] + public void OrderByJson() + { + using var t = Store.BeginTransaction(); + + var testMachines = new[] + { + new Machine { Name = "Machine A", Endpoint = new PassiveTentacleEndpoint { Name = "Tentacle B" } }, + new Machine { Name = "Machine B", Endpoint = new PassiveTentacleEndpoint { Name = "Tentacle C" } }, + new Machine { Name = "Machine C", Endpoint = new PassiveTentacleEndpoint { Name = "Tentacle A" } }, + }; + + foreach (var c in testMachines) + { + t.Insert(c); + } + + t.Commit(); + + var customers = t.Queryable() + .OrderBy(m => m.Endpoint.Name) + .ToList(); + + customers.Select(m => m.Endpoint.Name).Should().BeEquivalentTo("Tentacle A", "Tentacle B", "Tentacle C"); + } + [Test] public void OrderByDescending() { @@ -1168,6 +2007,32 @@ public void OrderByDescending() customers.Select(c => c.LastName).Should().BeEquivalentTo("Cherry", "Apple", "Banana"); } + [Test] + public void OrderByDescendingJson() + { + using var t = Store.BeginTransaction(); + + var testMachines = new[] + { + new Machine { Name = "Machine A", Endpoint = new PassiveTentacleEndpoint { Name = "Tentacle B" } }, + new Machine { Name = "Machine B", Endpoint = new PassiveTentacleEndpoint { Name = "Tentacle C" } }, + new Machine { Name = "Machine C", Endpoint = new PassiveTentacleEndpoint { Name = "Tentacle A" } }, + }; + + foreach (var c in testMachines) + { + t.Insert(c); + } + + t.Commit(); + + var customers = t.Queryable() + .OrderByDescending(m => m.Endpoint.Name) + .ToList(); + + customers.Select(m => m.Endpoint.Name).Should().BeEquivalentTo("Tentacle C", "Tentacle B", "Tentacle A"); + } + [Test] public void OrderByThenBy() { @@ -1195,6 +2060,36 @@ public void OrderByThenBy() customers.Select(c => c.FirstName).Should().BeEquivalentTo("Amanda", "Alice", "Charlie"); } + [Test] + public void OrderByThenByJson() + { + using var t = Store.BeginTransaction(); + + var testMachines = new[] + { + new Machine { Name = "Machine A", Endpoint = new PassiveTentacleEndpoint { Name = "Tentacle C" } }, + new Machine { Name = "Machine B", Endpoint = new PassiveTentacleEndpoint { Name = "Tentacle E" } }, + new Machine { Name = "Machine C", Endpoint = new PassiveTentacleEndpoint { Name = "Tentacle A" } }, + new Machine { Name = "Machine A", Endpoint = new ActiveTentacleEndpoint { Name = "Tentacle F" } }, + new Machine { Name = "Machine B", Endpoint = new ActiveTentacleEndpoint { Name = "Tentacle B" } }, + new Machine { Name = "Machine C", Endpoint = new ActiveTentacleEndpoint { Name = "Tentacle D" } }, + }; + + foreach (var c in testMachines) + { + t.Insert(c); + } + + t.Commit(); + + var customers = t.Queryable() + .OrderBy(m => m.Endpoint.Type) + .ThenBy(m => m.Endpoint.Name) + .ToList(); + + customers.Select(m => m.Endpoint.Name).Should().BeEquivalentTo("Tentacle B", "Tentacle D", "Tentacle F", "Tentacle A", "Tentacle C", "Tentacle E"); + } + [Test] public void OrderByThenByDescending() { @@ -1222,6 +2117,36 @@ public void OrderByThenByDescending() customers.Select(c => c.FirstName).Should().BeEquivalentTo("Alice", "Amanda", "Charlie"); } + [Test] + public void OrderByThenByDescendingJson() + { + using var t = Store.BeginTransaction(); + + var testMachines = new[] + { + new Machine { Name = "Machine A", Endpoint = new PassiveTentacleEndpoint { Name = "Tentacle C" } }, + new Machine { Name = "Machine B", Endpoint = new PassiveTentacleEndpoint { Name = "Tentacle E" } }, + new Machine { Name = "Machine C", Endpoint = new PassiveTentacleEndpoint { Name = "Tentacle A" } }, + new Machine { Name = "Machine A", Endpoint = new ActiveTentacleEndpoint { Name = "Tentacle F" } }, + new Machine { Name = "Machine B", Endpoint = new ActiveTentacleEndpoint { Name = "Tentacle B" } }, + new Machine { Name = "Machine C", Endpoint = new ActiveTentacleEndpoint { Name = "Tentacle D" } }, + }; + + foreach (var c in testMachines) + { + t.Insert(c); + } + + t.Commit(); + + var customers = t.Queryable() + .OrderBy(m => m.Endpoint.Type) + .ThenByDescending(m => m.Endpoint.Name) + .ToList(); + + customers.Select(m => m.Endpoint.Name).Should().BeEquivalentTo("Tentacle F", "Tentacle D", "Tentacle B", "Tentacle E", "Tentacle C", "Tentacle A"); + } + [Test] public void Any() { diff --git a/source/Nevermore/Advanced/Queryable/NevermoreQueryableExtensions.cs b/source/Nevermore/Advanced/Queryable/NevermoreQueryableExtensions.cs index c226efdf..c0035a00 100644 --- a/source/Nevermore/Advanced/Queryable/NevermoreQueryableExtensions.cs +++ b/source/Nevermore/Advanced/Queryable/NevermoreQueryableExtensions.cs @@ -14,6 +14,8 @@ public static class NevermoreQueryableExtensions static readonly MethodInfo HintMethodInfo = new Func, string, IQueryable>(Hint).GetMethodInfo().GetGenericMethodDefinition(); static readonly MethodInfo FirstOrDefaultMethodInfo = new Func, object>(System.Linq.Queryable.FirstOrDefault).GetMethodInfo().GetGenericMethodDefinition(); static readonly MethodInfo FirstOrDefaultWithPredicateMethodInfo = new Func, Expression>, object>(System.Linq.Queryable.FirstOrDefault).GetMethodInfo().GetGenericMethodDefinition(); + static readonly MethodInfo SingleOrDefaultMethodInfo = new Func, object>(System.Linq.Queryable.SingleOrDefault).GetMethodInfo().GetGenericMethodDefinition(); + static readonly MethodInfo SingleOrDefaultWithPredicateMethodInfo = new Func, Expression>, object>(System.Linq.Queryable.SingleOrDefault).GetMethodInfo().GetGenericMethodDefinition(); static readonly MethodInfo CountMethodInfo = new Func, int>(System.Linq.Queryable.Count).GetMethodInfo().GetGenericMethodDefinition(); static readonly MethodInfo CountWithPredicateMethodInfo = new Func, Expression>, int>(System.Linq.Queryable.Count).GetMethodInfo().GetGenericMethodDefinition(); static readonly MethodInfo AnyMethodInfo = new Func, bool>(System.Linq.Queryable.Any).GetMethodInfo().GetGenericMethodDefinition(); @@ -174,6 +176,41 @@ public static async Task FirstOrDefaultAsync(this IQueryable SingleOrDefaultAsync(this IQueryable source, CancellationToken cancellationToken = default) + { + if (source == null) + throw new ArgumentNullException(nameof(source)); + + if (source.Provider is IAsyncQueryProvider asyncQueryProvider) + { + var expression = Expression.Call( + null, + SingleOrDefaultMethodInfo.MakeGenericMethod(typeof(TSource)), + source.Expression); + return await asyncQueryProvider.ExecuteAsync(expression, cancellationToken).ConfigureAwait(false); + } + + throw new InvalidOperationException("The query provider does not support async operations."); + } + + public static async Task SingleOrDefaultAsync(this IQueryable source, Expression> predicate, CancellationToken cancellationToken = default) + { + if (source == null) + throw new ArgumentNullException(nameof(source)); + + if (source.Provider is IAsyncQueryProvider asyncQueryProvider) + { + var expression = Expression.Call( + null, + SingleOrDefaultWithPredicateMethodInfo.MakeGenericMethod(typeof(TSource)), + source.Expression, + predicate); + return await asyncQueryProvider.ExecuteAsync(expression, cancellationToken).ConfigureAwait(false); + } + + throw new InvalidOperationException("The query provider does not support async operations."); + } + public static async Task> ToListAsync(this IQueryable source, CancellationToken cancellationToken = default) { if (source == null) diff --git a/source/Nevermore/Advanced/Queryable/QueryProvider.cs b/source/Nevermore/Advanced/Queryable/QueryProvider.cs index c86e59f1..b7b531d2 100644 --- a/source/Nevermore/Advanced/Queryable/QueryProvider.cs +++ b/source/Nevermore/Advanced/Queryable/QueryProvider.cs @@ -59,21 +59,29 @@ public TResult Execute(Expression expression) .Invoke(queryExecutor, new object[] { command }); } - if (queryType == QueryType.SelectSingle) + if (queryType == QueryType.Count || queryType == QueryType.Exists) { - var stream = (IEnumerable)GenericStreamMethod.MakeGenericMethod(expression.Type) + return (TResult)GenericExecuteScalarMethod.MakeGenericMethod(expression.Type) .Invoke(queryExecutor, new object[] { command }); - return (TResult)stream.Cast().FirstOrDefault(); } - return (TResult)GenericExecuteScalarMethod.MakeGenericMethod(expression.Type) + var stream = (IEnumerable)GenericStreamMethod.MakeGenericMethod(expression.Type) .Invoke(queryExecutor, new object[] { command }); - } + return queryType switch + { + QueryType.SelectFirst => (TResult)stream.Cast().First(), + QueryType.SelectFirstOrDefault => (TResult)stream.Cast().FirstOrDefault(), + QueryType.SelectSingle => (TResult)stream.Cast().Single(), + QueryType.SelectSingleOrDefault => (TResult)stream.Cast().SingleOrDefault(), + _ => throw new ArgumentOutOfRangeException() + }; + } public async Task ExecuteAsync(Expression expression, CancellationToken cancellationToken) { var (command, queryType) = Translate(expression); + if (queryType == QueryType.SelectMany) { var sequenceType = expression.Type.GetSequenceType(); @@ -83,24 +91,31 @@ public async Task ExecuteAsync(Expression expression, Cancella return (TResult)await CreateList(asyncStream, sequenceType).ConfigureAwait(false); } - if (queryType == QueryType.SelectSingle) + if (queryType == QueryType.Count || queryType == QueryType.Exists) { - var asyncStream = (IAsyncEnumerable)GenericStreamAsyncMethod.MakeGenericMethod(expression.Type) - .Invoke(queryExecutor, new object[] { command, cancellationToken }); - var firstOrDefaultAsync = await FirstOrDefaultAsync(asyncStream, cancellationToken).ConfigureAwait(false); - - if (firstOrDefaultAsync is not null) return (TResult) firstOrDefaultAsync; - - // TODO: This NEEDS to go away when we turn nullable on in Nevermore - // This method needs to be able to return null for instances like `FirstOrDefaultAsync` - object GetNull() => null; - return (TResult) GetNull(); - + return await ((Task)GenericExecuteScalarAsyncMethod.MakeGenericMethod(expression.Type) + .Invoke(queryExecutor, new object[] { command, cancellationToken })) + .ConfigureAwait(false); } - return await ((Task)GenericExecuteScalarAsyncMethod.MakeGenericMethod(expression.Type) - .Invoke(queryExecutor, new object[] { command, cancellationToken })) - .ConfigureAwait(false); + var stream = (IAsyncEnumerable)GenericStreamAsyncMethod.MakeGenericMethod(expression.Type) + .Invoke(queryExecutor, new object[] { command, cancellationToken }); + + object result = queryType switch + { + QueryType.SelectFirst => (TResult)await FirstAsync(stream, cancellationToken).ConfigureAwait(false), + QueryType.SelectFirstOrDefault => (TResult)await FirstOrDefaultAsync(stream, cancellationToken).ConfigureAwait(false), + QueryType.SelectSingle => (TResult)await SingleAsync(stream, cancellationToken).ConfigureAwait(false), + QueryType.SelectSingleOrDefault => (TResult)await SingleOrDefaultAsync(stream, cancellationToken).ConfigureAwait(false), + _ => throw new ArgumentOutOfRangeException() + }; + + if (result is not null) return (TResult) result; + + // TODO: This NEEDS to go away when we turn nullable on in Nevermore + // This method needs to be able to return null for instances like `FirstOrDefaultAsync` + object GetNull() => null; + return (TResult) GetNull(); } public IAsyncEnumerable StreamAsync(Expression expression, CancellationToken cancellationToken) @@ -147,5 +162,64 @@ static async ValueTask FirstOrDefaultAsync(IAsyncEnumerable enumerable, return default; } + + static async ValueTask FirstAsync(IAsyncEnumerable enumerable, CancellationToken cancellationToken) + { +#pragma warning disable CA2007 + // CA2007 doesn't understand ConfiguredCancelableAsyncEnumerable and incorrectly thinks we need another ConfigureAwait(false) here + await using var enumerator = enumerable.ConfigureAwait(false).WithCancellation(cancellationToken).GetAsyncEnumerator(); +#pragma warning restore CA2007 + + if (!await enumerator.MoveNextAsync()) + { + throw new InvalidOperationException("Sequence contains no elements."); + } + + return enumerator.Current; + } + + static async Task SingleOrDefaultAsync(IAsyncEnumerable enumerable, CancellationToken cancellationToken = default) + { +#pragma warning disable CA2007 + // CA2007 doesn't understand ConfiguredCancelableAsyncEnumerable and incorrectly thinks we need another ConfigureAwait(false) here + await using var enumerator = enumerable.ConfigureAwait(false).WithCancellation(cancellationToken).GetAsyncEnumerator(); +#pragma warning restore CA2007 + + if (!await enumerator.MoveNextAsync()) + { + return default; + } + + var current = enumerator.Current; + + if (await enumerator.MoveNextAsync()) + { + throw new InvalidOperationException("Sequence contains more than one element."); + } + + return current; + } + + static async Task SingleAsync(IAsyncEnumerable enumerable, CancellationToken cancellationToken = default) + { +#pragma warning disable CA2007 + // CA2007 doesn't understand ConfiguredCancelableAsyncEnumerable and incorrectly thinks we need another ConfigureAwait(false) here + await using var enumerator = enumerable.ConfigureAwait(false).WithCancellation(cancellationToken).GetAsyncEnumerator(); +#pragma warning restore CA2007 + + if (!await enumerator.MoveNextAsync()) + { + throw new InvalidOperationException("Sequence contains no elements."); + } + + var current = enumerator.Current; + + if (!await enumerator.MoveNextAsync()) + { + return current; + } + + throw new InvalidOperationException("Sequence contains more than one element."); + } } } \ No newline at end of file diff --git a/source/Nevermore/Advanced/Queryable/QueryTranslator.cs b/source/Nevermore/Advanced/Queryable/QueryTranslator.cs index 04eb35c6..219ecf1e 100644 --- a/source/Nevermore/Advanced/Queryable/QueryTranslator.cs +++ b/source/Nevermore/Advanced/Queryable/QueryTranslator.cs @@ -7,6 +7,7 @@ using System.Reflection; using Nevermore.Querying.AST; using Nevermore.Util; +using Newtonsoft.Json; namespace Nevermore.Advanced.Queryable { @@ -59,6 +60,42 @@ protected override Expression VisitMethodCall(MethodCallExpression node) sqlBuilder.Where(new CustomWhereClause((string)constantExpression.Value)); return node; } + case nameof(Enumerable.Select): + { + if (node.Arguments.Count > 2) + { + throw new NotSupportedException("Select does not support custom comparers"); + } + + Visit(node.Arguments[0]); + + var expression = node.Arguments[1]; + if (expression is UnaryExpression { Operand: LambdaExpression { Body: NewExpression newExpression } }) + { + AggregateSelectColumns aggregateColumns; + if (newExpression.Members is not null) + { + var columns = newExpression.Arguments + .Select(GetFieldReference) + .Zip(newExpression.Members, (column, memberInfo) => new AliasedColumn(column, memberInfo.Name)) + .Cast(); + aggregateColumns = new AggregateSelectColumns(columns.ToArray()); + } + else + { + var columns = newExpression.Arguments + .Select(GetFieldReference); + aggregateColumns = new AggregateSelectColumns(columns.ToArray()); + } + + sqlBuilder.Column(aggregateColumns); + return node; + } + + var column = GetFieldReference(expression); + sqlBuilder.Column(column); + return node; + } case nameof(System.Linq.Queryable.Where): { Visit(node.Arguments[0]); @@ -74,8 +111,8 @@ protected override Expression VisitMethodCall(MethodCallExpression node) } Visit(node.Arguments[0]); - var fieldName = GetMemberNameFromKeySelectorExpression(node.Arguments[1]); - sqlBuilder.OrderBy(new OrderByField(new Column(fieldName))); + var column = GetFieldReference(node.Arguments[1]); + sqlBuilder.OrderBy(new OrderByField(column)); return node; } case nameof(System.Linq.Queryable.OrderByDescending): @@ -86,8 +123,8 @@ protected override Expression VisitMethodCall(MethodCallExpression node) } Visit(node.Arguments[0]); - var fieldName = GetMemberNameFromKeySelectorExpression(node.Arguments[1]); - sqlBuilder.OrderBy(new OrderByField(new Column(fieldName), OrderByDirection.Descending)); + var column = GetFieldReference(node.Arguments[1]); + sqlBuilder.OrderBy(new OrderByField(column, OrderByDirection.Descending)); return node; } case nameof(System.Linq.Queryable.ThenBy): @@ -98,8 +135,8 @@ protected override Expression VisitMethodCall(MethodCallExpression node) } Visit(node.Arguments[0]); - var fieldName = GetMemberNameFromKeySelectorExpression(node.Arguments[1]); - sqlBuilder.OrderBy(new OrderByField(new Column(fieldName))); + var column = GetFieldReference(node.Arguments[1]); + sqlBuilder.OrderBy(new OrderByField(column)); return node; } case nameof(System.Linq.Queryable.ThenByDescending): @@ -110,11 +147,11 @@ protected override Expression VisitMethodCall(MethodCallExpression node) } Visit(node.Arguments[0]); - var fieldName = GetMemberNameFromKeySelectorExpression(node.Arguments[1]); - sqlBuilder.OrderBy(new OrderByField(new Column(fieldName), OrderByDirection.Descending)); + var column = GetFieldReference(node.Arguments[1]); + sqlBuilder.OrderBy(new OrderByField(column, OrderByDirection.Descending)); return node; } - case nameof(System.Linq.Queryable.First): + case nameof(System.Linq.Queryable.Single): { Visit(node.Arguments[0]); if (node.Arguments.Count > 1) @@ -126,6 +163,30 @@ protected override Expression VisitMethodCall(MethodCallExpression node) sqlBuilder.Single(); return node; } + case nameof(System.Linq.Queryable.SingleOrDefault): + { + Visit(node.Arguments[0]); + if (node.Arguments.Count > 1) + { + var expression = (LambdaExpression)StripQuotes(node.Arguments[1]); + sqlBuilder.Where(CreateWhereClause(expression.Body)); + } + + sqlBuilder.SingleOrDefault(); + return node; + } + case nameof(System.Linq.Queryable.First): + { + Visit(node.Arguments[0]); + if (node.Arguments.Count > 1) + { + var expression = (LambdaExpression)StripQuotes(node.Arguments[1]); + sqlBuilder.Where(CreateWhereClause(expression.Body)); + } + + sqlBuilder.First(); + return node; + } case nameof(System.Linq.Queryable.FirstOrDefault): { Visit(node.Arguments[0]); @@ -135,7 +196,7 @@ protected override Expression VisitMethodCall(MethodCallExpression node) sqlBuilder.Where(CreateWhereClause(expression.Body)); } - sqlBuilder.Single(); + sqlBuilder.FirstOrDefault(); return node; } case nameof(System.Linq.Queryable.Any): @@ -183,23 +244,29 @@ protected override Expression VisitMethodCall(MethodCallExpression node) sqlBuilder.Skip(skip); return node; } + case nameof(System.Linq.Queryable.Distinct): + { + Visit(node.Arguments[0]); + sqlBuilder.Distinct(); + return node; + } + case "DistinctBy": + { + if (node.Arguments.Count > 2) + { + throw new NotSupportedException("DistinctBy does not support custom comparers"); + } + + Visit(node.Arguments[0]); + var column = GetFieldReference(node.Arguments[1]); + sqlBuilder.DistinctBy(column); + return node; + } default: throw new NotSupportedException(); } } - static string GetMemberNameFromKeySelectorExpression(Expression expression) - { - var expressionWithoutQuotes = StripQuotes(expression); - - if (expressionWithoutQuotes is LambdaExpression { Body: MemberExpression { NodeType: ExpressionType.MemberAccess } memberExpression }) - { - return memberExpression.Member.Name; - } - - throw new NotSupportedException(); - } - IWhereClause CreateWhereClause(Expression expression, bool invert = false) { if (expression is BinaryExpression binaryExpression) @@ -228,7 +295,7 @@ IWhereClause CreateWhereClause(Expression expression, bool invert = false) IWhereClause CreateMethodCallWhere(MethodCallExpression expression, bool invert = false) { - if (expression.Arguments.Count == 1 && expression.Method.DeclaringType == typeof(string)) + if (expression.Method.DeclaringType == typeof(string)) { return CreateStringMethodWhere(expression, invert); } @@ -261,6 +328,12 @@ IWhereClause CreateMethodCallWhere(MethodCallExpression expression, bool invert var (fieldReference, _) = GetFieldReferenceAndType(right); return sqlBuilder.CreateWhere(fieldReference, invert ? ArraySqlOperand.NotIn : ArraySqlOperand.In, values); } + else if (right is MethodCallExpression methodCallExpression) + { + var values = (IEnumerable)GetValueFromExpression(left, typeof(IEnumerable)); + var (fieldReference, _) = GetFieldReferenceAndType(methodCallExpression); + return sqlBuilder.CreateWhere(fieldReference, invert ? ArraySqlOperand.NotIn : ArraySqlOperand.In, values); + } } throw new NotSupportedException(); @@ -286,9 +359,44 @@ IWhereClause CreateStringMethodWhere(MethodCallExpression expression, bool inver return sqlBuilder.CreateWhere(fieldReference, invert ? UnarySqlOperand.NotLike : UnarySqlOperand.Like, $"%{value}"); } + if (expression.Method.Name == nameof(string.Equals)) + { + // If one of the IgnoreCase options was specified, then compare in lowercase + if (TryGetFirstArgumentOfType(expression, out StringComparison stringComparison)) + { + switch (stringComparison) + { + case StringComparison.CurrentCultureIgnoreCase: + case StringComparison.InvariantCultureIgnoreCase: + case StringComparison.OrdinalIgnoreCase: + fieldReference = new WhereFieldReferenceWithStringFunction(fieldReference, StringFunction.Lower); + value = value.ToLower(); + break; + } + } + + return sqlBuilder.CreateWhere(fieldReference, invert ? UnarySqlOperand.NotEqual : UnarySqlOperand.Equal, value); + } + throw new NotSupportedException(); } + bool TryGetFirstArgumentOfType(MethodCallExpression expression, out T arg) + { + arg = default; + foreach (var argument in expression.Arguments) + { + if (argument is not ConstantExpression constantExpression || + constantExpression.Type != typeof(T)) + continue; + + arg = (T)constantExpression.Value; + return true; + } + + return false; + } + IWhereClause CreateBinaryWhere(BinaryExpression expression, bool invert = false) { if (expression.NodeType == ExpressionType.AndAlso) @@ -349,6 +457,69 @@ object GetValueFromExpression(Expression expression, Type propertyType) return result; } + bool TryGetMemberExpression(Expression expression, out MemberExpression memberExpression, out PropertyInfo propertyInfo) + { + if (expression is MemberExpression { Member: PropertyInfo}) + { + memberExpression = (MemberExpression) expression; + propertyInfo = (PropertyInfo) memberExpression.Member; + return true; + } + + if (expression is UnaryExpression unaryExpression && + unaryExpression.Operand is LambdaExpression lambdaExpression && + lambdaExpression.Body is MemberExpression { Member: PropertyInfo }) + { + memberExpression = (MemberExpression) lambdaExpression.Body; + propertyInfo = (PropertyInfo) memberExpression.Member; + return true; + } + + memberExpression = null; + propertyInfo = null; + return false; + } + + IColumn GetFieldReference(Expression expression) + { + if (TryGetMemberExpression(expression, out MemberExpression memberExpression, out PropertyInfo propertyInfo)) + { + var hasJsonIgnoreAttribute = propertyInfo.GetCustomAttribute() is not null; + if (hasJsonIgnoreAttribute) + { + throw new NotSupportedException($"Cannot select on a property with the {nameof(JsonIgnoreAttribute)}."); + } + + var documentMap = sqlBuilder.DocumentMap; + var parameterExpression = memberExpression.FindChildOfType(); + var childPropertyExpression = memberExpression.FindChildOfType(); + if (childPropertyExpression == null && documentMap.Type.IsAssignableFrom(parameterExpression.Type)) + { + if (documentMap.IdColumn!.Property.Matches(propertyInfo)) + { + return new Column(documentMap.IdColumn.ColumnName); + } + + var column = documentMap.Columns.Where(c => c.Property is not null).FirstOrDefault(c => c.Property.Matches(propertyInfo)); + if (column is not null) + { + return new Column(column.ColumnName); + } + } + + if (documentMap.HasJsonColumn()) + { + var jsonPath = GetJsonPath(memberExpression); + IColumn fieldReference = propertyInfo.IsScalar() + ? new JsonValueColumn(jsonPath, propertyInfo.PropertyType) + : new JsonQueryColumn(jsonPath, propertyInfo.PropertyType); + return fieldReference; + } + } + + throw new NotSupportedException(); + } + (IWhereFieldReference, Type) GetFieldReferenceAndType(Expression expression) { if (expression is UnaryExpression unaryExpression) @@ -383,6 +554,35 @@ object GetValueFromExpression(Expression expression, Type propertyType) } } + if (expression is MethodCallExpression methodCallExpression) + { + var (reference, type) = GetFieldReferenceAndType(methodCallExpression.Object); + if (methodCallExpression.Method.Name == nameof(string.ToLower)) + { + return (new WhereFieldReferenceWithStringFunction(reference, StringFunction.Lower), type); + } + + if (methodCallExpression.Method.Name == nameof(string.ToUpper)) + { + return (new WhereFieldReferenceWithStringFunction(reference, StringFunction.Upper), type); + } + + if (methodCallExpression.Method.Name == nameof(string.Trim)) + { + return (new WhereFieldReferenceWithStringFunction(reference, StringFunction.Trim), type); + } + + if (methodCallExpression.Method.Name == nameof(string.TrimStart)) + { + return (new WhereFieldReferenceWithStringFunction(reference, StringFunction.LeftTrim), type); + } + + if (methodCallExpression.Method.Name == nameof(string.TrimEnd)) + { + return (new WhereFieldReferenceWithStringFunction(reference, StringFunction.RightTrim), type); + } + } + throw new NotSupportedException(); } diff --git a/source/Nevermore/Advanced/Queryable/QueryType.cs b/source/Nevermore/Advanced/Queryable/QueryType.cs index e7349f61..70541418 100644 --- a/source/Nevermore/Advanced/Queryable/QueryType.cs +++ b/source/Nevermore/Advanced/Queryable/QueryType.cs @@ -3,7 +3,10 @@ internal enum QueryType { SelectMany, + SelectFirst, + SelectFirstOrDefault, SelectSingle, + SelectSingleOrDefault, Count, Exists } diff --git a/source/Nevermore/Advanced/Queryable/SqlExpressionBuilder.cs b/source/Nevermore/Advanced/Queryable/SqlExpressionBuilder.cs index 9bdf7063..e3d3e8b7 100644 --- a/source/Nevermore/Advanced/Queryable/SqlExpressionBuilder.cs +++ b/source/Nevermore/Advanced/Queryable/SqlExpressionBuilder.cs @@ -12,6 +12,7 @@ internal class SqlExpressionBuilder { readonly IRelationalStoreConfiguration configuration; + readonly List selectColumns = new(); readonly List orderByFields = new(); readonly List whereClauses = new(); string hint; @@ -19,6 +20,8 @@ internal class SqlExpressionBuilder ITableSource from; int? skip; int? take; + bool distinct; + readonly List distinctByColumns = new(); QueryType queryType = QueryType.SelectMany; volatile int paramCounter; @@ -29,6 +32,11 @@ public SqlExpressionBuilder(IRelationalStoreConfiguration configuration) public DocumentMap DocumentMap { get; private set; } + public void Column(ISelectColumns selectColumns) + { + this.selectColumns.Add(selectColumns); + } + public void Where(IWhereClause whereClause) { whereClauses.Add(whereClause); @@ -70,12 +78,32 @@ public void OrderBy(OrderByField field) orderByFields.Add(field); } - public void Single() + public void First() + { + Take(1); + queryType = QueryType.SelectFirst; + } + + public void FirstOrDefault() { Take(1); + queryType = QueryType.SelectFirstOrDefault; + } + + public void Single() + { + // Hack: This is yuck. We're taking two rows here so that we can throw if we get more than one row. + Take(2); queryType = QueryType.SelectSingle; } + public void SingleOrDefault() + { + // Hack: This is yuck. We're taking two rows here so that we can throw if we get more than one row. + Take(2); + queryType = QueryType.SelectSingleOrDefault; + } + public void Exists() { queryType = QueryType.Exists; @@ -101,6 +129,16 @@ public void Hint(string hint) this.hint = hint; } + public void Distinct() + { + distinct = true; + } + + public void DistinctBy(IColumn column) + { + distinctByColumns.Add(column); + } + public void From(Type documentType) { DocumentMap = configuration.DocumentMaps.Resolve(documentType); @@ -125,25 +163,61 @@ public void From(Type documentType) from = new TableSourceWithHint(simpleTableSource, hint); } - var sqlExpression = queryType switch + IExpression sqlExpression; + if (distinctByColumns.Any()) + { + sqlExpression = CreateSelectDistinctByQuery(); + } + else { - QueryType.Exists => CreateExistsQuery(), - QueryType.Count => CreateCountQuery(), - _ => CreateSelectQuery() - }; + sqlExpression = queryType switch + { + QueryType.Exists => CreateExistsQuery(), + QueryType.Count => CreateCountQuery(), + _ => CreateSelectQuery(from) + }; + } return (sqlExpression, parameterValues, queryType); } + IExpression CreateSelectDistinctByQuery() + { + const string distinctByRowNumberFieldName = "distinctby_rownumber"; + const string distinctByRowNumberParameterName = "distinctby_rownumber"; + const string distinctByCteName = "distinctby_cte"; + + whereClauses.Add(new UnaryWhereClause(new WhereFieldReference(distinctByRowNumberFieldName), UnarySqlOperand.Equal, distinctByRowNumberParameterName)); + parameterValues.Add(distinctByRowNumberParameterName, 1); + + var orderBy = GetOrderBy(); + var cteSelect = new Select( + new AllRows(), + new AggregateSelectColumns(new ISelectColumns[] { + new SelectAllSource(), + new SelectRowNumber( + new Over(orderBy, new PartitionBy(distinctByColumns)), + distinctByRowNumberFieldName + ) + }), + from, + new Where(), + null, + null, + new Option(Array.Empty())); + + var select = CreateSelectQuery(new SchemalessTableSource(distinctByCteName)); + return new CteSelectSource(cteSelect, distinctByCteName, select); + } - IExpression CreateSelectQuery() + ISelect CreateSelectQuery(ISelectSource from) { - IRowSelection rowSelection = take.HasValue && !skip.HasValue ? new Top(take.Value) : null; - var orderBy = orderByFields.Any() ? new OrderBy(orderByFields) : GetDefaultOrderBy(); - var columns = new SelectAllJsonColumnLast(GetDocumentColumns().ToList()); + var rowSelection = CreateRowSelection(); + var orderBy = GetOrderBy(); + ISelectColumns columns = selectColumns.Any() ? new AggregateSelectColumns(selectColumns) : new SelectAllJsonColumnLast(GetDocumentColumns().ToList()); var select = new Select( - rowSelection ?? new AllRows(), - skip.HasValue ? new AggregateSelectColumns(new ISelectColumns[] { new SelectRowNumber(new Over(orderBy, null), "RowNum"), columns }) : columns, + rowSelection, + skip.HasValue ? new AggregateSelectColumns(new [] { new SelectRowNumber(new Over(orderBy, null), "RowNum"), columns }) : columns, from, CreateWhereClause(), null, @@ -163,7 +237,7 @@ IExpression CreateSelectQuery() } select = new Select( - new AllRows(), + distinct ? new Distinct() : new AllRows(), new SelectAllColumnsWithTableAliasJsonLast("aliased", GetDocumentColumns().ToList()), new SubquerySource(select, "aliased"), new Where(new AndClause(pagingFilters)), @@ -175,6 +249,31 @@ IExpression CreateSelectQuery() return select; } + IRowSelection CreateRowSelection() + { + var selections = new List(); + if (take.HasValue && !skip.HasValue) + { + selections.Add(new Top(take.Value)); + } + else + { + selections.Add(new AllRows()); + } + + if (distinct) + { + selections.Add(new Distinct()); + } + + return new CompositeRowSelection(selections); + } + + OrderBy GetOrderBy() + { + return orderByFields.Any() ? new OrderBy(orderByFields) : GetDefaultOrderBy(); + } + IExpression CreateExistsQuery() { IRowSelection rowSelection = take.HasValue && !skip.HasValue ? new Top(take.Value) : null; diff --git a/source/Nevermore/Advanced/ReaderStrategies/ArbitraryClasses/ArbitraryClassReaderStrategy.cs b/source/Nevermore/Advanced/ReaderStrategies/ArbitraryClasses/ArbitraryClassReaderStrategy.cs index eaace99b..9ac1e2d4 100644 --- a/source/Nevermore/Advanced/ReaderStrategies/ArbitraryClasses/ArbitraryClassReaderStrategy.cs +++ b/source/Nevermore/Advanced/ReaderStrategies/ArbitraryClasses/ArbitraryClassReaderStrategy.cs @@ -14,7 +14,7 @@ namespace Nevermore.Advanced.ReaderStrategies.ArbitraryClasses /// This strategy is used for "POCO", or "Plain-Old-CLR-Objects" classes (those that don't have a document map). /// They will be read from the reader automatically. Our requirements are: /// - /// - The class must provide a constructor (declared or not) with no parameters. + /// - The class must provide a constructor (declared or not) with no parameters, or a list of parameters that exactly matches the fields on the reader by type /// - We only bind public, settable properties /// - Column names must match property names, but the casing does not need to match /// - If a column exists in the results, a property must exist on the class @@ -31,10 +31,12 @@ public ArbitraryClassReaderStrategy(RelationalStoreConfiguration configuration) public bool CanRead(Type type) { - return - type.IsClass - && type.GetConstructors().Any(c => c.IsPublic && c.GetParameters().Length == 0) - && !configuration.DocumentMaps.ResolveOptional(type, out _); + return !IsPrimitive(type) && type.IsClass && !configuration.DocumentMaps.ResolveOptional(type, out _); + } + + bool IsPrimitive(Type type) + { + return type.IsPrimitive || type.IsArray || type == typeof(string) || type == typeof(decimal); } public Func> CreateReader() @@ -81,18 +83,31 @@ public bool CanRead(Type type) // result.LastName = reader.GetString(1); // return result; // } + // + // -- OR -- + // + // (DbDataReader reader, ArbitraryClassReaderContext context) => + // { + // var result = new Person(reader.GetString(0), reader.GetString(1)); + // return result; + // } CompiledExpression> Compile(IDataRecord record) { // To make it fast - as fast as if we wrote it by hand - we generate and compile C# expression trees for // each property on the class, and one to call the constructor. - + + var constructors = typeof(TRecord).GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); // Find the parameter-less constructor - var constructors = typeof(TRecord).GetConstructors(); - var defaultConstructor = constructors.FirstOrDefault(p => p.GetParameters().Length == 0) ?? throw new InvalidOperationException("When reading query results to a class, the class must provide a default constructor with no parameters."); - - // Create fast setters for all properties on the type - var properties = typeof(TRecord).GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.SetProperty); - + var defaultConstructor = constructors.FirstOrDefault(c => c.GetParameters().Length == 0); + // Find a parameterized constructor that matches the data reader + var parameterizedConstructor = (from ctor in constructors + let parameters = ctor.GetParameters() + where parameters.Length == record.FieldCount && MatchesTypes(parameters, record) + select ctor).FirstOrDefault(); + var selectedConstructor = parameterizedConstructor + ?? defaultConstructor + ?? throw new InvalidOperationException("No default constructor or constructor that exactly matches the number and types of the reader fields was found."); + var readerArg = Expression.Parameter(typeof(DbDataReader), "reader"); var contextArg = Expression.Parameter(typeof(ArbitraryClassReaderContext), "context"); @@ -101,18 +116,46 @@ CompiledExpression> Compile(IDataReco var resultLocalVariable = Expression.Variable(typeof(TRecord), "result"); locals.Add(resultLocalVariable); - body.Add(Expression.Assign(resultLocalVariable, Expression.New(defaultConstructor))); - + + if (selectedConstructor.GetParameters().Any()) + { + BuildParameterizedConstructorExpression(selectedConstructor, readerArg, body, resultLocalVariable); + } + else + { + BuildParameterlessConstructorExpression(record, body, resultLocalVariable, selectedConstructor, contextArg, readerArg); + } + + // Return it + body.Add(resultLocalVariable); + + var block = Expression.Block( + locals, + body + ); + + var lambda = Expression.Lambda>(block, readerArg, contextArg); + + return ExpressionCompiler.Compile(lambda); + } + + void BuildParameterlessConstructorExpression(IDataRecord record, List body, ParameterExpression resultLocalVariable, ConstructorInfo selectedConstructor, ParameterExpression contextArg, ParameterExpression readerArg) + { + // Create fast setters for all properties on the type + var properties = typeof(TRecord).GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.SetProperty); + + body.Add(Expression.Assign(resultLocalVariable, Expression.New(selectedConstructor))); + var expectedFieldCount = record.FieldCount; for (var i = 0; i < expectedFieldCount; i++) { var name = record.GetName(i); - + var property = properties.FirstOrDefault(p => p.Name == name); if (property != null) { body.Add(Expression.Assign(Expression.Field(contextArg, nameof(ArbitraryClassReaderContext.Column)), Expression.Constant(i))); - + body.Add(Expression.Assign( Expression.Property(resultLocalVariable, property), ExpressionHelper.GetValueFromReaderAsType(readerArg, Expression.Constant(i), property.PropertyType, configuration.TypeHandlers))); @@ -122,18 +165,19 @@ CompiledExpression> Compile(IDataReco throw new Exception($"The query returned a column named '{name}' but no property by that name exists on the target type '{typeof(TRecord).Name}'. When reading to an arbitrary type, all columns must have a matching settable property."); } } + } - // Return it - body.Add(resultLocalVariable); - - var block = Expression.Block( - locals, - body - ); + void BuildParameterizedConstructorExpression(ConstructorInfo selectedConstructor, ParameterExpression readerArg, List body, ParameterExpression resultLocalVariable) + { + var arguments = selectedConstructor + .GetParameters() + .Select((p, i) => ExpressionHelper.GetValueFromReaderAsType(readerArg, Expression.Constant(i), p.ParameterType, configuration.TypeHandlers)); + body.Add(Expression.Assign(resultLocalVariable, Expression.New(selectedConstructor, arguments))); + } - var lambda = Expression.Lambda>(block, readerArg, contextArg); - - return ExpressionCompiler.Compile(lambda); + static bool MatchesTypes(IEnumerable parameters, IDataRecord record) + { + return !parameters.Where((t, i) => t.ParameterType != record.GetFieldType(i)).Any(); } } } \ No newline at end of file diff --git a/source/Nevermore/Querying/AST/IColumn.cs b/source/Nevermore/Querying/AST/IColumn.cs index a41e2a1f..7669dccd 100644 --- a/source/Nevermore/Querying/AST/IColumn.cs +++ b/source/Nevermore/Querying/AST/IColumn.cs @@ -1,4 +1,6 @@ -namespace Nevermore.Querying.AST +using System; + +namespace Nevermore.Querying.AST { public interface IColumn : ISelectColumns { @@ -54,4 +56,36 @@ public class SelectCountSource : IColumn public string GenerateSql() => "COUNT(*)"; public override string ToString() => GenerateSql(); } + + public class JsonQueryColumn : IColumn + { + readonly string jsonPath; + public bool AggregatesRows => false; + readonly Type elementType; + + public JsonQueryColumn(string jsonPath, Type elementType) + { + this.jsonPath = jsonPath; + this.elementType = elementType; + } + + public string GenerateSql() => $"CAST(JSON_QUERY([JSON], '{jsonPath}') AS {elementType.ToDbType()})"; + public override string ToString() => GenerateSql(); + } + + public class JsonValueColumn : IColumn + { + readonly string jsonPath; + public bool AggregatesRows => false; + readonly Type elementType; + + public JsonValueColumn(string jsonPath, Type elementType) + { + this.jsonPath = jsonPath; + this.elementType = elementType; + } + + public string GenerateSql() => $"CAST(JSON_VALUE([JSON], '{jsonPath}') AS {elementType.ToDbType()})"; + public override string ToString() => GenerateSql(); + } } \ No newline at end of file diff --git a/source/Nevermore/Querying/AST/IRowSelection.cs b/source/Nevermore/Querying/AST/IRowSelection.cs index a44257ca..37908367 100644 --- a/source/Nevermore/Querying/AST/IRowSelection.cs +++ b/source/Nevermore/Querying/AST/IRowSelection.cs @@ -1,10 +1,28 @@ -namespace Nevermore.Querying.AST +using System.Collections.Generic; +using System.Linq; + +namespace Nevermore.Querying.AST { public interface IRowSelection { string GenerateSql(); } + public class CompositeRowSelection : IRowSelection + { + readonly IEnumerable rowSelections; + + public CompositeRowSelection(IEnumerable rowSelections) + { + this.rowSelections = rowSelections; + } + + public string GenerateSql() + { + return string.Join(" ", rowSelections.Select(r => r.GenerateSql())); + } + } + public class Top : IRowSelection { readonly int numberOfRows; @@ -26,11 +44,6 @@ public class AllRows : IRowSelection public class Distinct : IRowSelection { - public Distinct() - { - - } - public string GenerateSql() => $"DISTINCT "; public override string ToString() => GenerateSql(); } diff --git a/source/Nevermore/Querying/AST/ISelectSource.cs b/source/Nevermore/Querying/AST/ISelectSource.cs index 6b1e2415..cef301df 100644 --- a/source/Nevermore/Querying/AST/ISelectSource.cs +++ b/source/Nevermore/Querying/AST/ISelectSource.cs @@ -1,9 +1,8 @@ namespace Nevermore.Querying.AST { - public interface ISelectSource + public interface ISelectSource : IExpression { string Schema { get; } - string GenerateSql(); } public interface IAliasedSelectSource : ISelectSource diff --git a/source/Nevermore/Querying/AST/IWhereFieldReference.cs b/source/Nevermore/Querying/AST/IWhereFieldReference.cs index 408544de..b2116f6d 100644 --- a/source/Nevermore/Querying/AST/IWhereFieldReference.cs +++ b/source/Nevermore/Querying/AST/IWhereFieldReference.cs @@ -1,10 +1,52 @@ -namespace Nevermore.Querying.AST +using System; + +namespace Nevermore.Querying.AST { public interface IWhereFieldReference { string GenerateSql(); } + public enum StringFunction + { + Lower, + Upper, + Trim, + LeftTrim, + RightTrim, + } + + public class WhereFieldReferenceWithStringFunction : IWhereFieldReference + { + readonly IWhereFieldReference inner; + readonly StringFunction function; + + public WhereFieldReferenceWithStringFunction(IWhereFieldReference inner, StringFunction function) + { + this.inner = inner; + this.function = function; + } + + public string GenerateSql() + { + switch (function) + { + case StringFunction.Lower: + return $"LOWER({inner.GenerateSql()})"; + case StringFunction.Upper: + return $"UPPER({inner.GenerateSql()})"; + case StringFunction.Trim: + return $"TRIM({inner.GenerateSql()})"; + case StringFunction.LeftTrim: + return $"LTRIM({inner.GenerateSql()})"; + case StringFunction.RightTrim: + return $"RTRIM({inner.GenerateSql()})"; + default: + throw new ArgumentOutOfRangeException(); + } + } + } + public class WhereFieldReference : IWhereFieldReference { readonly string fieldName; diff --git a/source/Nevermore/Querying/AST/TypeExtensionMethods.cs b/source/Nevermore/Querying/AST/TypeExtensionMethods.cs new file mode 100644 index 00000000..466b7b1f --- /dev/null +++ b/source/Nevermore/Querying/AST/TypeExtensionMethods.cs @@ -0,0 +1,26 @@ +using System; + +namespace Nevermore.Querying.AST +{ + public static class TypeExtensionMethods + { + public static string ToDbType(this Type type) + { + return Type.GetTypeCode(type) switch + { + TypeCode.String => "nvarchar(max)", + TypeCode.Int16 => "int", + TypeCode.Double => "double", + TypeCode.Boolean => "bit", + TypeCode.Char => "char(max)", + TypeCode.DateTime => "datetime2", + TypeCode.Decimal => "decimal(18,8)", + TypeCode.Int32 => "int", + TypeCode.Int64 => "bigint", + TypeCode.SByte => "tinyint", + TypeCode.Single => "float", + _ => throw new ArgumentOutOfRangeException() + }; + } + } +} \ No newline at end of file diff --git a/source/Nevermore/Querying/AST/Where.cs b/source/Nevermore/Querying/AST/Where.cs index 4f79d13d..a38345f3 100644 --- a/source/Nevermore/Querying/AST/Where.cs +++ b/source/Nevermore/Querying/AST/Where.cs @@ -105,26 +105,7 @@ public JsonArrayWhereClause(string parameterName, ArraySqlOperand operand, strin public string GenerateSql() { - return $"@{parameterName} {GetQueryOperandSql()} (SELECT [Val] FROM OPENJSON([JSON], 'strict {jsonPath}') WITH ([Val] {GetDbType()} '$'))"; - } - - string GetDbType() - { - return Type.GetTypeCode(elementType) switch - { - TypeCode.String => "nvarchar(max)", - TypeCode.Int16 => "int", - TypeCode.Double => "double", - TypeCode.Boolean => "bit", - TypeCode.Char => "char(max)", - TypeCode.DateTime => "datetime2", - TypeCode.Decimal => "decimal(18,8)", - TypeCode.Int32 => "int", - TypeCode.Int64 => "bigint", - TypeCode.SByte => "tinyint", - TypeCode.Single => "float", - _ => throw new ArgumentOutOfRangeException() - }; + return $"@{parameterName} {GetQueryOperandSql()} (SELECT [Val] FROM OPENJSON([JSON], 'strict {jsonPath}') WITH ([Val] {elementType.ToDbType()} '$'))"; } string GetQueryOperandSql()