From 452bfb6b24793121ad4fbd480a2924b76eff2c09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 21:01:59 +0100 Subject: [PATCH 1/3] refactor: resolve the LINQ joins in one place for the analyzers LinqJoins resolves Join, GroupJoin, LeftJoin, RightJoin and FullJoin of Enumerable, Queryable and AsyncEnumerable by symbol, maps a call's arguments to the definition's parameters (receiver, position, name, conditional access), and, where a call does not bind, takes the candidate whose outer sequence the receiver is, the most derived one, as overload resolution would. CMTK0008's key check uses it in place of its own copy, with no change in what it reports; CMTK0009's outer joins (#261) build on it. Co-Authored-By: Claude Opus 5.5 --- src/CodoMetis.TypeKit.Analyzers/LinqJoins.cs | 147 ++++++++++++++++++ .../MixedValueComparisonAnalyzer.cs | 50 ++---- 2 files changed, 156 insertions(+), 41 deletions(-) create mode 100644 src/CodoMetis.TypeKit.Analyzers/LinqJoins.cs diff --git a/src/CodoMetis.TypeKit.Analyzers/LinqJoins.cs b/src/CodoMetis.TypeKit.Analyzers/LinqJoins.cs new file mode 100644 index 00000000..e37ef3fe --- /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 34a87a28..c05d5e1a 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; From 6df0333b298bf71cad11d1e27e8b3fdc6c0471c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 21:01:59 +0100 Subject: [PATCH 2/3] feat: CMTK0009 reports the side of an outer join with no match LeftJoin and RightJoin (.NET 10) and FullJoin (.NET 11) hand their result selector, or their tuple, the default of the side with no match. CMTK0009 now reports every overload of LeftJoin whose inner element, RightJoin whose outer element, and FullJoin where either element is a no-default struct, a warning per side, on Enumerable, Queryable and AsyncEnumerable, the .NET 11 tuple-returning overloads included. The message names an Option of each element in memory and a nullable projection in a query. Warning, no code fix. An Option element of a join is not reported: its default is None, which is the way out the message names, and reporting it would flag that rewrite in turn. A join that does not bind, because a lambda reads a generated member before weaving, is read from its candidates. Input class: the receiver and arguments of a LINQ join call, and what its element type is. Catalogues: the call forms (extension, static, named, conditional access, unbound, a list, an array, a query, a sequence-constrained type parameter, sync and async key selectors, with and without a comparer, tuple forms) on each host and side, and TypeArgumentKinds, 79 kinds, against NoDefaultKindTests' oracle. The FullJoin rows compile against Microsoft.NETCore.App.Ref 11.0.0-rc.3.26522.148. Siblings found and fixed here: the unbound call on a query took Enumerable's candidate and gave the in-memory advice, and conditional access was silent. CMTK0008 not listing FullJoin is #273. Closes #261 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + design/analyzers.md | 2 +- .../DefaultProducingCallAnalyzer.cs | 105 +++++- src/CodoMetis.TypeKit.Analyzers/README.md | 16 +- .../NoDefaultKindTests.cs | 2 +- .../OuterJoinDefaultTests.cs | 330 ++++++++++++++++++ test/oracles/claims.json | 29 ++ 7 files changed, 477 insertions(+), 8 deletions(-) create mode 100644 test/CodoMetis.TypeKit.Analyzers.Tests/OuterJoinDefaultTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c1c7f433..5fe89366 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ section is the GitHub release's notes. ### Changed - 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: 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 a1054091..cc892289 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/src/CodoMetis.TypeKit.Analyzers/DefaultProducingCallAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/DefaultProducingCallAnalyzer.cs index 23cde09d..c310da82 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/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index e6923964..f385b037 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. | @@ -428,13 +428,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 b4b35760..bd2ba855 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 00000000..fb39f991 --- /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 212af7c8..27f45a39 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -2876,6 +2876,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", From e37ff2cbe535a8b91173cd9aedd6bb887f5d9dda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 21:01:59 +0100 Subject: [PATCH 3/3] docs: KB-059, EF Core 11 translates a nullable projection under FULL JOIN The .NET 11 join surface CMTK0009 resolves, and EF Core 11.0.0-rc.3.26522.148 on SQLite: a nullable projection under FullJoin is a FULL JOIN reading null for the side with no match, and without it EF throws "Nullable object must have a value." Measured in a scratch net11.0 project outside the solution (11.0.100-rc.1 SDK and runtime, the only ones installed). Co-Authored-By: Claude Opus 5.5 --- ...s-a-nullable-projection-under-full-join.md | 29 +++++++++++++++++++ design/knowledge/README.md | 1 + 2 files changed, 30 insertions(+) create mode 100644 design/knowledge/KB-059-ef-core-11-translates-a-nullable-projection-under-full-join.md 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 00000000..ad2f641e --- /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 e1f22505..157bf48c 100644 --- a/design/knowledge/README.md +++ b/design/knowledge/README.md @@ -88,3 +88,4 @@ 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 |