diff --git a/YesSql.sln b/YesSql.sln index 31c3aab6..01c002d9 100644 --- a/YesSql.sln +++ b/YesSql.sln @@ -50,6 +50,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "YesSql.Filters.Abstractions EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "YesSql.Filters.Query", "src\YesSql.Filters.Query\YesSql.Filters.Query.csproj", "{86C3967F-B817-4119-B354-B6EB1AC1F237}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "YesSql.Filters.Enumerable", "src\YesSql.Filters.Enumerable\YesSql.Filters.Enumerable.csproj", "{8F8F2268-9EE8-4BEC-B60E-44DFC122F6BF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -302,6 +304,22 @@ Global {86C3967F-B817-4119-B354-B6EB1AC1F237}.Release|x64.Build.0 = Release|Any CPU {86C3967F-B817-4119-B354-B6EB1AC1F237}.Release|x86.ActiveCfg = Release|Any CPU {86C3967F-B817-4119-B354-B6EB1AC1F237}.Release|x86.Build.0 = Release|Any CPU + {8F8F2268-9EE8-4BEC-B60E-44DFC122F6BF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F8F2268-9EE8-4BEC-B60E-44DFC122F6BF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F8F2268-9EE8-4BEC-B60E-44DFC122F6BF}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {8F8F2268-9EE8-4BEC-B60E-44DFC122F6BF}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {8F8F2268-9EE8-4BEC-B60E-44DFC122F6BF}.Debug|x64.ActiveCfg = Debug|Any CPU + {8F8F2268-9EE8-4BEC-B60E-44DFC122F6BF}.Debug|x64.Build.0 = Debug|Any CPU + {8F8F2268-9EE8-4BEC-B60E-44DFC122F6BF}.Debug|x86.ActiveCfg = Debug|Any CPU + {8F8F2268-9EE8-4BEC-B60E-44DFC122F6BF}.Debug|x86.Build.0 = Debug|Any CPU + {8F8F2268-9EE8-4BEC-B60E-44DFC122F6BF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F8F2268-9EE8-4BEC-B60E-44DFC122F6BF}.Release|Any CPU.Build.0 = Release|Any CPU + {8F8F2268-9EE8-4BEC-B60E-44DFC122F6BF}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {8F8F2268-9EE8-4BEC-B60E-44DFC122F6BF}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {8F8F2268-9EE8-4BEC-B60E-44DFC122F6BF}.Release|x64.ActiveCfg = Release|Any CPU + {8F8F2268-9EE8-4BEC-B60E-44DFC122F6BF}.Release|x64.Build.0 = Release|Any CPU + {8F8F2268-9EE8-4BEC-B60E-44DFC122F6BF}.Release|x86.ActiveCfg = Release|Any CPU + {8F8F2268-9EE8-4BEC-B60E-44DFC122F6BF}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -322,6 +340,7 @@ Global {6BAC7887-02FB-4B6F-BB5C-D6DEF9646CC6} = {0C294EC4-E6EF-4839-AD34-335C1A5112F9} {348B307C-66F0-4DBE-BA96-5D9DFAB5588E} = {0C294EC4-E6EF-4839-AD34-335C1A5112F9} {86C3967F-B817-4119-B354-B6EB1AC1F237} = {0C294EC4-E6EF-4839-AD34-335C1A5112F9} + {8F8F2268-9EE8-4BEC-B60E-44DFC122F6BF} = {0C294EC4-E6EF-4839-AD34-335C1A5112F9} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A6AB2945-2138-42B9-8F82-FCCF4DFC8F55} diff --git a/src/YesSql.Filters.Enumerable/EnumerableBooleanEngineBuilder.cs b/src/YesSql.Filters.Enumerable/EnumerableBooleanEngineBuilder.cs new file mode 100644 index 00000000..9d627b0f --- /dev/null +++ b/src/YesSql.Filters.Enumerable/EnumerableBooleanEngineBuilder.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using YesSql; +using YesSql.Filters.Abstractions.Builders; +using YesSql.Filters.Enumerable.Services; + +namespace YesSql.Filters.Enumerable +{ + public class EnumerableBooleanEngineBuilder : BooleanEngineBuilder> where T : class + { + public EnumerableBooleanEngineBuilder( + string name, + Func, EnumerableExecutionContext, ValueTask>> matchQuery, + Func, EnumerableExecutionContext, ValueTask>> notMatchQuery) + { + _termOption = new EnumerableTermOption(name, matchQuery, notMatchQuery); + } + } +} diff --git a/src/YesSql.Filters.Enumerable/EnumerableEngineBuilder.cs b/src/YesSql.Filters.Enumerable/EnumerableEngineBuilder.cs new file mode 100644 index 00000000..9580d2c7 --- /dev/null +++ b/src/YesSql.Filters.Enumerable/EnumerableEngineBuilder.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using YesSql.Filters.Abstractions.Builders; +using YesSql.Filters.Enumerable.Services; + +namespace YesSql.Filters.Enumerable +{ + /// + /// Builds a for an . + /// + public class EnumerableEngineBuilder where T : class + { + private readonly Dictionary>> _termBuilders = new Dictionary>>(); + + public EnumerableEngineBuilder SetTermParser(TermEngineBuilder> builder) + { + _termBuilders[builder.Name] = builder; + + return this; + } + + public IEnumerableParser Build() + { + var builders = _termBuilders.Values.Select(x => x.Build()); + + var parsers = builders.Select(x => x.Parser).ToArray(); + var termOptions = builders.Select(x => x.TermOption).ToDictionary(k => k.Name, v => v, StringComparer.OrdinalIgnoreCase); + + return new EnumerableParser(parsers, termOptions); + } + } +} diff --git a/src/YesSql.Filters.Enumerable/EnumerableEngineBuilderExtensions.cs b/src/YesSql.Filters.Enumerable/EnumerableEngineBuilderExtensions.cs new file mode 100644 index 00000000..a0cd6fcd --- /dev/null +++ b/src/YesSql.Filters.Enumerable/EnumerableEngineBuilderExtensions.cs @@ -0,0 +1,33 @@ +using System; +using YesSql.Filters.Abstractions.Builders; +using YesSql.Filters.Enumerable.Services; + +namespace YesSql.Filters.Enumerable +{ + public static class EnumerableEngineBuilderExtensions + { + /// + /// Adds a term where the name must be specified to an + /// + public static EnumerableEngineBuilder WithNamedTerm(this EnumerableEngineBuilder builder, string name, Action>> action) where T : class + { + var parserBuilder = new NamedTermEngineBuilder>(name); + action(parserBuilder); + + builder.SetTermParser(parserBuilder); + return builder; + } + + /// + /// Adds a term where the name is optional to an + /// + public static EnumerableEngineBuilder WithDefaultTerm(this EnumerableEngineBuilder builder, string name, Action>> action) where T : class + { + var parserBuilder = new DefaultTermEngineBuilder>(name); + action(parserBuilder); + + builder.SetTermParser(parserBuilder); + return builder; + } + } +} diff --git a/src/YesSql.Filters.Enumerable/EnumerableFilterResult.cs b/src/YesSql.Filters.Enumerable/EnumerableFilterResult.cs new file mode 100644 index 00000000..02bee1d2 --- /dev/null +++ b/src/YesSql.Filters.Enumerable/EnumerableFilterResult.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using YesSql.Filters.Abstractions.Nodes; +using YesSql.Filters.Abstractions.Services; +using YesSql.Filters.Enumerable.Services; + +namespace YesSql.Filters.Enumerable +{ + public class EnumerableFilterResult : FilterResult> where T : class + { + public EnumerableFilterResult(IReadOnlyDictionary> termOptions) : base(termOptions) + { } + + public EnumerableFilterResult(List terms, IReadOnlyDictionary> termOptions) : base(terms, termOptions) + { } + + public void MapFrom(TModel model) + { + foreach (var option in TermOptions) + { + if (option.Value.MapFrom is Action, string, TermOption, TModel> mappingMethod) + { + mappingMethod(this, option.Key, option.Value, model); + } + } + } + + /// + /// Applies term filters to an + /// + public async ValueTask> ExecuteAsync(EnumerableExecutionContext context) + { + var visitor = new EnumerableFilterVisitor(); + + foreach (var term in _terms.Values) + { + // TODO optimize value task. + await VisitTerm(TermOptions, context, visitor, term); + } + + // Execute always run terms. These are not added to the terms list. + foreach (var termOption in TermOptions) + { + if (!termOption.Value.AlwaysRun) + { + continue; + } + + if (!_terms.ContainsKey(termOption.Key)) + { + var alwaysRunNode = new NamedTermNode(termOption.Key, new UnaryNode(String.Empty, OperateNodeQuotes.None)); + await VisitTerm(TermOptions, context, visitor, alwaysRunNode); + } + } + + return context.Item; + } + + private async static Task VisitTerm(IReadOnlyDictionary> termOptions, EnumerableExecutionContext context, EnumerableFilterVisitor visitor, TermNode term) + { + context.CurrentTermOption = termOptions[term.TermName]; + + var termQuery = visitor.Visit(term, context); + context.Item = await termQuery.Invoke(context.Item); + context.CurrentTermOption = null; + } + } +} diff --git a/src/YesSql.Filters.Enumerable/EnumerableFilterResultExtensions.cs b/src/YesSql.Filters.Enumerable/EnumerableFilterResultExtensions.cs new file mode 100644 index 00000000..3953ebdd --- /dev/null +++ b/src/YesSql.Filters.Enumerable/EnumerableFilterResultExtensions.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using YesSql.Filters.Enumerable.Services; + +namespace YesSql.Filters.Enumerable +{ + public static class EnumerableFilterResultExtensions + { + public static ValueTask> ExecuteAsync(this EnumerableFilterResult result, IEnumerable query) where T : class + => result.ExecuteAsync(new EnumerableExecutionContext(query)); + } +} diff --git a/src/YesSql.Filters.Enumerable/EnumerableTermEngineBuilderExtensions.cs b/src/YesSql.Filters.Enumerable/EnumerableTermEngineBuilderExtensions.cs new file mode 100644 index 00000000..ce70632f --- /dev/null +++ b/src/YesSql.Filters.Enumerable/EnumerableTermEngineBuilderExtensions.cs @@ -0,0 +1,70 @@ +using System; +using System.Threading.Tasks; +using YesSql.Filters.Abstractions.Builders; +using YesSql.Filters.Enumerable.Services; +using System.Collections.Generic; + +namespace YesSql.Filters.Enumerable +{ + public static class QueryTermFilterBuilderExtensions + { + /// + /// Adds a single condition to a . + /// . + /// The predicate to apply when the term is parsed. + /// + public static EnumerableUnaryEngineBuilder OneCondition(this TermEngineBuilder> builder, Func, IEnumerable> matchQuery) where T : class + { + ValueTask> valueQuery(string q, IEnumerable val, EnumerableExecutionContext ctx) => new(matchQuery(q, val)); + + return builder.OneCondition(valueQuery); + } + + /// + /// Adds a single condition to a . + /// + /// An async predicate to apply when the term is parsed. + /// + public static EnumerableUnaryEngineBuilder OneCondition(this TermEngineBuilder> builder, Func, EnumerableExecutionContext, ValueTask>> matchQuery) where T : class + { + var operatorBuilder = new EnumerableUnaryEngineBuilder(builder.Name, matchQuery); + builder.SetOperator(operatorBuilder); + + return operatorBuilder; + } + + /// + /// Adds a condition which supports many operations to a + /// + /// The predicate to apply when the term is parsed with an AND or OR operator. + /// The predicate to apply when the term is parsed with a NOT operator. + /// + public static EnumerableBooleanEngineBuilder ManyCondition( + this TermEngineBuilder> builder, + Func, IEnumerable> matchQuery, + Func, IEnumerable> notMatchQuery) where T : class + { + ValueTask> valueMatch(string q, IEnumerable val, EnumerableExecutionContext ctx) => new(matchQuery(q, val)); + ValueTask> valueNotMatch(string q, IEnumerable val, EnumerableExecutionContext ctx) => new(notMatchQuery(q, val)); + + return builder.ManyCondition(valueMatch, valueNotMatch); + } + + /// + /// Adds a condition which supports many operations to a + /// . + /// The predicate to apply when the term is parsed with an AND or OR operator. + /// The predicate to apply when the term is parsed with a NOT operator. + /// + public static EnumerableBooleanEngineBuilder ManyCondition( + this TermEngineBuilder> builder, + Func, EnumerableExecutionContext, ValueTask>> matchQuery, + Func, EnumerableExecutionContext, ValueTask>> notMatchQuery) where T : class + { + var operatorBuilder = new EnumerableBooleanEngineBuilder(builder.Name, matchQuery, notMatchQuery); + builder.SetOperator(operatorBuilder); + + return operatorBuilder; + } + } +} diff --git a/src/YesSql.Filters.Enumerable/EnumerableUnaryEngineBuilder.cs b/src/YesSql.Filters.Enumerable/EnumerableUnaryEngineBuilder.cs new file mode 100644 index 00000000..a63aff45 --- /dev/null +++ b/src/YesSql.Filters.Enumerable/EnumerableUnaryEngineBuilder.cs @@ -0,0 +1,70 @@ +using System; +using System.Threading.Tasks; +using YesSql.Filters.Abstractions.Builders; +using YesSql.Filters.Abstractions.Nodes; +using YesSql.Filters.Abstractions.Services; +using YesSql.Filters.Enumerable.Services; +using YesSql; +using System.Collections.Generic; + +namespace YesSql.Filters.Enumerable +{ + + public class EnumerableUnaryEngineBuilder : UnaryEngineBuilder> where T : class + { + public EnumerableUnaryEngineBuilder(string name, Func, EnumerableExecutionContext, ValueTask>> query) : base(new EnumerableTermOption(name, query)) + { + } + + /// + /// Adds a mapping function which can be applied to a model. + /// The type of model. + /// + public EnumerableUnaryEngineBuilder MapTo(Action map) + { + _termOption.MapTo = map; + + return this; + } + + /// + /// Adds a mapping function where terms can be mapped from a model. + /// The type of model. + /// Mapping to apply + /// + public EnumerableUnaryEngineBuilder MapFrom(Func map) + { + static TermNode factory(string name, string value) => new NamedTermNode(name, new UnaryNode(value, OperateNodeQuotes.None)); + + return MapFrom(map, factory); + } + + /// + /// Adds a mapping function where terms can be mapped from a model. + /// The type of model. + /// Mapping to apply + /// Factory to create a when adding a mapping + /// + public EnumerableUnaryEngineBuilder MapFrom(Func map, Func factory) + { + Action, string, TermOption, TModel> mapFrom = (EnumerableFilterResult terms, string name, TermOption termOption, TModel model) => + { + (var shouldMap, var value) = map(model); + if (shouldMap) + { + var node = termOption.MapFromFactory(name, value); + terms.TryAddOrReplace(node); + } + else + { + terms.TryRemove(name); + } + }; + + _termOption.MapFrom = mapFrom; + _termOption.MapFromFactory = factory; + + return this; + } + } +} diff --git a/src/YesSql.Filters.Enumerable/IEnumerableParser.cs b/src/YesSql.Filters.Enumerable/IEnumerableParser.cs new file mode 100644 index 00000000..55cb8496 --- /dev/null +++ b/src/YesSql.Filters.Enumerable/IEnumerableParser.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using YesSql.Filters.Abstractions.Services; + +namespace YesSql.Filters.Enumerable +{ + /// + /// Represents a filter parser for an + /// + public interface IEnumerableParser : IFilterParser> where T : class + { + } +} diff --git a/src/YesSql.Filters.Enumerable/Services/EnumerableExecutionContext.cs b/src/YesSql.Filters.Enumerable/Services/EnumerableExecutionContext.cs new file mode 100644 index 00000000..faa1e6d1 --- /dev/null +++ b/src/YesSql.Filters.Enumerable/Services/EnumerableExecutionContext.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using YesSql.Filters.Abstractions.Services; + +namespace YesSql.Filters.Enumerable.Services +{ + public class EnumerableExecutionContext : FilterExecutionContext> where T : class + { + public EnumerableExecutionContext(IEnumerable query) : base(query) + { + } + + public EnumerableTermOption CurrentTermOption { get; set; } + } +} diff --git a/src/YesSql.Filters.Enumerable/Services/EnumerableFilterVisitor.cs b/src/YesSql.Filters.Enumerable/Services/EnumerableFilterVisitor.cs new file mode 100644 index 00000000..9f9eabf3 --- /dev/null +++ b/src/YesSql.Filters.Enumerable/Services/EnumerableFilterVisitor.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using YesSql.Filters.Abstractions.Nodes; +using YesSql.Filters.Abstractions.Services; + +namespace YesSql.Filters.Enumerable.Services +{ + public class EnumerableFilterVisitor : IFilterVisitor, Func, ValueTask>>> where T : class + { + public Func, ValueTask>> Visit(TermOperationNode node, EnumerableExecutionContext argument) + => node.Operation.Accept(this, argument); + + public Func, ValueTask>> Visit(AndTermNode node, EnumerableExecutionContext argument) + { + var predicates = new List, ValueTask>>>(); + foreach (var child in node.Children) + { + Func, ValueTask>> predicate = (q) => child.Operation.Accept(this, argument)(q); + predicates.Add(predicate); + } + + var result = (Func, ValueTask>>)Delegate.Combine(predicates.ToArray()); + + return result; + } + + public Func, ValueTask>> Visit(UnaryNode node, EnumerableExecutionContext argument) + { + var currentQuery = argument.CurrentTermOption.MatchPredicate; + if (!node.UseMatch) + { + currentQuery = argument.CurrentTermOption.NotMatchPredicate; + } + + return result => currentQuery(node.Value, argument.Item, argument); + } + + public Func, ValueTask>> Visit(NotUnaryNode node, EnumerableExecutionContext argument) + { + Func, ValueTask>> except = async (q) => + { + var not = await node.Operation.Accept(this, argument)(q); + + return not; + }; + + return except; + } + + public Func, ValueTask>> Visit(OrNode node, EnumerableExecutionContext argument) + { + Func, ValueTask>> union = async (q) => + { + var l = await node.Left.Accept(this, argument)(q); + var r = await node.Right.Accept(this, argument)(q); + + return l.Union(r); + }; + + return union; + } + + public Func, ValueTask>> Visit(AndNode node, EnumerableExecutionContext argument) + { + Func, ValueTask>> intersect = async (q) => + { + var l = await node.Left.Accept(this, argument)(q); + var r = await node.Right.Accept(this, argument)(q); + + return l.Intersect(r); + }; + + return intersect; + } + + public Func, ValueTask>> Visit(GroupNode node, EnumerableExecutionContext argument) + => node.Operation.Accept(this, argument); + + public Func, ValueTask>> Visit(TermNode node, EnumerableExecutionContext argument) + => node.Accept(this, argument); + } +} diff --git a/src/YesSql.Filters.Enumerable/Services/EnumerableParseContext.cs b/src/YesSql.Filters.Enumerable/Services/EnumerableParseContext.cs new file mode 100644 index 00000000..08e86492 --- /dev/null +++ b/src/YesSql.Filters.Enumerable/Services/EnumerableParseContext.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using Parlot; +using Parlot.Fluent; + +namespace YesSql.Filters.Enumerable.Services +{ + public class EnumerableParseContext : ParseContext where T : class + { + public EnumerableParseContext(IReadOnlyDictionary> termOptions, Scanner scanner, bool useNewLines = false) : base(scanner, useNewLines) + { + TermOptions = termOptions; + } + + public IReadOnlyDictionary> TermOptions { get; } + } +} diff --git a/src/YesSql.Filters.Enumerable/Services/EnumerableParser.cs b/src/YesSql.Filters.Enumerable/Services/EnumerableParser.cs new file mode 100644 index 00000000..b48456dc --- /dev/null +++ b/src/YesSql.Filters.Enumerable/Services/EnumerableParser.cs @@ -0,0 +1,51 @@ + +using System; +using System.Collections.Generic; +using YesSql.Filters.Abstractions.Nodes; +using Parlot; +using Parlot.Fluent; +using static Parlot.Fluent.Parsers; + +namespace YesSql.Filters.Enumerable.Services +{ + public class EnumerableParser : IEnumerableParser where T : class + { + private readonly Dictionary> _termOptions; + private readonly Parser> _parser; + + public EnumerableParser(Parser[] termParsers, Dictionary> termOptions) + { + _termOptions = termOptions; + + var terms = OneOf(termParsers); + + _parser = ZeroOrMany(terms) + .Then(static (context, terms) => + { + var ctx = (EnumerableParseContext)context; + + return new EnumerableFilterResult(terms, ctx.TermOptions); + }).Compile(); + } + + public EnumerableFilterResult Parse(string text) + { + if (String.IsNullOrEmpty(text)) + { + return new EnumerableFilterResult(_termOptions); + } + + var context = new EnumerableParseContext(_termOptions, new Scanner(text)); + + var result = default(ParseResult>); + if (_parser.Parse(context, ref result)) + { + return result.Value; + } + else + { + return new EnumerableFilterResult(_termOptions); + } + } + } +} diff --git a/src/YesSql.Filters.Enumerable/Services/EnumerableTermOption.cs b/src/YesSql.Filters.Enumerable/Services/EnumerableTermOption.cs new file mode 100644 index 00000000..39410671 --- /dev/null +++ b/src/YesSql.Filters.Enumerable/Services/EnumerableTermOption.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using YesSql.Filters.Abstractions.Services; + +namespace YesSql.Filters.Enumerable.Services +{ + public class EnumerableTermOption : TermOption where T : class + { + public EnumerableTermOption(string name, Func, EnumerableExecutionContext, ValueTask>> matchPredicate) : base(name) + { + MatchPredicate = matchPredicate; + } + + public EnumerableTermOption(string name, Func, EnumerableExecutionContext, ValueTask>> matchPredicate, Func, EnumerableExecutionContext, ValueTask>> notMatchPredicate) + : base(name) + { + MatchPredicate = matchPredicate; + NotMatchPredicate = notMatchPredicate; + } + public Func, EnumerableExecutionContext, ValueTask>> MatchPredicate { get; } + public Func, EnumerableExecutionContext, ValueTask>> NotMatchPredicate { get; } + } +} diff --git a/src/YesSql.Filters.Enumerable/YesSql.Filters.Enumerable.csproj b/src/YesSql.Filters.Enumerable/YesSql.Filters.Enumerable.csproj new file mode 100644 index 00000000..021fb2d1 --- /dev/null +++ b/src/YesSql.Filters.Enumerable/YesSql.Filters.Enumerable.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/YesSql.Tests/Filters/EnumerableEngineTests.cs b/test/YesSql.Tests/Filters/EnumerableEngineTests.cs new file mode 100644 index 00000000..a98a22c1 --- /dev/null +++ b/test/YesSql.Tests/Filters/EnumerableEngineTests.cs @@ -0,0 +1,336 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; +using YesSql.Filters.Enumerable; +using YesSql.Tests.Models; + +namespace YesSql.Tests.Filters +{ + public class EnumerableEngineTests + { + [Fact] + public void ShouldParseNamedTerm() + { + var parser = new EnumerableEngineBuilder() + .WithNamedTerm("name", b => b.OneCondition(PersonOneConditionQuery())) + .Build(); + + Assert.Equal("name:steve", parser.Parse("name:steve").ToString()); + Assert.Equal("name:steve", parser.Parse("name:steve").ToNormalizedString()); + } + + [Fact] + public void ShouldParseNamedTermWhenQuoted() + { + var parser = new EnumerableEngineBuilder() + .WithNamedTerm("name", b => b.OneCondition(PersonOneConditionQuery())) + .Build(); + + Assert.Equal("name:\"steve balmer\"", parser.Parse("name:\"steve balmer\"").ToString()); + Assert.Equal("name:\"steve balmer\"", parser.Parse("name:\"steve balmer\"").ToNormalizedString()); + Assert.Equal("name:'steve balmer'", parser.Parse("name:'steve balmer'").ToString()); + Assert.Equal("name:'steve balmer'", parser.Parse("name:'steve balmer'").ToNormalizedString()); + } + + [Fact] + public void ShouldParseTermWithLocalizedChars() + { + var parser = new EnumerableEngineBuilder() + .WithNamedTerm("name", b => b.OneCondition(PersonOneConditionQuery())) + .Build(); + + Assert.Equal("name:账单", parser.Parse("name:账单").ToString()); + Assert.Equal("name:账单", parser.Parse("name:账单").ToNormalizedString()); + } + + [Fact] + public void ShouldParseManyNamedTerms() + { + var parser = new EnumerableEngineBuilder() + .WithNamedTerm("name", b => b.OneCondition(PersonOneConditionQuery())) + .WithNamedTerm("status", b => b.OneCondition(PersonOneConditionQuery())) + .Build(); + + Assert.Equal("name:steve status:published", parser.Parse("name:steve status:published").ToString()); + Assert.Equal("name:steve status:published", parser.Parse("name:steve status:published").ToNormalizedString()); + } + + [Fact] + public void ShouldParseManyNamedTermsWithManyCondition() + { + var parser = new EnumerableEngineBuilder() + .WithNamedTerm("name", b => b + .ManyCondition(PersonManyMatch(), PersonManyNotMatch())) + .WithNamedTerm("status", b => b + .ManyCondition(PersonManyMatch(), PersonManyNotMatch())) + .Build(); + + Assert.Equal("name:steve status:published", parser.Parse("name:steve status:published").ToString()); + Assert.Equal("name:steve status:published", parser.Parse("name:steve status:published").ToNormalizedString()); + } + + [Fact] + public void ShouldParseDefaultTermWithManyCondition() + { + var parser = new EnumerableEngineBuilder() + .WithDefaultTerm("name", b => b.ManyCondition(PersonManyMatch(), PersonManyNotMatch())) + .WithNamedTerm("status", b => b.ManyCondition(PersonManyMatch(), PersonManyNotMatch())) + .Build(); + + Assert.Equal("steve status:published", parser.Parse("steve status:published").ToString()); + Assert.Equal("name:steve status:published", parser.Parse("steve status:published").ToNormalizedString()); + } + + [Fact] + public void ShouldParseDefaultTermWithManyConditionWhenLast() + { + var parser = new EnumerableEngineBuilder() + .WithNamedTerm("status", b => b.ManyCondition(PersonManyMatch(), PersonManyNotMatch())) + .WithDefaultTerm("name", b => b.ManyCondition(PersonManyMatch(), PersonManyNotMatch())) + .Build(); + + Assert.Equal("steve status:published", parser.Parse("steve status:published").ToString()); + Assert.Equal("name:steve status:published", parser.Parse("steve status:published").ToNormalizedString()); + } + + [Fact] + public void ShouldParseDefaultTermWithManyConditionWhenDefaultIsFirst() + { + // TODO Validation on builder if you have two manys. you cannot have a default. + var parser = new EnumerableEngineBuilder() + .WithDefaultTerm("name", b => b.ManyCondition(PersonManyMatch(), PersonManyNotMatch())) + .WithNamedTerm("status", b => b.ManyCondition(PersonManyMatch(), PersonManyNotMatch())) + .Build(); + + Assert.Equal("status:(published OR steve)", parser.Parse("status:published steve").ToNormalizedString()); + } + + [Fact] + public void ShouldParseDefaultTerm() + { + var parser = new EnumerableEngineBuilder() + .WithNamedTerm("age", b => b.OneCondition(PersonOneConditionQuery())) + .WithDefaultTerm("name", b => b.OneCondition(PersonOneConditionQuery())) + .Build(); + + Assert.Equal("name:steve", parser.Parse("name:steve").ToString()); + Assert.Equal("steve", parser.Parse("steve").ToString()); + Assert.Equal("steve age:20", parser.Parse("steve age:20").ToString()); + Assert.Equal("age:20 name:steve", parser.Parse("age:20 name:steve").ToString()); + Assert.Equal("age:20 steve", parser.Parse("age:20 steve").ToString()); + Assert.Equal(2, parser.Parse("steve age:20").Count()); + Assert.Equal("name:steve", parser.Parse("steve").ToNormalizedString()); + } + + [Fact] + public void ShouldParseDefaultTermWithOneMany() + { + var parser = new EnumerableEngineBuilder() + .WithNamedTerm("age", builder => builder.OneCondition(PersonOneConditionQuery())) + .WithDefaultTerm("name", builder => + builder.ManyCondition(PersonManyMatch(), PersonManyNotMatch()) + ) + .Build(); + + + Assert.Equal("name:steve", parser.Parse("name:steve").ToString()); + Assert.Equal("steve", parser.Parse("steve").ToString()); + Assert.Equal("steve age:20", parser.Parse("steve age:20").ToString()); + Assert.Equal("age:20 name:steve", parser.Parse("age:20 name:steve").ToString()); + Assert.Equal("age:20 steve", parser.Parse("age:20 steve").ToString()); + Assert.Equal(2, parser.Parse("steve age:20").Count()); + Assert.Equal("name:steve", parser.Parse("steve").ToNormalizedString()); + } + + [Fact] + public void ShouldParseDefaultTermAtEndOfStatement() + { + var parser = new EnumerableEngineBuilder() + .WithNamedTerm("age", b => b + .OneCondition((val, query) => + { + if (Int32.TryParse(val, out var age)) + { + query = query.Where(x => x.Age == age); + } + + return query; + })) + .WithDefaultTerm("name", b => b.OneCondition(PersonOneConditionQuery())) + .Build(); + + + Assert.Equal("age:20 name:steve", parser.Parse("age:20 name:steve").ToString()); + Assert.Equal(2, parser.Parse("age:20 name:steve").Count()); + Assert.Equal("age:20 steve", parser.Parse("age:20 steve").ToString()); + Assert.Equal(2, parser.Parse("age:20 steve").Count()); + } + + [Fact] + public void ShouldParseDefaultTermAtEndOfStatementWithBuilder() + { + var parser = new EnumerableEngineBuilder() + .WithNamedTerm("age", builder => + builder + .OneCondition((val, query) => + { + if (Int32.TryParse(val, out var age)) + { + query = query.Where(x => x.Age == age); + } + + return query; + }) + ) + .WithDefaultTerm("name", builder => + builder.OneCondition(PersonOneConditionQuery()) + ) + .Build(); + + Assert.Equal("age:20 name:steve", parser.Parse("age:20 name:steve").ToString()); + Assert.Equal(2, parser.Parse("age:20 name:steve").Count()); + Assert.Equal("age:20 steve", parser.Parse("age:20 steve").ToString()); + Assert.Equal(2, parser.Parse("age:20 steve").Count()); + } + + [Fact] + public void OrderOfDefaultTermShouldNotMatter() + { + var parser1 = new EnumerableEngineBuilder() + .WithNamedTerm("age", b => b.OneCondition(PersonOneConditionQuery())) + .WithDefaultTerm("name", b => b.ManyCondition(PersonManyMatch(), PersonManyNotMatch())) + .Build(); + + var parser2 = new EnumerableEngineBuilder() + .WithDefaultTerm("name", b => b.ManyCondition(PersonManyMatch(), PersonManyNotMatch())) + .WithNamedTerm("age", b => b.OneCondition(PersonOneConditionQuery())) + .Build(); + + Assert.Equal("steve age:20", parser1.Parse("steve age:20").ToString()); + + var result = parser1.Parse("steve age:20"); + Assert.Equal(2, result.Count()); + + Assert.Equal("age:20 steve", parser1.Parse("age:20 steve").ToString()); + Assert.Equal(2, parser1.Parse("age:20 steve").Count()); + + Assert.Equal("steve age:20", parser2.Parse("steve age:20").ToString()); + Assert.Equal(2, parser2.Parse("steve age:20").Count()); + + Assert.Equal("age:20 steve", parser2.Parse("age:20 steve").ToString()); + Assert.Equal(2, parser2.Parse("age:20 steve").Count()); + } + + [Theory] + [InlineData("title:bill post", "title:(bill OR post)")] + [InlineData("title:bill OR post", "title:(bill OR post)")] + [InlineData("title:beach AND sand", "title:(beach AND sand)")] + [InlineData("title:beach AND sand OR mountain AND lake", "title:((beach AND sand) OR (mountain AND lake))")] + [InlineData("title:(beach AND sand) OR (mountain AND lake)", "title:((beach AND sand) OR (mountain AND lake))")] + [InlineData("title:(beach AND sand) OR (mountain AND lake) NOT lizards", "title:(((beach AND sand) OR (mountain AND lake)) NOT lizards)")] + [InlineData("title:NOT beach", "title:NOT beach")] + [InlineData("title:beach NOT mountain", "title:(beach NOT mountain)")] + [InlineData("title:beach NOT mountain lake", "title:((beach NOT mountain) OR lake)")] + public void Complex(string search, string normalized) + { + var parser = new EnumerableEngineBuilder
() + .WithNamedTerm("title", b => b.ManyCondition(ArticleManyMatch(), ArticleManyNotMatch())) + .Build(); + + var result = parser.Parse(search); + + Assert.Equal(normalized, result.ToNormalizedString()); + } + + [Theory] + [InlineData("title:(bill)", "title:(bill)")] + [InlineData("title:(bill AND steve) OR Paul", "title:((bill AND steve) OR Paul)")] + [InlineData("title:((bill AND steve) OR Paul)", "title:((bill AND steve) OR Paul)")] + public void ShouldGroup(string search, string normalized) + { + var parser = new EnumerableEngineBuilder
() + .WithNamedTerm("title", b => b.ManyCondition(ArticleManyMatch(), ArticleManyNotMatch())) + .Build(); + + var result = parser.Parse(search); + + Assert.Equal(search, result.ToString()); + Assert.Equal(normalized, result.ToNormalizedString()); + } + + [Theory] + [InlineData("title:bill steve")] + public void ShouldNotIncludeExtraWhitespace(string search) + { + var parser = new EnumerableEngineBuilder
() + .WithNamedTerm("title", b => b.ManyCondition(ArticleManyMatch(), ArticleManyNotMatch())) + .Build(); + + var result = parser.Parse(search); + + Assert.Equal(search, result.ToString()); + } + + [Fact] + public void ShouldIgnoreMultipleNamedTerms() + { + var parser = new EnumerableEngineBuilder() + .WithNamedTerm("name", b => b.OneCondition(PersonOneConditionQuery())) + .Build(); + + // By convention the last term is used when single = true; + Assert.Equal("name:bill", parser.Parse("name:steve name:bill").ToString()); + Assert.Equal("name:bill", parser.Parse("name:steve name:bill").ToNormalizedString()); + } + + [Fact] + public void ShouldAllowMultipleNamedTerms() + { + var parser = new EnumerableEngineBuilder() + .WithNamedTerm("name", b => b + .OneCondition(PersonOneConditionQuery()) + .AllowMultiple()) + .Build(); + + // By convention the last term is used when single = true; + Assert.Equal("name:steve name:bill", parser.Parse("name:steve name:bill").ToString()); + Assert.Equal("name:steve name:bill", parser.Parse("name:steve name:bill").ToNormalizedString()); + } + + [Theory] + [InlineData("extrachar:age-asc")] + [InlineData("extrachar:age-desc")] + [InlineData("extrachar:2020-01-01..2020-10-10")] + [InlineData("extrachar:>ten")] + [InlineData("extrachar:<100")] + [InlineData("extrachar:<=100")] + [InlineData("extrachar:100*")] + [InlineData("extrachar:100+")] + public void ShouldIncludeExtraChars(string search) + { + var parser = new EnumerableEngineBuilder() + .WithNamedTerm("extrachar", b => b.OneCondition(PersonOneConditionQuery())) + .Build(); + + var result = parser.Parse(search); + + Assert.Equal(search, result.ToString()); + } + + private static Func, IEnumerable> PersonOneConditionQuery() + => (val, enumerable) => enumerable.Where(x => x.Firstname.Contains(val, StringComparison.OrdinalIgnoreCase)); + + private static Func, IEnumerable> PersonManyMatch() + => PersonOneConditionQuery(); + + private static Func, IEnumerable> PersonManyNotMatch() + => (val, query) => query.Where(x => !x.Firstname.Contains(val, StringComparison.OrdinalIgnoreCase)); + + private static Func, IEnumerable
> ArticleManyMatch() + => (val, query) => query.Where(x => x.Title.Contains(val, StringComparison.OrdinalIgnoreCase)); + + private static Func, IEnumerable
> ArticleManyNotMatch() + => (val, query) => query.Where(x => !x.Title.Contains(val, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/test/YesSql.Tests/Filters/EnumerableFilterTests.cs b/test/YesSql.Tests/Filters/EnumerableFilterTests.cs new file mode 100644 index 00000000..4cda58a9 --- /dev/null +++ b/test/YesSql.Tests/Filters/EnumerableFilterTests.cs @@ -0,0 +1,405 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Xunit; +using YesSql.Filters.Enumerable; +using YesSql.Tests.Models; + +namespace YesSql.Tests.Filters +{ + public class ArticleDocument + { + public List
Articles { get; } = new(); + } + + public class EnumerableFilterTests + { + [Fact] + public async Task ShouldParseNamedTermQuery() + { + var document = new ArticleDocument(); + var billsArticle = new Article + { + Title = "article by bill about rabbits", + PublishedUtc = DateTime.UtcNow + }; + + var stevesArticle = new Article + { + Title = "post by steve about cats", + PublishedUtc = DateTime.UtcNow + }; + + document.Articles.Add(billsArticle); + document.Articles.Add(stevesArticle); + + + var filter = "title:steve"; + + var toFilter = document.Articles.AsEnumerable(); + + var parser = new EnumerableEngineBuilder
() + .WithNamedTerm("title", b => b + .OneCondition((val, enumerable) => enumerable.Where(x => x.Title.Contains(val, StringComparison.OrdinalIgnoreCase))) + ) + .Build(); + + var parsed = parser.Parse(filter); + + var filtered = await parsed.ExecuteAsync(toFilter); + + // Parsed query + Assert.Equal("post by steve about cats", filtered.FirstOrDefault().Title); + Assert.Single(filtered); + } + + [Theory] + [InlineData("steve")] + [InlineData("title:steve")] + public async Task ShouldParseDefaultTermQuery(string search) + { + var document = new ArticleDocument(); + + var billsArticle = new Article + { + Title = "article by bill about rabbits", + PublishedUtc = DateTime.UtcNow + }; + + var stevesArticle = new Article + { + Title = "post by steve about cats", + PublishedUtc = DateTime.UtcNow + }; + + document.Articles.Add(billsArticle); + document.Articles.Add(stevesArticle); + + var toQuery = document.Articles.AsEnumerable(); + + var parser = new EnumerableEngineBuilder
() + .WithDefaultTerm("title", b => b + .OneCondition((val, query) => query.Where(x => x.Title.Contains(val, StringComparison.OrdinalIgnoreCase)))) + .Build(); + + var parsed = parser.Parse(search); + + var filtered = await parsed.ExecuteAsync(toQuery); + + var assert3 = filtered.FirstOrDefault().Title; + var assert4 = filtered.Count(); + + // Parsed query + Assert.Equal("post by steve about cats", assert3); + Assert.Equal(1, assert4); + } + + [Fact] + public async Task ShouldParseOrQuery() + { + var document = new ArticleDocument(); + + var billsArticle = new Article + { + Title = "article by bill about rabbits", + PublishedUtc = DateTime.UtcNow + }; + + var stevesArticle = new Article + { + Title = "post by steve about cats", + PublishedUtc = DateTime.UtcNow + }; + + document.Articles.Add(billsArticle); + document.Articles.Add(stevesArticle); + + // boolean OR "title:(bill OR post)" + var filter = "title:bill post"; + var filterQuery = document.Articles.AsEnumerable(); + + var parser = new EnumerableEngineBuilder
() + .WithNamedTerm("title", b => b + .ManyCondition( + (val, query) => query.Where(x => x.Title.Contains(val, StringComparison.OrdinalIgnoreCase)), + (val, query) => query.Where(x => !x.Title.Contains(val, StringComparison.OrdinalIgnoreCase)) + ) + ) + .Build(); + + var parsed = parser.Parse(filter); + var filtered = await parsed.ExecuteAsync(filterQuery); + + // Parsed query + Assert.Equal(2, filtered.Count()); + } + + [Fact] + public async Task ShouldParseAndQuery() + { + var document = new ArticleDocument(); + + var billsArticle = new Article + { + Title = "article by bill about rabbits", + PublishedUtc = DateTime.UtcNow + }; + + var stevesArticle = new Article + { + Title = "post by steve about cats", + PublishedUtc = DateTime.UtcNow + }; + + document.Articles.Add(billsArticle); + document.Articles.Add(stevesArticle); + + var toFilter = document.Articles.AsEnumerable(); + + // boolean AND "title:(bill AND rabbits)" + var filter = "title:bill AND rabbits"; + + var parser = new EnumerableEngineBuilder
() + .WithNamedTerm("title", b => b + .ManyCondition( + (val, query) => query.Where(x => x.Title.Contains(val, StringComparison.OrdinalIgnoreCase)), + (val, query) => query.Where(x => !x.Title.Contains(val, StringComparison.OrdinalIgnoreCase)) + ) + ) + .Build(); + + var parsed = parser.Parse(filter); + + var filtered = await parsed.ExecuteAsync(toFilter); + + // Parsed query + Assert.Single(filtered); + } + + [Fact] + public async Task ShouldParseTwoNamedTermQuerys() + { + var document = new ArticleDocument(); + + var billsArticle = new Article + { + Title = "article by bill about rabbits", + PublishedUtc = DateTime.UtcNow + }; + + var stevesArticle = new Article + { + Title = "article by steve about cats", + PublishedUtc = DateTime.UtcNow + }; + + document.Articles.Add(billsArticle); + document.Articles.Add(stevesArticle); + + var filter = "title:article title:article"; + var filterQuery = document.Articles.AsEnumerable(); + + var parser = new EnumerableEngineBuilder
() + .WithNamedTerm("title", b => b + .OneCondition((val, query) => query.Where(x => x.Title.Contains(val, StringComparison.OrdinalIgnoreCase))) + .AllowMultiple() + ) + .Build(); + + var parsed = parser.Parse(filter); + + var filtered = await parsed.ExecuteAsync(filterQuery); + + // Parsed query + Assert.Equal(2, filterQuery.Count()); + } + + [Fact] + public async Task ShouldParseComplexQuery() + { + var document = new ArticleDocument(); + var beachLizardsArticle = new Article + { + Title = "On the beach in the sand we found lizards", + PublishedUtc = DateTime.UtcNow + }; + + var mountainArticle = new Article + { + Title = "On the mountain it snowed at the lake", + PublishedUtc = DateTime.UtcNow + }; + + document.Articles.Add(beachLizardsArticle); + document.Articles.Add(mountainArticle); + + + var filter = "title:(beach AND sand) OR (mountain AND lake)"; + var filterQuery = document.Articles.AsEnumerable(); + + var parser = new EnumerableEngineBuilder
() + .WithNamedTerm("title", b => b + .ManyCondition( + (val, query) => query.Where(x => x.Title.Contains(val, StringComparison.OrdinalIgnoreCase)), + (val, query) => query.Where(x => !x.Title.Contains(val, StringComparison.OrdinalIgnoreCase)) + ) + ) + .Build(); + + var parsed = parser.Parse(filter); + + await parsed.ExecuteAsync(filterQuery); + + // Parsed query + Assert.Equal(2, filterQuery.Count()); + } + + [Fact] + public async Task ShouldParseNotComplexQuery() + { + var document = new ArticleDocument(); + + var beachLizardsArticle = new Article + { + Title = "On the beach in the sand we found lizards", + PublishedUtc = DateTime.UtcNow + }; + + var sandcastlesArticle = new Article + { + Title = "On the beach in the sand we built sandcastles", + PublishedUtc = DateTime.UtcNow + }; + + var mountainArticle = new Article + { + Title = "On the mountain it snowed at the lake", + PublishedUtc = DateTime.UtcNow + }; + + document.Articles.Add(beachLizardsArticle); + document.Articles.Add(sandcastlesArticle); + document.Articles.Add(mountainArticle); + + // boolean : ((beach AND sand) OR (mountain AND lake)) NOT lizards + var filter = "title:((beach AND sand) OR (mountain AND lake)) NOT lizards"; + var toFilter = document.Articles.AsEnumerable(); + + var parser = new EnumerableEngineBuilder
() + .WithNamedTerm("title", b => b + .ManyCondition( + (val, query) => query.Where(x => x.Title.Contains(val, StringComparison.OrdinalIgnoreCase)), + (val, query) => query.Where(x => !x.Title.Contains(val, StringComparison.OrdinalIgnoreCase)) + ) + ) + .Build(); + + var parsed = parser.Parse(filter); + + var filtered = await parsed.ExecuteAsync(toFilter); + + // Parsed query + Assert.Equal(2, filtered.Count()); + } + + [Fact] + public async Task ShouldParseNotBooleanQuery() + { + var document = new ArticleDocument(); + + var billsArticle = new Article + { + Title = "Article by bill about rabbits", + PublishedUtc = DateTime.UtcNow + }; + + var stevesArticle = new Article + { + Title = "Post by steve about cats", + PublishedUtc = DateTime.UtcNow + }; + + var paulsArticle = new Article + { + Title = "Blog by paul about chickens", + PublishedUtc = DateTime.UtcNow + }; + + document.Articles.Add(billsArticle); + document.Articles.Add(stevesArticle); + document.Articles.Add(paulsArticle); + + var filter = "title:NOT steve"; + + var toFilter = document.Articles.AsEnumerable(); + + var parser = new EnumerableEngineBuilder
() + .WithNamedTerm("title", b => b + .ManyCondition( + (val, query) => query.Where(x => x.Title.Contains(val, StringComparison.OrdinalIgnoreCase)), + (val, query) => query.Where(x => !x.Title.Contains(val, StringComparison.OrdinalIgnoreCase)) + ) + ) + .Build(); + + var parsed = parser.Parse(filter); + + var filtered = await parsed.ExecuteAsync(toFilter); + + // Parsed query + Assert.Equal(2, filtered.Count()); + } + + [Fact] + public async Task ShouldParseNotQueryWithOrder() + { + var document = new ArticleDocument(); + + var billsArticle = new Article + { + Title = "Article by bill about rabbits", + PublishedUtc = DateTime.UtcNow + }; + + var stevesArticle = new Article + { + Title = "Post by steve about cats", + PublishedUtc = DateTime.UtcNow + }; + + var paulsArticle = new Article + { + Title = "Blog by paul about chickens", + PublishedUtc = DateTime.UtcNow + }; + + document.Articles.Add(billsArticle); + document.Articles.Add(stevesArticle); + document.Articles.Add(paulsArticle); + + var filter = "title:about NOT steve"; + var filterQuery = document.Articles.AsEnumerable(); + + var parser = new EnumerableEngineBuilder
() + .WithNamedTerm("title", b => b + .ManyCondition( + (val, query) => query.Where(x => x.Title.Contains(val, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(x => x.Title), + (val, query) => query + .Where(x => !x.Title.Contains(val, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(x => x.Title) + ) + ) + .Build(); + + var parsed = parser.Parse(filter); + + var filtered = await parsed.ExecuteAsync(filterQuery); + + // Parsed query + Assert.Equal(2, filtered.Count()); + Assert.Equal("Blog by paul about chickens", filtered.FirstOrDefault().Title); + } + } +} diff --git a/test/YesSql.Tests/YesSql.Tests.csproj b/test/YesSql.Tests/YesSql.Tests.csproj index d1c956b9..0ceef013 100644 --- a/test/YesSql.Tests/YesSql.Tests.csproj +++ b/test/YesSql.Tests/YesSql.Tests.csproj @@ -28,6 +28,7 @@ +