diff --git a/CHANGELOG.md b/CHANGELOG.md index 1441623..1ada0ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ section is the GitHub release's notes. - Packages: the public surface is checked against 2.3.1's, so nothing 2.3.1 ships is removed or changed in 2.4.0. - Analyzers: CMTK0005 now also warns at `CollectionsMarshal.SetCount(ids, count)` on a `List`, which makes room for `count` items without putting one there, as `new OrderId[count]` does; a constant count of zero isn't reported. In a build that treats warnings as errors, that can report code that built before ([CMTK0005](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0005)). +- Analyzers: CMTK0009 now warns at `LeftJoin`, `RightJoin` and .NET 11's `FullJoin` whose side with no match is a value object, `Result` or `[RequireCustomInitialization]` struct, which the join fills with `default`; the message names an `Option` of each element, or a nullable projection in a query. In a build that treats warnings as errors, that can report code that built before ([CMTK0009](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0009)). ### Fixed diff --git a/design/analyzers.md b/design/analyzers.md index 6b64815..9875fdf 100644 --- a/design/analyzers.md +++ b/design/analyzers.md @@ -39,7 +39,7 @@ exist. | CMTK0006 | Field or auto-property of a no-default struct type in a class that no initializer, `required` or constructor sets; a non-public parameterless constructor (EF, serializers) and a record's copy constructor are exempt. Measured 2026-09-28 on the repository's EF entities and request types: 12 of 12 reports on settable properties of a type with only the implicit constructor, each a real way to a default. `required`, the fix the message names where it compiles, works with EF's compiled model, precompiled queries and Native AOT (the smoke test rerun with it). A `[SetsRequiredMembers]` constructor must assign the `required` members it promises | Warning | shipped in 1.0.0 | | CMTK0007 | `FromKnownGood` given a value straight from a parameter of the enclosing method or lambda, or a member or element of one. A value the code produced itself is legitimate and silent; a parameter copied into a local first is not seen. Info, since test theories and helpers that only receive constants are reported too | Info | shipped in 1.0.0 | | CMTK0008 | The wrapped values of two different value objects compared (`==`, `!=`, ordering, `Equals`, `CompareTo`) through `.Value`, `?.Value`, `GetValue()` or `ValueOrNull()`: the typed ids unwrapped into the bug they prevent. Recognised by syntax, since `.Value` of a same-project value object does not bind where analyzers run. Also `string.Equals`, `object.Equals`, a comparer's `Equals`, `string.Compare`/`CompareOrdinal` over two such values (the forms MA0006 rewrites `==` into), and two value objects of different types through `Equals(object)`, which is always false. Also a LINQ join on two such values: a query's `join … on a.Value equals b.Value`, with or without `into`, and `Join`/`GroupJoin`/`LeftJoin`/`RightJoin` of `Enumerable`, `Queryable` and `AsyncEnumerable` whose key selectors are lambdas returning them, resolved by symbol, with the candidates where a key selector does not bind; a composite key is not looked into. A raw value on one side, or an explicit cast, is silent | Warning | shipped in 1.0.0 | -| CMTK0009 | A call that returns the `default` of a no-default struct when it finds nothing: `Enumerable`/`Queryable` `FirstOrDefault`, `LastOrDefault`, `SingleOrDefault`, `ElementAtOrDefault`, `DefaultIfEmpty` without a default value, `Nullable.GetValueOrDefault()`, `GetValueOrDefault(dictionary, key)`, `Activator.CreateInstance`, `RuntimeHelpers.GetUninitializedObject`, and `Option.OrDefault()`. The message names `FirstOrNone`, `GetValueOrNone` or `Or`. Warning, since a sequence checked for emptiness first is correct. Also `ImmutableArray`'s own, `Find`/`FindLast` of `List`, `Array` and `ImmutableList`, `ImmutableDictionary.GetValueOrDefault` and the immutable builders', and the async `FirstOrDefaultAsync`, `LastOrDefaultAsync`, `SingleOrDefaultAsync` and `ElementAtOrDefaultAsync` of `System.Linq.AsyncEnumerable` and EF Core, whose type is the method's type argument; EF Core's only where the project references it. From 2.3.0, with a message of its own, `FirstOrNone`, `SingleOrNone`, `LastOrNone`, `FirstOrError` and `LastOrError` on a receiver that is an `IQueryable` (an implementer or a type parameter constrained to one included) bound to the `IEnumerable` overload because the condition is a delegate: a `Func` variable, field or property, a method group, an anonymous method, a call that returns one. A receiver typed as a sequence, `AsEnumerable()` or a cast, is silent; no code fix, since the condition's rewrite is the author's (#129) | Warning | shipped in 1.0.0 | +| CMTK0009 | A call that returns the `default` of a no-default struct when it finds nothing: `Enumerable`/`Queryable` `FirstOrDefault`, `LastOrDefault`, `SingleOrDefault`, `ElementAtOrDefault`, `DefaultIfEmpty` without a default value, `Nullable.GetValueOrDefault()`, `GetValueOrDefault(dictionary, key)`, `Activator.CreateInstance`, `RuntimeHelpers.GetUninitializedObject`, and `Option.OrDefault()`. The message names `FirstOrNone`, `GetValueOrNone` or `Or`. Warning, since a sequence checked for emptiness first is correct. Also `ImmutableArray`'s own, `Find`/`FindLast` of `List`, `Array` and `ImmutableList`, `ImmutableDictionary.GetValueOrDefault` and the immutable builders', and the async `FirstOrDefaultAsync`, `LastOrDefaultAsync`, `SingleOrDefaultAsync` and `ElementAtOrDefaultAsync` of `System.Linq.AsyncEnumerable` and EF Core, whose type is the method's type argument; EF Core's only where the project references it. From 2.3.0, with a message of its own, `FirstOrNone`, `SingleOrNone`, `LastOrNone`, `FirstOrError` and `LastOrError` on a receiver that is an `IQueryable` (an implementer or a type parameter constrained to one included) bound to the `IEnumerable` overload because the condition is a delegate: a `Func` variable, field or property, a method group, an anonymous method, a call that returns one. A receiver typed as a sequence, `AsEnumerable()` or a cast, is silent; no code fix, since the condition's rewrite is the author's (#129). From 2.4.0 (#261), the side of an outer join with no match: every overload of `LeftJoin` whose inner element, `RightJoin` whose outer element, and .NET 11's `FullJoin` where either element is a no-default struct, a diagnostic per side, on `Enumerable`, `Queryable` and `AsyncEnumerable`, the tuple-returning overloads included, resolved by symbol through `LinqJoins`, which CMTK0008 shares. Where a lambda reads a generated member and the join does not bind, the elements are read from the sequences passed, and the candidate is the one whose outer sequence the receiver is, `Queryable`'s for a query. The message names an `Option` of each element in memory and a nullable projection in a query (EF Core 11.0.0-rc.3 translates it under `FULL JOIN`, KB-059). An `Option` element of a join is not reported: its `default` is `None`, the way out the message names, which reporting it would flag in turn. Warning, no code fix | Warning | shipped in 1.0.0 | | CMTK0010 | A type argument that breaks a constraint attribute on its parameter (`[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]`; from 2.1.0 also `[Interface]`, `[Unsealed]`, `[ValueObject]`, `[ClosedSet]`, `[FlagsEnum]`, `[ReadOnlyStruct]`, [decision 81](adr/0081-six-more-kinds-of-type-argument.md), the two marker ones proved by a bound to the marker, the other four by the attribute alone; an interface is no value object, [decision 85](adr/0085-an-interface-is-no-value-object.md); a proof reads what a closed use can be, [decision 84](adr/0084-cmtk0010-proofs-read-what-a-closed-use-can-be.md)), or a pair of arguments for two parameters marked `[Distinct]` or `[Unrelated]` (2.1.0, [decision 79](adr/0079-distinct-and-unrelated-type-parameters.md)) that are one symbol (nullable annotations aside, `dynamic` as `object`) or related by an implicit identity, reference or boxing conversion either way, reported once at the later argument, a forwarding pair a breach unless both carry the attribute in one generic or their C# constraints admit no common (or no related) type, at any closed use: a base type, an implemented interface, a member type, a local, a `typeof`, a call with its type arguments written (at the argument) or inferred (at the call), a method group whose arguments the delegate infers (at the group). A type parameter forwarded without the same attribute is a breach too, unless its C# constraints leave it no breaking argument: `struct`/`unmanaged` or a bound proving `[Sealed]` proves `[Sealed]`; `new()` or whatever proves `[Sealed]` proves `[Concrete]`; a bound that is or proves a record proves `[Record]` (CS8865); nothing but the attribute proves `[Abstract]`. `[Concrete]` accepts an enum, a delegate and `dynamic`, as `new()` and `struct` do for an enum. A default interface method reached through a derived interface carries its own constraint once: it was its own implementation there, and the rule recursed until the compiler's stack overflowed (an independent review, 2026-10-14). Both settled 2026-10-13 before the tag, measured as false reports (an enum, a delegate, `dynamic`; six forwarding forms) with no miss among their neighbours. The open generic in its own body and an unbound `typeof` are silent. Reflection is out of reach | Error | built 2026-10-13 for 2.0.0 (phase 13), a major, so Error is within [decision 34](adr/0034-errors-fixed-within-major-version-warnings.md) | | CMTK0011 | A concrete class implementing or deriving from a type marked `[RequireParameterlessConstructor]`, directly or through another type, without a parameterless constructor of the required accessibility; a class declaring no constructor satisfies `Public`. A struct is never reported (decided 2026-10-13, before the tag): its parameterless constructor, implicit or declared, is public and can be nothing else (CS8958), so it satisfies `Public` as it is, and under any other accessibility the rule reported an Error only a suppression could answer, and the fix wrote a constructor that did not compile. A wrong accessibility is reported at the constructor. The code fix, the package's first, in `CodoMetis.TypeKit.Analyzers.CodeFixes.dll` beside the analyzer, writes or changes the constructor; a type with a primary constructor in any of its parts gets none (CS8862), nor, since 2026-10-14, one whose implicit `base()` binds to nothing (CS7036, CS0122, CS0121), which a review found the fix writing. It writes its own trivia and asks no formatter, which laid out the whole type again and wrote the machine's line endings: doc comments, attributes, comments and the members' layout stay, added lines take the members' indentation and the brace's line ending, a one-line type gets the constructor on its line | Error | built 2026-10-13 for 2.0.0 (phase 13) | | CMTK0012 | A closed set's generated `Match` given an argument by position: every parameter is a `Func` in declaration order, so reordering two members swapped their branches and compiled. Bound, only the generated signature counts; unbound (the declaring project, before weaving), a call named `Match` on a closed set with one argument per member, its names the generators' rule (the member's name, first letter lower-cased, a keyword escaped). The code fix names every argument after the parameter it binds to now | Error | built 2026-10-13 for 2.0.0 (phase 13), after a review found the swap | diff --git a/design/knowledge/KB-059-ef-core-11-translates-a-nullable-projection-under-full-join.md b/design/knowledge/KB-059-ef-core-11-translates-a-nullable-projection-under-full-join.md new file mode 100644 index 0000000..ad2f641 --- /dev/null +++ b/design/knowledge/KB-059-ef-core-11-translates-a-nullable-projection-under-full-join.md @@ -0,0 +1,29 @@ +--- +verified: 2026-10-31 +pins: + - external Microsoft.NETCore.App.Ref 11.0.0-rc.3.26522.148 and Microsoft.EntityFrameworkCore.Sqlite 11.0.0-rc.3.26522.148 +source: the join surface compiled against the 11.0.0-rc.3 reference assemblies by OuterJoinDefaultTests, and reflected over on the 11.0.0-rc.1 runtime; the translation measured with EF Core 11.0.0-rc.3 on SQLite in a `net11.0` console built by the 11.0.100-rc.1 SDK and run on the 11.0.0-rc.1 runtime, the only ones installed (`ToQueryString` and the rows read back); dotnet/runtime#127236 and "What's new in .NET 11 libraries" +turned-on: CMTK0009's outer joins (#261), which overloads it resolves, and the nullable projection its message and the Analyzers README name for a query +test: none, the repository's SDK cannot target net11.0 to run EF Core 11; the join surface is held by OuterJoinDefaultTests, which compiles against the 11.0.0-rc.3 reference assemblies +--- + +# KB-059. EF Core 11 translates a nullable projection under `FULL JOIN` + +- **.NET 11 adds `FullJoin` on `Enumerable`, `Queryable` and `AsyncEnumerable`.** With a result selector, + `FullJoin(outer, inner, outerKeySelector, innerKeySelector, resultSelector, + comparer = null)`; `AsyncEnumerable` also has the form whose selectors take a `CancellationToken` and return a + `ValueTask`. The type parameters are `TOuter` and `TInner` and the sequences `outer` and `inner`, as in .NET 10's + `LeftJoin` and `RightJoin`. +- **.NET 11 also adds outer joins that return tuples.** `LeftJoin`, `RightJoin` and `FullJoin` + take no result selector and return `(TOuter Outer, TInner Inner)`, on all three hosts, with an optional comparer. + The side with no match is `default` in the tuple as it is in the result selector. +- **EF Core 11 translates `FullJoin` to `FULL JOIN`, and a nullable projection with it.** Over a struct column with a + value converter, `db.Lefts.Select(l => (Code?)l.Code).FullJoin(db.Rights.Select(r => (Code?)r.Code), l => l, r => r, + (l, r) => new { l, r })` is `SELECT "l"."Code" AS "l", "r"."Code" AS "r" FROM "Lefts" AS "l" FULL JOIN "Rights" AS + "r" ON "l"."Code" = "r"."Code"`, and over the lefts 10, 20 and the rights 20, 30 reads back `10 | null`, + `20 | 20`, `null | 30`. The same projection under `LeftJoin` is a `LEFT JOIN` with `10 | null`, `20 | 20`. +- **Without the projection, EF throws for the side with no match.** The same `FullJoin` over `l.Code` and `r.Code` + translates to the same SQL and throws `InvalidOperationException: Nullable object must have a value.` when it reads + the rows. LINQ to objects gives `default` instead, which is what CMTK0009 reports. + +Checked on SQLite alone; SQL Server and PostgreSQL were not run. diff --git a/design/knowledge/README.md b/design/knowledge/README.md index d017164..b84c79c 100644 --- a/design/knowledge/README.md +++ b/design/knowledge/README.md @@ -88,4 +88,5 @@ test and later for the harness's `Baseline.Fact`. | [KB-055](KB-055-ericksonlopez-domainprimitives.md) | EricksonLopez.DomainPrimitives 3.0.0 is an attribute on a partial record struct, with a catalogue of ready-made types, re-validated on every read | EricksonLopez.DomainPrimitives 3.0.0 | prose | | [KB-057](KB-057-a-nullable-contract-has-an-element-type-and-reads-null-as-null.md) | A `Nullable` contract has `T` as its element type, and a JSON null read into it is null whatever `T`'s converter does | OpenApi 10.0.12, SDK 10.0.4xx | `KB057` | | [KB-058](KB-058-ef-orders-captured-values-in-sql-only-with-a-lambda.md) | EF orders captured values in SQL only when the chain takes a lambda: `values.Max()` runs in memory, `values.Max(v => v)` in SQL | EF Sqlite 10.0.12 | `KB058` | +| [KB-059](KB-059-ef-core-11-translates-a-nullable-projection-under-full-join.md) | .NET 11 adds `FullJoin` and tuple-returning outer joins on all three LINQ hosts, and EF Core 11 translates a nullable projection under `FULL JOIN`, where a non-nullable one throws for the side with no match | .NET 11.0.0-rc.3, EF Sqlite 11.0.0-rc.3 | prose | | [KB-060](KB-060-metalama-2026-1-on-the-net-11-sdk.md) | Metalama 2026.1 weaves a `net11.0` project on SDK 11 RC3 at C# 14, and a web project fails with `LAMA0625` when SDK 11 is the only SDK | .NET SDK 11 RC3, Metalama 2026.1.30, 2027.0.5-preview | prose | diff --git a/src/CodoMetis.TypeKit.Analyzers/DefaultProducingCallAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/DefaultProducingCallAnalyzer.cs index 23cde09..c310da8 100644 --- a/src/CodoMetis.TypeKit.Analyzers/DefaultProducingCallAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/DefaultProducingCallAnalyzer.cs @@ -42,6 +42,16 @@ namespace CodoMetis.TypeKit.Analyzers; /// X.TryFrom(…).OrDefault() is recognised there by name. /// /// +/// An outer join hands its result selector, or its tuple, the default of the side with no match: +/// LeftJoin's inner element, RightJoin's outer element and either element of .NET 11's +/// FullJoin, every overload on Enumerable, Queryable and AsyncEnumerable +/// (). Each such side whose element is a no-default struct is reported, read from the +/// call's type arguments, or from the sequences' element types where the call does not bind because a +/// lambda reads a generated member. In memory the way out is an Option of each element, whose +/// default is None, so an Option element is not reported here; in a query, a nullable +/// projection, which EF Core 11 translates under FULL JOIN as under LEFT JOIN. +/// +/// /// With a message of its own, the rule also reports FirstOrNone, SingleOrNone, /// LastOrNone, FirstOrError and LastOrError called on a query with a delegate for /// a condition: a Func variable, a field, a method group, an anonymous method, a call that @@ -97,6 +107,9 @@ public override void Initialize(AnalysisContext context) if (queryable is not null && func is not null && QueryDelegateForms(symbols, func) is { Count: > 0 } delegates) start.RegisterOperationAction(operation => AnalyzeQueryDelegate(operation, delegates, queryable, func), OperationKind.Invocation); + if (Joins(start.Compilation) is { Count: > 0 } joins) + start.RegisterSyntaxNodeAction(node => AnalyzeJoin(node, symbols, joins), SyntaxKind.InvocationExpression); + var forms = Forms(start.Compilation, symbols); if (forms.Count == 0) return; @@ -178,6 +191,35 @@ private static Dictionary QueryDelegateForms(TypeKitSymbo return forms; } + /// + /// ids.LeftJoin(known, …) and the other outer joins: each side the join fills with default + /// for a missing match whose element is a no-default struct, a diagnostic each. + /// + private static void AnalyzeJoin(SyntaxNodeAnalysisContext context, TypeKitSymbols symbols, Dictionary joins) + { + var invocation = (InvocationExpressionSyntax)context.Node; + + if (LinqJoins.NameOf(invocation) is not ("LeftJoin" or "RightJoin" or "FullJoin")) return; + if (LinqJoins.Resolve(context.SemanticModel, invocation, joins, context.CancellationToken, out var sides, out var bound) is not { } join) return; + + foreach (var side in sides) + { + // Where the call does not bind, no type argument is known: the sequence passed for the side says it. + var element = bound + ? join.TypeArguments[side.TypeParameter] + : LinqJoins.Element(context.SemanticModel, invocation, join, side.Sequence, context.CancellationToken); + + if (element is null) continue; + + if (element is { IsValueType: false, NullableAnnotation: NullableAnnotation.Annotated }) element = element.WithNullableAnnotation(NullableAnnotation.NotAnnotated); + + // A default Option is None: the side with no match is absent, which is the way out the message names. + if (symbols.DefaultRestriction(element) is null || symbols.OutcomeName(element) == "Option") continue; + + context.Report(Create(invocation, join.Name, element, side.Form)); + } + } + private static void Analyze(OperationAnalysisContext context, TypeKitSymbols symbols, Dictionary forms) { var invocation = (IInvocationOperation)context.Operation; @@ -366,6 +408,53 @@ private static Dictionary Forms(Compilation compilation, Ty return forms; } + /// + /// The outer joins by original definition, with each side they fill with default for a missing + /// match: LeftJoin's inner, RightJoin's outer, both of FullJoin's. An overload + /// that does not name both sides as the others do is left out. + /// + private static Dictionary Joins(Compilation compilation) + { + var joins = new Dictionary(SymbolEqualityComparer.Default); + var queryable = compilation.GetTypesByMetadataName("System.Linq.Queryable"); + + foreach (var method in LinqJoins.Overloads(compilation, ["LeftJoin", "RightJoin", "FullJoin"])) + { + // The type parameter and parameter names are the methods' public contract, the same for every + // overload: TOuter of outer, TInner of inner. + var inQuery = queryable.Contains(method.ContainingType, SymbolEqualityComparer.Default); + var outer = Side(method, "TOuter", "outer", " as the outer element of an inner element with no match", inQuery); + var inner = Side(method, "TInner", "inner", " as the inner element of an outer element with no match", inQuery); + + if (outer is null || inner is null) continue; + + joins[method] = method.Name switch + { + "LeftJoin" => [inner], + "RightJoin" => [outer], + _ => [outer, inner] + }; + } + + return joins; + } + + private static JoinSide? Side(IMethodSymbol join, string typeParameter, string sequence, string circumstance, bool inQuery) + { + var parameter = join.TypeParameters.FirstOrDefault(candidate => candidate.Name == typeParameter); + var argument = join.Parameters.FirstOrDefault(candidate => candidate.Name == sequence); + + if (parameter is null || argument is null) return null; + + // A default Option is None, so absence is explicit and an element that equals its type's default stays + // a value. A query cannot hold an Option, and a nullable projection is translated under any join. + var advice = inQuery + ? $"Select a nullable first, as in {sequence}.Select(x => ({{0}}?)x), so that no match is null" + : $"Wrap each {sequence} element in an Option before the join, as in {sequence}.Select(Option.Some), so that no match is None"; + + return new JoinSide(parameter.Ordinal, argument.Ordinal, new Form(Kind.Join, Produced.TypeArgument, circumstance, advice)); + } + private static void AddWithoutDefaultValue(Dictionary forms, INamedTypeSymbol? type, string name, Form form) { foreach (var method in Methods(type, name)) @@ -389,7 +478,8 @@ private enum Kind Nullable, Dictionary, Created, - OrDefault + OrDefault, + Join } /// Where the produced type is read at a call. @@ -408,6 +498,19 @@ private enum Produced TypeOf } + /// One side of an outer join that is default for a missing match. + /// The ordinal of its element's type parameter, TOuter or TInner. + /// The ordinal of the parameter its sequence is passed for, outer or inner. + /// How it is reported. + private sealed class JoinSide(int typeParameter, int sequence, Form form) + { + public int TypeParameter { get; } = typeParameter; + + public int Sequence { get; } = sequence; + + public Form Form { get; } = form; + } + /// How one method is reported. /// Which family the method is. /// Where the produced type is read. diff --git a/src/CodoMetis.TypeKit.Analyzers/LinqJoins.cs b/src/CodoMetis.TypeKit.Analyzers/LinqJoins.cs new file mode 100644 index 0000000..e37ef3f --- /dev/null +++ b/src/CodoMetis.TypeKit.Analyzers/LinqJoins.cs @@ -0,0 +1,147 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace CodoMetis.TypeKit.Analyzers; + +/// +/// The LINQ joins of Enumerable, Queryable and AsyncEnumerable, resolved by symbol, and +/// what a call to one passes for each of its parameters. CMTK0008 reads their key selectors and CMTK0009 +/// the side an outer join fills with default. +/// +internal static class LinqJoins +{ + /// + /// Every match of a name counts, as in : the System.Linq.Async package + /// declares an AsyncEnumerable of its own. + /// + private static readonly string[] Hosts = ["System.Linq.Enumerable", "System.Linq.Queryable", "System.Linq.AsyncEnumerable"]; + + /// Every overload of the joins named the compilation can call, by original definition. + public static IEnumerable Overloads(Compilation compilation, IReadOnlyCollection names) => + Hosts.SelectMany(name => compilation.GetTypesByMetadataName(name)) + .SelectMany(type => names.SelectMany(name => type.GetMembers(name))) + .OfType(); + + /// The name a call is made by, as an extension, after ?. or statically, or null for any other form. + public static string? NameOf(InvocationExpressionSyntax invocation) => + invocation.Expression switch + { + MemberAccessExpressionSyntax access => access.Name.Identifier.ValueText, + MemberBindingExpressionSyntax binding => binding.Name.Identifier.ValueText, + IdentifierNameSyntax identifier => identifier.Identifier.ValueText, + _ => null + }; + + /// The receiver of an extension call: rows of rows.LeftJoin(…) and of rows?.LeftJoin(…). + private static ExpressionSyntax? Receiver(InvocationExpressionSyntax invocation) => + invocation.Expression switch + { + MemberAccessExpressionSyntax access => access.Expression, + MemberBindingExpressionSyntax => invocation.FirstAncestorOrSelf()?.Expression, + _ => null + }; + + /// + /// The join binds to or, where it does not bind, the candidate in + /// it would bind to, with what that join maps to. + /// + /// + /// A lambda that reads the generated .Value of a value object of the same project does not bind + /// where Metalama runs analyzers, and neither does the join that takes it. Its candidates are every host's + /// join of that name, with the parameters in the same places. A query is a sequence too, so the candidate + /// taken is the one whose outer sequence the receiver is, the most derived of those: Queryable's for + /// an IQueryable<T>, as overload resolution would choose. + /// + public static IMethodSymbol? Resolve(SemanticModel model, InvocationExpressionSyntax invocation, Dictionary joins, CancellationToken cancellation, out T form, out bool bound) + { + var info = model.GetSymbolInfo(invocation, cancellation); + + bound = info.Symbol is not null; + form = default!; + + if (info.Symbol is IMethodSymbol symbol) return joins.TryGetValue(Definition(symbol), out form!) ? symbol : null; + + var candidates = info.CandidateSymbols.OfType().Where(candidate => joins.ContainsKey(Definition(candidate))).ToList(); + if (candidates.Count == 0) return null; + + var receivers = candidates.Where(candidate => OuterSequence(candidate) is { } sequence && Element(model, invocation, candidate, sequence.Ordinal, cancellation) is not null).ToList(); + var join = receivers.FirstOrDefault(candidate => receivers.All(other => Derives(OuterSequence(candidate)!, OuterSequence(other)!))) ?? candidates[0]; + + form = joins[Definition(join)]; + return join; + } + + /// The outer parameter of a join's definition, whose type is the outer sequence. + private static IParameterSymbol? OuterSequence(IMethodSymbol join) => Definition(join).Parameters.FirstOrDefault(parameter => parameter.Name == "outer"); + + /// Whether the sequence type of is that of or derives from it. + private static bool Derives(IParameterSymbol parameter, IParameterSymbol other) + { + var sequence = parameter.Type.OriginalDefinition; + var target = other.Type.OriginalDefinition; + + return SymbolEqualityComparer.Default.Equals(sequence, target) + || sequence.AllInterfaces.Any(candidate => SymbolEqualityComparer.Default.Equals(candidate.OriginalDefinition, target)); + } + + /// + /// The element type of the sequence passes for the parameter of the join's + /// definition at , as that parameter reads it: T of the one + /// IEnumerable<T>, IQueryable<T> or IAsyncEnumerable<T> the argument + /// is, implements, or is constrained to. + /// + public static ITypeSymbol? Element(SemanticModel model, InvocationExpressionSyntax invocation, IMethodSymbol join, int ordinal, CancellationToken cancellation) + { + if (Definition(join).Parameters[ordinal].Type is not INamedTypeSymbol { IsGenericType: true } parameter + || Argument(invocation, join, ordinal) is not { } argument) return null; + + var sequence = parameter.OriginalDefinition; + var type = model.GetTypeInfo(argument, cancellation).Type; + + IEnumerable candidates = type switch + { + null => [], + ITypeParameterSymbol constrained => constrained.ConstraintTypes.SelectMany(constraint => constraint.AllInterfaces.Prepend(constraint)), + _ => type.AllInterfaces.Prepend(type) + }; + + var matches = candidates.OfType() + .Where(candidate => SymbolEqualityComparer.Default.Equals(candidate.OriginalDefinition, sequence)) + .Distinct(SymbolEqualityComparer.Default) + .ToList(); + + return matches.Count == 1 ? matches[0].TypeArguments[0] : null; + } + + /// The unreduced original definition of a join, which lists. + public static IMethodSymbol Definition(IMethodSymbol join) => (join.ReducedFrom ?? join).OriginalDefinition; + + /// + /// What passes for the parameter of the join's definition at + /// , by position or by name: outer.Join(inner, …) passes the outer + /// sequence as the receiver, so its first argument is the second parameter. + /// + public static ExpressionSyntax? Argument(InvocationExpressionSyntax invocation, IMethodSymbol join, int ordinal) + { + var definition = Definition(join); + var offset = join.ReducedFrom is null ? 0 : 1; + + if (offset == 1 && ordinal == 0) return Receiver(invocation); + + var arguments = invocation.ArgumentList.Arguments; + + for (var index = 0; index < arguments.Count; index++) + { + var at = arguments[index].NameColon is { } name + ? definition.Parameters.FirstOrDefault(parameter => parameter.Name == name.Name.Identifier.ValueText)?.Ordinal + : index + offset; + + if (at == ordinal) return arguments[index].Expression; + } + + return null; + } +} diff --git a/src/CodoMetis.TypeKit.Analyzers/MixedValueComparisonAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/MixedValueComparisonAnalyzer.cs index 34a87a2..c05d5e1 100644 --- a/src/CodoMetis.TypeKit.Analyzers/MixedValueComparisonAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/MixedValueComparisonAnalyzer.cs @@ -121,37 +121,10 @@ private static void AnalyzeJoinClause(SyntaxNodeAnalysisContext context, TypeKit /// private static void AnalyzeJoin(SyntaxNodeAnalysisContext context, InvocationExpressionSyntax invocation, TypeKitSymbols symbols, Dictionary joins) { - var info = context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken); + if (LinqJoins.Resolve(context.SemanticModel, invocation, joins, context.CancellationToken, out var keys, out _) is not { } join) return; - IMethodSymbol? join = null; - KeySelectors keys = default; - - foreach (var candidate in info.Symbol is IMethodSymbol bound ? [bound] : info.CandidateSymbols.OfType()) - { - if (!joins.TryGetValue((candidate.ReducedFrom ?? candidate).OriginalDefinition, out keys)) continue; - - join = candidate; - break; - } - - if (join is null) return; - - // outer.Join(inner, …) passes the outer sequence as the receiver: the first argument is the second parameter. - var definition = (join.ReducedFrom ?? join).OriginalDefinition; - var offset = join.ReducedFrom is null ? 0 : 1; - var arguments = invocation.ArgumentList.Arguments; - - ExpressionSyntax? outerKey = null, innerKey = null; - - for (var index = 0; index < arguments.Count; index++) - { - var ordinal = arguments[index].NameColon is { } name - ? definition.Parameters.FirstOrDefault(parameter => parameter.Name == name.Name.Identifier.ValueText)?.Ordinal - : index + offset; - - if (ordinal == keys.Outer) outerKey = arguments[index].Expression; - else if (ordinal == keys.Inner) innerKey = arguments[index].Expression; - } + var outerKey = LinqJoins.Argument(invocation, join, keys.Outer); + var innerKey = LinqJoins.Argument(invocation, join, keys.Inner); if (outerKey is null || innerKey is null || KeyOf(outerKey) is not { } outer || KeyOf(innerKey) is not { } inner) return; @@ -169,24 +142,19 @@ private static Location Across(SyntaxNode first, SyntaxNode second) => /// /// The LINQ joins by their original definitions, with where their key selectors are: Join, /// GroupJoin, LeftJoin and RightJoin of Enumerable, Queryable and - /// AsyncEnumerable, each type resolved from the compilation. Every match of a name counts, as - /// in : the System.Linq.Async package declares an - /// AsyncEnumerable of its own. + /// AsyncEnumerable (). /// private static Dictionary Joins(Compilation compilation) { var joins = new Dictionary(SymbolEqualityComparer.Default); - foreach (var type in new[] { "System.Linq.Enumerable", "System.Linq.Queryable", "System.Linq.AsyncEnumerable" }.SelectMany(name => compilation.GetTypesByMetadataName(name))) + foreach (var method in LinqJoins.Overloads(compilation, JoinNames)) { - foreach (var method in JoinNames.SelectMany(name => type.GetMembers(name)).OfType()) - { - // The parameter names are the methods' public contract, the same for every overload. - var outer = method.Parameters.FirstOrDefault(parameter => parameter.Name == "outerKeySelector"); - var inner = method.Parameters.FirstOrDefault(parameter => parameter.Name == "innerKeySelector"); + // The parameter names are the methods' public contract, the same for every overload. + var outer = method.Parameters.FirstOrDefault(parameter => parameter.Name == "outerKeySelector"); + var inner = method.Parameters.FirstOrDefault(parameter => parameter.Name == "innerKeySelector"); - if (outer is not null && inner is not null) joins[method] = new KeySelectors(outer.Ordinal, inner.Ordinal); - } + if (outer is not null && inner is not null) joins[method] = new KeySelectors(outer.Ordinal, inner.Ordinal); } return joins; diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index e44cdde..64827ec 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -34,7 +34,7 @@ project. The two packages share a version and move together. | CMTK0006 | Warning | A field or auto-property of such a type, in a class, that no initializer, `required` or constructor sets. Also a `required` one that a `[SetsRequiredMembers]` constructor doesn't set. | | CMTK0007 | Info | `FromKnownGood` given a value that comes straight from a parameter. | | CMTK0008 | Warning | The values inside two different value objects compared, `order.CustomerId.Value == product.Id.Value`, or the two value objects themselves compared through `Equals(object)`. | -| CMTK0009 | Warning | A call that returns the `default` of such a type when it finds nothing: `FirstOrDefault()`, `GetValueOrDefault()`, `Option.OrDefault()` and their kind. Also `FirstOrNone`, `SingleOrNone` or `FirstOrError` on a query given a delegate, which reads every row. | +| CMTK0009 | Warning | A call that returns the `default` of such a type when it finds nothing: `FirstOrDefault()`, `GetValueOrDefault()`, `Option.OrDefault()` and their kind, and the side of a `LeftJoin`, `RightJoin` or `FullJoin` with no match. Also `FirstOrNone`, `SingleOrNone` or `FirstOrError` on a query given a delegate, which reads every row. | | CMTK0010 | Error | A type argument that breaks a constraint its parameter declares by attribute: `[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]`, `[Interface]`, `[Unsealed]`, `[ValueObject]`, `[ClosedSet]`, `[FlagsEnum]` or `[ReadOnlyStruct]`. Also a pair of arguments for two parameters marked `[Distinct]` or `[Unrelated]` that are one type, or related by inheritance, implementation or boxing, reported once at the later argument. Checked at any closed use, written or inferred: a base type, a member, a parameter, a local, a `typeof`, an attribute, a call or a method group, a member of a generic extension block, whose arguments the receiver gives, and a method the compiler calls for a `foreach`, an `await`, a deconstruction, a positional pattern or a collection expression (there, `[Abstract]`, `[Sealed]`, `[Record]` and `[Concrete]` join in 3.0.0). A type parameter passed on without the attribute is a breach too. A method that implements an interface's method, or overrides one, carries the constraint of what it implements, a static one and one a base class declares included. | | CMTK0011 | Error | A concrete class that derives from or implements a type marked `[RequireParameterlessConstructor]`, directly or through another, without a parameterless constructor of the accessibility it asks for. A struct is never reported: its parameterless constructor is public and can be nothing else. The code fix writes or changes the constructor. | | CMTK0012 | Error | A closed set's `Match` given an argument by position. It binds to whichever member is declared at that place, so reordering the members swaps the branches. The code fix names every argument. | @@ -430,13 +430,19 @@ var first = ids.FirstOrDefault(); // CMTK0009: an OrderId nob | `dictionary.GetValueOrDefault(key)`, an immutable dictionary's included | `GetValueOrNone(key)`, which returns an `Option`, or pass a default value | | `Activator.CreateInstance()`, `Activator.CreateInstance(typeof(T))`, `RuntimeHelpers.GetUninitializedObject(typeof(T))` | one of the type's factories | | `Option.OrDefault()` | `Or(fallback)` or `Match`, which say what `None` becomes | +| `LeftJoin`, `RightJoin`, and .NET 11's `FullJoin` on a sequence or an `IAsyncEnumerable`: `LeftJoin`'s inner element when an outer one has no match, `RightJoin`'s outer element, either of `FullJoin`'s | wrap that side's elements in an `Option` before the join, `inner.Select(Option.Some)`. A `default` `Option` is `None`, so no match reads as `None` | +| `Queryable.LeftJoin`, `RightJoin`, `FullJoin` | select a nullable first, `inner.Select(x => (OrderId?)x)`, so no match is null. EF Core translates it under `LEFT JOIN`, and EF Core 11 under `FULL JOIN` too (checked with 11.0.0-rc.3.26522.148) | The element type is judged as CMTK0001 judges a `default`: a value object, `Option`, `Result`, `[RequireCustomInitialization]` struct, or a type parameter constrained to be one. For the -asynchronous forms, it is the type the task returns. - -Not reported: an overload given a default value, and an element type whose default is null or an -ordinary value. EF Core's forms are recognised in any project that references EF Core. +asynchronous forms, it is the type the task returns. For a join, it is the element of the side with +no match, and each such side is a warning of its own: a `FullJoin` of two value objects gets two. + +Not reported: an overload given a default value, an element type whose default is null or an +ordinary value, the side of a join that always has an element (`LeftJoin`'s outer, `RightJoin`'s +inner), `Join` and `GroupJoin`, which have no side without a match, and an `Option` element of a +join, whose `default` is `None`. EF Core's forms are recognised in any project that references EF +Core. It's a warning, because code that checks the sequence isn't empty first is correct, and the rule can't tell. diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/NoDefaultKindTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/NoDefaultKindTests.cs index b4b3576..bd2ba85 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/NoDefaultKindTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/NoDefaultKindTests.cs @@ -18,7 +18,7 @@ namespace CodoMetis.TypeKit.Analyzers.Tests; /// public sealed class NoDefaultKindTests { - private static bool HasNoDefault(TypeArgumentKind kind) => kind switch + internal static bool HasNoDefault(TypeArgumentKind kind) => kind switch { KindOfType type => type.Is.HasFlag(Is.Struct) && type.Is.HasFlag(Is.ValueObjectMarker), KindOfParameter parameter => parameter.Facts.Declares.HasFlag(Declares.ValueObjectBound) diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/OuterJoinDefaultTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/OuterJoinDefaultTests.cs new file mode 100644 index 0000000..fb39f99 --- /dev/null +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/OuterJoinDefaultTests.cs @@ -0,0 +1,330 @@ +using CodoMetis.TypeKit.Analyzers.Tests.Variants; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; + +namespace CodoMetis.TypeKit.Analyzers.Tests; + +/// +/// CMTK0009's outer joins: LeftJoin hands its result selector the default of the inner element +/// for an outer element with no match, RightJoin the outer element's, and .NET 11's FullJoin +/// either, on Enumerable, Queryable and AsyncEnumerable. +/// +/// +/// .NET 10's reference assemblies have no FullJoin and no join that returns tuples, so the tests of those +/// compile against .NET 11's (), and the rest against .NET 10's, as every analyzer test does. +/// The element is judged as CMTK0001 judges a default, except an Option, whose default is +/// None: the way out the message names. +/// +public sealed class OuterJoinDefaultTests +{ + private const string Subjects = + """ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + using CodoMetis.TypeKit; + using CodoMetis.TypeKit.ValueObjects; + + public enum Fault { Refused } + + public readonly struct Plain : IValue { } + + public readonly struct Validated : IValidatedValue + { + public static Result Create(int value) => Result.Error(Fault.Refused); + } + + [RequireCustomInitialization] + public struct Guarded { public int N; } + + public struct Free { public int N; } + + public sealed class Row { public int Key { get; set; } } + + """; + + /// + /// .NET 11's reference assemblies, 11.0.0-rc.3, which declare FullJoin and the joins that return + /// tuples, resolved from NuGet as is. + /// + private static readonly ReferenceAssemblies Net110 = + new("net11.0", new PackageIdentity("Microsoft.NETCore.App.Ref", "11.0.0-rc.3.26522.148"), Path.Combine("ref", "net11.0")); + + private static AnalyzerTest Test(string code, bool net11 = false, params DiagnosticResult[] expected) + { + var test = new AnalyzerTest(Subjects + code); + if (net11) test.ReferenceAssemblies = Net110; + test.ExpectedDiagnostics.AddRange(expected); + return test; + } + + private static string Advice(string side, string type, bool query) => + query + ? $"Select a nullable first, as in {side}.Select(x => ({type}?)x), so that no match is null" + : $"Wrap each {side} element in an Option before the join, as in {side}.Select(Option.Some), so that no match is None"; + + /// The inner element, default for an outer element with no match. + private static DiagnosticResult Inner(int location, string method, string type, bool query = false) => + new DiagnosticResult("CMTK0009", DiagnosticSeverity.Warning) + .WithLocation(location) + .WithMessage($"'{method}' returns a default '{type}' as the inner element of an outer element with no match, an instance that passed no factory. {Advice("inner", type, query)}."); + + /// The outer element, default for an inner element with no match. + private static DiagnosticResult Outer(int location, string method, string type, bool query = false) => + new DiagnosticResult("CMTK0009", DiagnosticSeverity.Warning) + .WithLocation(location) + .WithMessage($"'{method}' returns a default '{type}' as the outer element of an inner element with no match, an instance that passed no factory. {Advice("outer", type, query)}."); + + /// + /// .NET 10's LeftJoin and RightJoin on each host, with and without a comparer, with + /// asynchronous key selectors, statically and with named arguments: the side the join fills is reported. + /// + [Fact] + public Task Each_outer_join_reports_the_side_it_fills_with_default() => + Test( + """ + public static class C + { + public static void Run(List plains, List rows, IQueryable queryPlains, IQueryable queryRows, + IAsyncEnumerable streamPlains, IAsyncEnumerable streamRows) + { + var left = {|#0:rows.LeftJoin(plains, r => r.Key, p => 1, (r, p) => p)|}; + var leftCompared = {|#1:rows.LeftJoin(plains, r => r.Key, p => 1, (r, p) => p, EqualityComparer.Default)|}; + var right = {|#2:plains.RightJoin(rows, p => 1, r => r.Key, (p, r) => p)|}; + var rightCompared = {|#3:plains.RightJoin(rows, p => 1, r => r.Key, (p, r) => p, EqualityComparer.Default)|}; + var queryLeft = {|#4:queryRows.LeftJoin(plains, r => r.Key, p => 1, (r, p) => p)|}; + var queryRight = {|#5:queryPlains.RightJoin(rows, p => 1, r => r.Key, (p, r) => p)|}; + var streamLeft = {|#6:streamRows.LeftJoin(streamPlains, r => r.Key, p => 1, (r, p) => p)|}; + var streamRight = {|#7:streamPlains.RightJoin(streamRows, p => 1, r => r.Key, (p, r) => p)|}; + var streamAwaited = {|#8:streamRows.LeftJoin(streamPlains, (r, ct) => ValueTask.FromResult(r.Key), (p, ct) => ValueTask.FromResult(1), (r, p, ct) => ValueTask.FromResult(p))|}; + var staticForm = {|#9:Enumerable.RightJoin(plains, rows, p => 1, r => r.Key, (p, r) => p)|}; + var named = {|#10:rows.LeftJoin(resultSelector: (r, p) => p, inner: plains, innerKeySelector: p => 1, outerKeySelector: r => r.Key)|}; + var conditional = plains?{|#11:.RightJoin(rows, p => 1, r => r.Key, (p, r) => p)|}; + } + } + """, + expected: + [ + Inner(0, "LeftJoin", "Plain"), + Inner(1, "LeftJoin", "Plain"), + Outer(2, "RightJoin", "Plain"), + Outer(3, "RightJoin", "Plain"), + Inner(4, "LeftJoin", "Plain", query: true), + Outer(5, "RightJoin", "Plain", query: true), + Inner(6, "LeftJoin", "Plain"), + Outer(7, "RightJoin", "Plain"), + Inner(8, "LeftJoin", "Plain"), + Outer(9, "RightJoin", "Plain"), + Inner(10, "LeftJoin", "Plain"), + Outer(11, "RightJoin", "Plain"), + ]) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// .NET 11's FullJoin fills either side, each a diagnostic of its own, and the joins that return + /// tuples fill them as the ones with a result selector do. + /// + [Fact] + public Task FullJoin_reports_each_side_and_the_tuple_forms_report_as_the_others() => + Test( + """ + public static class C + { + public static void Run(List plains, List guarded, List rows, IQueryable queryPlains, IQueryable queryRows, + IAsyncEnumerable streamPlains, IAsyncEnumerable streamRows) + { + var both = {|#0:plains.FullJoin(guarded, p => 1, g => 1, (p, g) => 0)|}; + var outerOnly = {|#1:plains.FullJoin(rows, p => 1, r => r.Key, (p, r) => 0)|}; + var innerOnly = {|#2:rows.FullJoin(plains, r => r.Key, p => 1, (r, p) => 0, EqualityComparer.Default)|}; + var tuples = {|#3:rows.FullJoin(plains, r => r.Key, p => 1)|}; + var leftTuples = {|#4:rows.LeftJoin(plains, r => r.Key, p => 1)|}; + var rightTuples = {|#5:plains.RightJoin(rows, p => 1, r => r.Key)|}; + var query = {|#6:queryPlains.FullJoin(rows, p => 1, r => r.Key, (p, r) => 0)|}; + var queryTuples = {|#7:queryRows.FullJoin(plains, r => r.Key, p => 1)|}; + var queryLeftTuples = {|#8:queryRows.LeftJoin(plains, r => r.Key, p => 1)|}; + var stream = {|#9:streamPlains.FullJoin(streamRows, p => 1, r => r.Key, (p, r) => 0)|}; + var streamAwaited = {|#10:streamRows.FullJoin(streamPlains, (r, ct) => ValueTask.FromResult(r.Key), (p, ct) => ValueTask.FromResult(1), (r, p, ct) => ValueTask.FromResult(0))|}; + var streamTuples = {|#11:streamPlains.RightJoin(streamRows, (p, ct) => ValueTask.FromResult(1), (r, ct) => ValueTask.FromResult(r.Key))|}; + } + } + """, + net11: true, + expected: + [ + Outer(0, "FullJoin", "Plain"), + Inner(0, "FullJoin", "Guarded"), + Outer(1, "FullJoin", "Plain"), + Inner(2, "FullJoin", "Plain"), + Inner(3, "FullJoin", "Plain"), + Inner(4, "LeftJoin", "Plain"), + Outer(5, "RightJoin", "Plain"), + Outer(6, "FullJoin", "Plain", query: true), + Inner(7, "FullJoin", "Plain", query: true), + Inner(8, "LeftJoin", "Plain", query: true), + Outer(9, "FullJoin", "Plain"), + Inner(10, "FullJoin", "Plain"), + Outer(11, "RightJoin", "Plain"), + ]) + .RunAsync(TestContext.Current.CancellationToken); + + /// Every type CMTK0001 forbids the default of, a type parameter constrained to a value object included. + [Fact] + public Task Every_no_default_element_type_reports() => + Test( + """ + public static class C + { + public static void Run(List rows, List validated, List guarded, List> results) + { + var checkedOne = {|#0:rows.LeftJoin(validated, r => r.Key, v => 1, (r, v) => 0)|}; + var custom = {|#1:guarded.RightJoin(rows, g => 1, r => r.Key, (g, r) => 0)|}; + var result = {|#2:rows.LeftJoin(results, r => r.Key, x => 1, (r, x) => 0)|}; + } + + public static void Struct(List rows, IEnumerable items) where T : struct, IValue => _ = {|#3:rows.LeftJoin(items, r => r.Key, i => 1, (r, i) => 0)|}; + + public static void New(List rows, IEnumerable items) where T : IValue, new() => _ = {|#4:items.RightJoin(rows, i => 1, r => r.Key, (i, r) => 0)|}; + } + """, + expected: + [ + Inner(0, "LeftJoin", "Validated"), + Outer(1, "RightJoin", "Guarded"), + Inner(2, "LeftJoin", "Result"), + Inner(3, "LeftJoin", "T"), + Outer(4, "RightJoin", "T"), + ]) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// The side a join never fills, an element whose default is null or an ordinary value, an Option + /// (the way out in memory, whose default is None), the nullable projection (the way out in a query), + /// and the joins with no unmatched side stay silent. + /// + [Fact] + public Task The_side_never_filled_a_legitimate_default_the_ways_out_and_inner_joins_stay_silent() => + Test( + """ + public static class C + { + public static void Run(List plains, List rows, List nullables, List free, List> options, + IQueryable queryRows, IAsyncEnumerable streamPlains, IAsyncEnumerable streamRows) + { + var outerOfLeft = plains.LeftJoin(rows, p => 1, r => r.Key, (p, r) => p); + var innerOfRight = rows.RightJoin(plains, r => r.Key, p => 1, (r, p) => p); + var outerOfLeftTuples = plains.LeftJoin(rows, p => 1, r => r.Key); + var classes = rows.LeftJoin(rows, r => r.Key, s => s.Key, (r, s) => s); + var classesFull = rows.FullJoin(rows, r => r.Key, s => s.Key, (r, s) => s); + var nullable = rows.LeftJoin(nullables, r => r.Key, p => 1, (r, p) => p); + var ordinary = free.FullJoin(free, f => f.N, g => g.N, (f, g) => g); + var option = rows.LeftJoin(options, r => r.Key, o => 1, (r, o) => o); + var wayOut = rows.LeftJoin(plains.Select(Option.Some), r => r.Key, p => 1, (r, p) => p); + var wayOutFull = plains.Select(Option.Some).FullJoin(plains.Select(Option.Some), p => 1, q => 1, (p, q) => p); + var wayOutStream = streamRows.LeftJoin(streamPlains.Select(Option.Some), r => r.Key, p => 1, (r, p) => p); + var wayOutQuery = queryRows.LeftJoin(plains.Select(p => (Plain?)p), r => r.Key, p => 1, (r, p) => p); + var inner = plains.Join(plains, p => 1, q => 1, (p, q) => p); + var grouped = rows.GroupJoin(plains, r => r.Key, p => 1, (r, ps) => ps); + } + } + """, + net11: true) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// In a project that declares value objects, Metalama runs analyzers before weaving, where a lambda that + /// reads the generated .Value does not bind (CS1061 here), and neither does the join that takes it. + /// Its candidates are LINQ's joins, and the element types are read from the sequences passed: a list, an + /// array, a query, a type parameter constrained to a sequence, after ?., in any argument form. + /// + [Fact] + public Task A_join_that_does_not_bind_is_read_from_its_candidates() => + Test( + """ + public static class C + { + public static void Run(List plains, Plain[] array, List rows, IQueryable queryRows) + { + var left = {|#0:rows.LeftJoin(plains, r => r.Key, p => p.{|#10:Value|}, (r, p) => p)|}; + var right = {|#1:plains.RightJoin(rows, p => p.{|#11:Value|}, r => r.Key, (p, r) => p)|}; + var fromArray = {|#2:rows.LeftJoin(array, r => r.Key, p => p.{|#12:Value|}, (r, p) => p)|}; + var query = {|#3:queryRows.LeftJoin(plains, r => r.Key, p => p.{|#13:Value|}, (r, p) => p)|}; + var staticForm = {|#4:Enumerable.LeftJoin(rows, plains, r => r.Key, p => p.{|#14:Value|}, (r, p) => p)|}; + var named = {|#5:rows.LeftJoin(innerKeySelector: p => p.{|#15:Value|}, inner: array, outerKeySelector: r => r.Key, resultSelector: (r, p) => p)|}; + var outerOfLeft = plains.LeftJoin(rows, p => p.{|#16:Value|}, r => r.Key, (p, r) => p); + var conditional = plains?{|#7:.RightJoin(rows, p => p.{|#18:Value|}, r => r.Key, (p, r) => p)|}; + } + + public static void Constrained(List rows, TSequence plains) where TSequence : IEnumerable => + _ = {|#6:rows.LeftJoin(plains, r => r.Key, p => p.{|#17:Value|}, (r, p) => p)|}; + } + """, + expected: + [ + Inner(0, "LeftJoin", "Plain"), Unbound(10), + Outer(1, "RightJoin", "Plain"), Unbound(11), + Inner(2, "LeftJoin", "Plain"), Unbound(12), + Inner(3, "LeftJoin", "Plain", query: true), Unbound(13), + Inner(4, "LeftJoin", "Plain"), Unbound(14), + Inner(5, "LeftJoin", "Plain"), Unbound(15), + Unbound(16), + Outer(7, "RightJoin", "Plain"), Unbound(18), + Inner(6, "LeftJoin", "Plain"), Unbound(17), + ]) + .RunAsync(TestContext.Current.CancellationToken); + + /// The generated .Value that does not bind before weaving, listed in source order with the rule's results. + private static DiagnosticResult Unbound(int location) => DiagnosticResult.CompilerError("CS1061").WithLocation(location); + + public static TheoryData Kinds() => [.. TypeArgumentKinds.All.Where(kind => kind.Mentions.All(Declared)).Select(kind => kind.Name)]; + + private static bool Declared(string attribute) => RealTypeKit.Assembly.GetType($"CodoMetis.TypeKit.{attribute}Attribute") is not null; + + /// + /// Across the variant catalogue of what a type is (), each join reports the + /// side it fills exactly where CMTK0001 reports a default of the kind (), + /// and never the side it does not fill. + /// + [Theory] + [MemberData(nameof(Kinds))] + public Task Each_kind_is_reported_on_the_side_a_join_fills_as_CMTK0001_judges_it(string kindName) + { + var kind = TypeArgumentKinds.All.Single(candidate => candidate.Name == kindName); + var reporting = NoDefaultKindTests.HasNoDefault(kind); + + string At(int location, string code) => reporting ? $"{{|#{location}:{code}|}}" : code; + + var test = new AnalyzerTest( + $$""" + #nullable enable + using System.Linq; + + public static class Use{{kind.Parameters}} {{kind.Where}} + { + public static void M(IEnumerable keys, IEnumerable<{{kind.Argument}}> items) + { + _ = {{At(0, "keys.LeftJoin(items, k => 0, i => 0, (k, i) => 0)")}}; + _ = {{At(1, "items.RightJoin(keys, i => 0, k => 0, (i, k) => 0)")}}; + _ = {{At(2, "items.FullJoin(keys, i => 0, k => 0, (i, k) => 0)")}}; + _ = {{At(3, "keys.FullJoin(items, k => 0, i => 0)")}}; + _ = items.LeftJoin(keys, i => 0, k => 0, (i, k) => 0); + _ = keys.RightJoin(items, k => 0, i => 0, (k, i) => 0); + } + } + """); + + test.TestState.Sources.Add(("Candidates.cs", TypeArgumentKinds.Candidates)); + test.ReferenceAssemblies = Net110; + test.TestBehaviors = VariantSweep.Behaviors; + + if (reporting) + { + test.ExpectedDiagnostics.AddRange( + from location in Enumerable.Range(0, 4) + select new DiagnosticResult("CMTK0009", DiagnosticSeverity.Warning).WithLocation(location)); + } + + return test.RunAsync(TestContext.Current.CancellationToken); + } +} diff --git a/test/oracles/claims.json b/test/oracles/claims.json index 8855858..c20cc21 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -2890,6 +2890,35 @@ "UnassignedNoDefaultMemberAnalyzerTests" ] }, + { + "id": "K09-a-join-reports-each-side-with-no-match", + "source": "src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0009", + "quote": "For a join, it is the element of the side with no match, and each such side is a warning of its own: a `FullJoin` of two value objects gets two.", + "claim": "For a join, it is the element of the side with no match, and each such side is a warning of its own: a `FullJoin` of two value objects gets two.", + "oracle": "claim", + "surfaces": [ + "IValueObject.*", + "IValidatedValue.*" + ], + "heldBy": [ + "OuterJoinDefaultTests" + ] + }, + { + "id": "K09-an-option-element-of-a-join-is-silent", + "source": "src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0009", + "quote": "the side of a join that always has an element (`LeftJoin`'s outer, `RightJoin`'s inner), `Join` and `GroupJoin`, which have no side without a match, and an `Option` element of a join, whose `default` is `None`.", + "claim": "the side of a join that always has an element (`LeftJoin`'s outer, `RightJoin`'s inner), `Join` and `GroupJoin`, which have no side without a match, and an `Option` element of a join, whose `default` is `None`.", + "oracle": "claim", + "surfaces": [ + "IValueObject.*", + "IValidatedValue.*", + "Option.Some" + ], + "heldBy": [ + "OuterJoinDefaultTests" + ] + }, { "id": "L01-read-as-the-underlying-type-reads", "source": "README.md#what-typekit-guarantees",