diff --git a/Source/Csla.Analyzers/Csla.Analyzers/AnalyzerReleases.Unshipped.md b/Source/Csla.Analyzers/Csla.Analyzers/AnalyzerReleases.Unshipped.md
index c78e476580..644dc4662c 100644
--- a/Source/Csla.Analyzers/Csla.Analyzers/AnalyzerReleases.Unshipped.md
+++ b/Source/Csla.Analyzers/Csla.Analyzers/AnalyzerReleases.Unshipped.md
@@ -25,4 +25,6 @@ CSLA0019 | Usage | Error | FindRefAndOutParametersInOperationsAnalyzer
CSLA0020 | Usage | Warning | ObjectAuthorizationRulesAttributeMissing
CSLA0021 | Usage | Info | ObjectAuthorizationRulesPublic
CSLA0022 | Usage | Warning | ObjectAuthorizationRulesStatic
-CSLA0023 | Refactoring | Info | EvaluateManagedBackingFieldsNameofAnalyzer
\ No newline at end of file
+CSLA0023 | Refactoring | Info | EvaluateManagedBackingFieldsNameofAnalyzer
+CSLA0024 | Usage | Warning | TypeWithOperationsShouldBePartialAnalyzer
+CSLA0025 | Usage | Warning | UseGeneratedDataPortalExtensionAnalyzer
\ No newline at end of file
diff --git a/Source/Csla.Analyzers/Csla.Analyzers/Constants.cs b/Source/Csla.Analyzers/Csla.Analyzers/Constants.cs
index da07f4d178..8e142e79f4 100644
--- a/Source/Csla.Analyzers/Csla.Analyzers/Constants.cs
+++ b/Source/Csla.Analyzers/Csla.Analyzers/Constants.cs
@@ -98,6 +98,14 @@ public static class AnalyzerIdentifiers
///
///
public const string EvaluateManagedBackingFieldsNameof = "CSLA0023";
+ ///
+ ///
+ ///
+ public const string TypeWithOperationsShouldBePartial = "CSLA0024";
+ ///
+ ///
+ ///
+ public const string UseGeneratedDataPortalExtension = "CSLA0025";
}
diff --git a/Source/Csla.Analyzers/Csla.Analyzers/Properties/Resources.Designer.cs b/Source/Csla.Analyzers/Csla.Analyzers/Properties/Resources.Designer.cs
index 5556a12d37..0c8321331c 100644
--- a/Source/Csla.Analyzers/Csla.Analyzers/Properties/Resources.Designer.cs
+++ b/Source/Csla.Analyzers/Csla.Analyzers/Properties/Resources.Designer.cs
@@ -549,5 +549,59 @@ internal static string Shared_AddAttributeDescription {
return ResourceManager.GetString("Shared_AddAttributeDescription", resourceCulture);
}
}
+
+ ///
+ /// Looks up a localized string similar to Make type partial.
+ ///
+ internal static string TypeWithOperationsShouldBePartial_AddPartialDescription {
+ get {
+ return ResourceManager.GetString("TypeWithOperationsShouldBePartial_AddPartialDescription", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Type '{0}' has data portal operation methods and should be declared partial so the CSLA source generator can implement its operations interface.
+ ///
+ internal static string TypeWithOperationsShouldBePartial_Message {
+ get {
+ return ResourceManager.GetString("TypeWithOperationsShouldBePartial_Message", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Type with data portal operation methods should be partial.
+ ///
+ internal static string TypeWithOperationsShouldBePartial_Title {
+ get {
+ return ResourceManager.GetString("TypeWithOperationsShouldBePartial_Title", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Use the generated data portal extension method for '{0}' instead of calling '{1}' with untyped criteria.
+ ///
+ internal static string UseGeneratedDataPortalExtension_Message {
+ get {
+ return ResourceManager.GetString("UseGeneratedDataPortalExtension_Message", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Use generated data portal extension method.
+ ///
+ internal static string UseGeneratedDataPortalExtension_Title {
+ get {
+ return ResourceManager.GetString("UseGeneratedDataPortalExtension_Title", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Use '{0}'.
+ ///
+ internal static string UseGeneratedDataPortalExtension_UseExtensionDescription {
+ get {
+ return ResourceManager.GetString("UseGeneratedDataPortalExtension_UseExtensionDescription", resourceCulture);
+ }
+ }
}
}
diff --git a/Source/Csla.Analyzers/Csla.Analyzers/Properties/Resources.resx b/Source/Csla.Analyzers/Csla.Analyzers/Properties/Resources.resx
index 031868b675..e380eae390 100644
--- a/Source/Csla.Analyzers/Csla.Analyzers/Properties/Resources.resx
+++ b/Source/Csla.Analyzers/Csla.Analyzers/Properties/Resources.resx
@@ -279,4 +279,22 @@
Authorization rules should be declared as static methods
+
+ Type with data portal operation methods should be partial
+
+
+ Type '{0}' has data portal operation methods and should be declared partial so the CSLA source generator can implement its operations interface
+
+
+ Make type partial
+
+
+ Use generated data portal extension method
+
+
+ Use the generated data portal extension method for '{0}' instead of calling '{1}' with untyped criteria
+
+
+ Use '{0}'
+
\ No newline at end of file
diff --git a/Source/Csla.Analyzers/Csla.Analyzers/TypeWithOperationsShouldBePartialAddPartialCodeFix.cs b/Source/Csla.Analyzers/Csla.Analyzers/TypeWithOperationsShouldBePartialAddPartialCodeFix.cs
new file mode 100644
index 0000000000..e832a143f3
--- /dev/null
+++ b/Source/Csla.Analyzers/Csla.Analyzers/TypeWithOperationsShouldBePartialAddPartialCodeFix.cs
@@ -0,0 +1,86 @@
+using System.Collections.Immutable;
+using System.Composition;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Csla.Analyzers
+{
+ ///
+ /// Adds the partial modifier to a type with data portal
+ /// operation methods and to all of its containing types.
+ ///
+ [ExportCodeFixProvider(LanguageNames.CSharp)]
+ [Shared]
+ public sealed class TypeWithOperationsShouldBePartialAddPartialCodeFix
+ : CodeFixProvider
+ {
+ ///
+ ///
+ ///
+ public override ImmutableArray FixableDiagnosticIds => [Constants.AnalyzerIdentifiers.TypeWithOperationsShouldBePartial];
+
+ ///
+ ///
+ ///
+ public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
+
+ ///
+ ///
+ ///
+ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
+ {
+ var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
+ if (root is null)
+ {
+ return;
+ }
+
+ context.CancellationToken.ThrowIfCancellationRequested();
+
+ var diagnostic = context.Diagnostics.First();
+ var classNode = root.FindNode(diagnostic.Location.SourceSpan).FirstAncestorOrSelf();
+ if (classNode is null)
+ {
+ return;
+ }
+
+ var nonPartialNodes = TypeWithOperationsShouldBePartialAnalyzer.GetNonPartialTypeDeclarations(classNode);
+ if (nonPartialNodes.Count == 0)
+ {
+ return;
+ }
+
+ context.CancellationToken.ThrowIfCancellationRequested();
+
+ var newRoot = root.ReplaceNodes(nonPartialNodes, (_, rewritten) => AddPartialModifier(rewritten));
+ var description = TypeWithOperationsShouldBePartialAddPartialCodeFixConstants.AddPartialDescription;
+
+ context.RegisterCodeFix(
+ CodeAction.Create(description,
+ _ => Task.FromResult(context.Document.WithSyntaxRoot(newRoot)), description), diagnostic);
+ }
+
+ private static TypeDeclarationSyntax AddPartialModifier(TypeDeclarationSyntax node)
+ {
+ if (node.Modifiers.Count == 0)
+ {
+ // No modifiers: the partial keyword takes over the leading trivia
+ // (indentation, comments) of the type keyword.
+ var keyword = node.Keyword;
+ var partialToken = SyntaxFactory.Token(
+ keyword.LeadingTrivia, SyntaxKind.PartialKeyword, SyntaxFactory.TriviaList(SyntaxFactory.Space));
+ return node
+ .WithKeyword(keyword.WithLeadingTrivia(SyntaxFactory.TriviaList()))
+ .WithModifiers(SyntaxFactory.TokenList(partialToken));
+ }
+
+ // partial must be the last modifier, immediately before the type keyword.
+ var partialModifier = SyntaxFactory.Token(SyntaxKind.PartialKeyword)
+ .WithTrailingTrivia(SyntaxFactory.Space);
+ return node.WithModifiers(node.Modifiers.Add(partialModifier));
+ }
+ }
+}
diff --git a/Source/Csla.Analyzers/Csla.Analyzers/TypeWithOperationsShouldBePartialAnalyzer.cs b/Source/Csla.Analyzers/Csla.Analyzers/TypeWithOperationsShouldBePartialAnalyzer.cs
new file mode 100644
index 0000000000..71cdc854df
--- /dev/null
+++ b/Source/Csla.Analyzers/Csla.Analyzers/TypeWithOperationsShouldBePartialAnalyzer.cs
@@ -0,0 +1,143 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Diagnostics;
+using System.Collections.Immutable;
+
+namespace Csla.Analyzers
+{
+ ///
+ /// Reports classes that declare data portal operation methods
+ /// (methods with [Create], [Fetch], etc.) when the
+ /// class or any of its containing types is not partial.
+ /// The CSLA source generator needs to add a partial declaration
+ /// to such types to implement their operations interface.
+ ///
+ [DiagnosticAnalyzer(LanguageNames.CSharp)]
+ public sealed class TypeWithOperationsShouldBePartialAnalyzer
+ : DiagnosticAnalyzer
+ {
+ private static readonly string[] OperationAttributeMetadataNames =
+ [
+ "Csla.CreateAttribute",
+ "Csla.FetchAttribute",
+ "Csla.InsertAttribute",
+ "Csla.UpdateAttribute",
+ "Csla.ExecuteAttribute",
+ "Csla.DeleteAttribute",
+ "Csla.DeleteSelfAttribute",
+ "Csla.CreateChildAttribute",
+ "Csla.FetchChildAttribute",
+ "Csla.InsertChildAttribute",
+ "Csla.UpdateChildAttribute",
+ "Csla.DeleteSelfChildAttribute",
+ "Csla.ExecuteChildAttribute"
+ ];
+
+ private static readonly DiagnosticDescriptor shouldBePartialRule =
+ new(
+ Constants.AnalyzerIdentifiers.TypeWithOperationsShouldBePartial, TypeWithOperationsShouldBePartialAnalyzerConstants.Title,
+ TypeWithOperationsShouldBePartialAnalyzerConstants.Message, Constants.Categories.Usage,
+ DiagnosticSeverity.Warning, true,
+ helpLinkUri: HelpUrlBuilder.Build(
+ Constants.AnalyzerIdentifiers.TypeWithOperationsShouldBePartial, nameof(TypeWithOperationsShouldBePartialAnalyzer)));
+
+ ///
+ ///
+ ///
+ public override ImmutableArray SupportedDiagnostics => [shouldBePartialRule];
+
+ ///
+ ///
+ ///
+ public override void Initialize(AnalysisContext context)
+ {
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.EnableConcurrentExecution();
+ context.RegisterCompilationStartAction(compilationContext =>
+ {
+ var operationAttributes = new HashSet(SymbolEqualityComparer.Default);
+ foreach (var metadataName in OperationAttributeMetadataNames)
+ {
+ var attributeType = compilationContext.Compilation.GetTypeByMetadataName(metadataName);
+ if (attributeType is not null)
+ {
+ operationAttributes.Add(attributeType);
+ }
+ }
+
+ if (operationAttributes.Count == 0)
+ {
+ return;
+ }
+
+ compilationContext.RegisterSymbolAction(
+ symbolContext => AnalyzeNamedType(symbolContext, operationAttributes), SymbolKind.NamedType);
+ });
+ }
+
+ private static void AnalyzeNamedType(SymbolAnalysisContext context, HashSet operationAttributes)
+ {
+ var typeSymbol = (INamedTypeSymbol)context.Symbol;
+ if (typeSymbol.TypeKind != TypeKind.Class || typeSymbol.IsRecord)
+ {
+ return;
+ }
+
+ if (!HasOperationMethods(typeSymbol, operationAttributes))
+ {
+ return;
+ }
+
+ foreach (var reference in typeSymbol.DeclaringSyntaxReferences)
+ {
+ context.CancellationToken.ThrowIfCancellationRequested();
+
+ if (reference.GetSyntax(context.CancellationToken) is ClassDeclarationSyntax classNode &&
+ GetNonPartialTypeDeclarations(classNode).Count > 0)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ shouldBePartialRule, classNode.Identifier.GetLocation(), typeSymbol.Name));
+ return;
+ }
+ }
+ }
+
+ private static bool HasOperationMethods(INamedTypeSymbol typeSymbol, HashSet operationAttributes)
+ {
+ foreach (var member in typeSymbol.GetMembers())
+ {
+ if (member is IMethodSymbol method)
+ {
+ foreach (var attribute in method.GetAttributes())
+ {
+ if (attribute.AttributeClass is not null && operationAttributes.Contains(attribute.AttributeClass))
+ {
+ return true;
+ }
+ }
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Gets the given type declaration and all of its containing
+ /// type declarations that are not declared partial.
+ ///
+ internal static List GetNonPartialTypeDeclarations(TypeDeclarationSyntax typeNode)
+ {
+ var result = new List();
+ foreach (var node in typeNode.AncestorsAndSelf().OfType())
+ {
+ if (!node.Modifiers.Any(SyntaxKind.PartialKeyword))
+ {
+ result.Add(node);
+ }
+ }
+
+ return result;
+ }
+ }
+}
diff --git a/Source/Csla.Analyzers/Csla.Analyzers/TypeWithOperationsShouldBePartialAnalyzerConstants.cs b/Source/Csla.Analyzers/Csla.Analyzers/TypeWithOperationsShouldBePartialAnalyzerConstants.cs
new file mode 100644
index 0000000000..c0bc4d1e2b
--- /dev/null
+++ b/Source/Csla.Analyzers/Csla.Analyzers/TypeWithOperationsShouldBePartialAnalyzerConstants.cs
@@ -0,0 +1,31 @@
+using Csla.Analyzers.Properties;
+using Microsoft.CodeAnalysis;
+
+namespace Csla.Analyzers
+{
+ ///
+ ///
+ ///
+ public static class TypeWithOperationsShouldBePartialAnalyzerConstants
+ {
+ ///
+ ///
+ ///
+ public static readonly LocalizableResourceString Title = new(nameof(Resources.TypeWithOperationsShouldBePartial_Title), Resources.ResourceManager, typeof(Resources));
+ ///
+ ///
+ ///
+ public static readonly LocalizableResourceString Message = new(nameof(Resources.TypeWithOperationsShouldBePartial_Message), Resources.ResourceManager, typeof(Resources));
+ }
+
+ ///
+ ///
+ ///
+ public static class TypeWithOperationsShouldBePartialAddPartialCodeFixConstants
+ {
+ ///
+ ///
+ ///
+ public static string AddPartialDescription => Resources.TypeWithOperationsShouldBePartial_AddPartialDescription;
+ }
+}
diff --git a/Source/Csla.Analyzers/Csla.Analyzers/UseGeneratedDataPortalExtensionAnalyzer.cs b/Source/Csla.Analyzers/Csla.Analyzers/UseGeneratedDataPortalExtensionAnalyzer.cs
new file mode 100644
index 0000000000..b0d078e93f
--- /dev/null
+++ b/Source/Csla.Analyzers/Csla.Analyzers/UseGeneratedDataPortalExtensionAnalyzer.cs
@@ -0,0 +1,524 @@
+// Portions of this analyzer are adapted from the NotDataPortalExtensionMethodUsedAnalyzer
+// in Csla.DataPortalExtensions (https://github.com/StefanOssendorf/Csla.DataPortalExtensions),
+// Copyright (c) Stefan Ossendorf, licensed under the MIT License.
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Operations;
+using System.Collections.Immutable;
+
+namespace Csla.Analyzers
+{
+ ///
+ /// Reports calls to the untyped, criteria-based methods of
+ /// IDataPortal<T>, IChildDataPortal<T>, and
+ /// DataPortal<T> when T or its assembly is marked with
+ /// [DataPortalExtensions], meaning strongly typed extension
+ /// methods are generated and should be used instead. The diagnostic is
+ /// only reported when the generator produces an extension method that
+ /// can replace the call.
+ ///
+ [DiagnosticAnalyzer(LanguageNames.CSharp)]
+ public sealed class UseGeneratedDataPortalExtensionAnalyzer
+ : DiagnosticAnalyzer
+ {
+ ///
+ /// Diagnostic property holding the names of the generated extension
+ /// methods that can replace the call, separated by semicolons.
+ ///
+ public const string ExtensionNamesProperty = "ExtensionNames";
+
+ private static readonly DiagnosticDescriptor useExtensionRule =
+ new(
+ Constants.AnalyzerIdentifiers.UseGeneratedDataPortalExtension, UseGeneratedDataPortalExtensionAnalyzerConstants.Title,
+ UseGeneratedDataPortalExtensionAnalyzerConstants.Message, Constants.Categories.Usage,
+ DiagnosticSeverity.Warning, true,
+ helpLinkUri: HelpUrlBuilder.Build(
+ Constants.AnalyzerIdentifiers.UseGeneratedDataPortalExtension, nameof(UseGeneratedDataPortalExtensionAnalyzer)));
+
+ ///
+ ///
+ ///
+ public override ImmutableArray SupportedDiagnostics => [useExtensionRule];
+
+ ///
+ ///
+ ///
+ public override void Initialize(AnalysisContext context)
+ {
+ // Generated code must be skipped: the generated extension methods
+ // themselves call the untyped data portal methods.
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.EnableConcurrentExecution();
+ context.RegisterCompilationStartAction(compilationContext =>
+ {
+ var compilation = compilationContext.Compilation;
+ var extensionsAttribute = compilation.GetTypeByMetadataName("Csla.DataPortalExtensionsAttribute");
+ if (extensionsAttribute is null)
+ {
+ return;
+ }
+
+ var globalOptions = compilationContext.Options.AnalyzerConfigOptionsProvider.GlobalOptions;
+ var symbols = new KnownSymbols(
+ GetAsyncSuffix(globalOptions),
+ GenerateSync(globalOptions),
+ extensionsAttribute,
+ compilation.GetTypeByMetadataName("Csla.IDataPortal`1"),
+ compilation.GetTypeByMetadataName("Csla.IChildDataPortal`1"),
+ compilation.GetTypeByMetadataName("Csla.DataPortal`1"),
+ compilation.GetTypeByMetadataName("Csla.NoDataPortalExtensionAttribute"),
+ compilation.GetTypeByMetadataName("Csla.InjectAttribute"),
+ compilation.GetTypeByMetadataName("Csla.Core.ICslaObject"));
+
+ compilationContext.RegisterOperationAction(
+ operationContext => AnalyzeInvocation(operationContext, symbols), OperationKind.Invocation);
+ });
+ }
+
+ private static void AnalyzeInvocation(OperationAnalysisContext context, KnownSymbols symbols)
+ {
+ var invocation = (IInvocationOperation)context.Operation;
+ var method = invocation.TargetMethod;
+ var containingType = method.ContainingType;
+ if (containingType is null || containingType.TypeArguments.Length != 1)
+ {
+ return;
+ }
+
+ var definition = containingType.OriginalDefinition;
+ var isDataPortalClass = SymbolEqualityComparer.Default.Equals(definition, symbols.DataPortal);
+ var isRootPortal = isDataPortalClass || SymbolEqualityComparer.Default.Equals(definition, symbols.IDataPortal);
+ var isChildPortal = isDataPortalClass || SymbolEqualityComparer.Default.Equals(definition, symbols.IChildDataPortal);
+ if (!isRootPortal && !isChildPortal)
+ {
+ return;
+ }
+
+ if (!IsCriteriaMethod(method, isRootPortal, isChildPortal))
+ {
+ return;
+ }
+
+ context.CancellationToken.ThrowIfCancellationRequested();
+
+ if (containingType.TypeArguments[0] is not INamedTypeSymbol businessType ||
+ GetPrefix(businessType, symbols) is not { } prefix)
+ {
+ return;
+ }
+
+ var extensionNames = GetGeneratedExtensionNames(businessType, prefix, method.Name, GetCriteriaArguments(invocation), context.Compilation, symbols);
+ if (extensionNames.Count == 0)
+ {
+ return;
+ }
+
+ var properties = ImmutableDictionary.Empty.Add(ExtensionNamesProperty, string.Join(";", extensionNames));
+ context.ReportDiagnostic(Diagnostic.Create(
+ useExtensionRule, GetLocation(invocation), properties, businessType.Name, method.Name));
+ }
+
+ private static bool IsCriteriaMethod(IMethodSymbol method, bool isRootPortal, bool isChildPortal)
+ {
+ switch (method.Name)
+ {
+ case "Create":
+ case "CreateAsync":
+ case "Fetch":
+ case "FetchAsync":
+ case "Delete":
+ case "DeleteAsync":
+ return isRootPortal;
+ case "Execute":
+ case "ExecuteAsync":
+ // Only the params object[] criteria overload, not Execute(T command)
+ return isRootPortal && method.Parameters.Length == 1 && method.Parameters[0].IsParams;
+ case "CreateChild":
+ case "CreateChildAsync":
+ case "FetchChild":
+ case "FetchChildAsync":
+ return isChildPortal;
+ default:
+ return false;
+ }
+ }
+
+ ///
+ /// Mirrors the data portal extensions generator: returns the prefix of the
+ /// extension methods requested for the business type by its own
+ /// [DataPortalExtensions] attribute or by the attribute on its assembly,
+ /// or null when no extension methods are requested.
+ ///
+ private static string? GetPrefix(INamedTypeSymbol businessType, KnownSymbols symbols)
+ {
+ if (businessType.IsRecord ||
+ FindAttribute(businessType.GetAttributes(), symbols.NoDataPortalExtensionAttribute) is not null)
+ {
+ return null;
+ }
+
+ var typeAttribute = FindAttribute(businessType.GetAttributes(), symbols.DataPortalExtensionsAttribute);
+ var assemblyAttribute = FindAttribute(businessType.ContainingAssembly.GetAttributes(), symbols.DataPortalExtensionsAttribute);
+ if (typeAttribute is null && assemblyAttribute is null)
+ {
+ return null;
+ }
+
+ return GetPrefix(typeAttribute) ?? GetPrefix(assemblyAttribute) ?? string.Empty;
+ }
+
+ private static AttributeData? FindAttribute(ImmutableArray attributes, INamedTypeSymbol? attributeType)
+ {
+ foreach (var attribute in attributes)
+ {
+ if (attributeType is not null && SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeType))
+ {
+ return attribute;
+ }
+ }
+
+ return null;
+ }
+
+ private static string? GetPrefix(AttributeData? attribute)
+ {
+ if (attribute is null)
+ {
+ return null;
+ }
+
+ foreach (var namedArgument in attribute.NamedArguments)
+ {
+ if (namedArgument.Key == "Prefix")
+ {
+ return namedArgument.Value.Value as string ?? string.Empty;
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Mirrors the data portal extensions generator: returns the names of the
+ /// extension methods generated for operation methods of the kind called
+ /// that accept the criteria values passed.
+ ///
+ private static List GetGeneratedExtensionNames(INamedTypeSymbol businessType, string prefix, string portalMethodName,
+ ImmutableArray? criteriaArguments, Compilation compilation, KnownSymbols symbols)
+ {
+ var names = new List();
+ if (businessType.IsAbstract || symbols.ICslaObject is null ||
+ !businessType.AllInterfaces.Any(i => SymbolEqualityComparer.Default.Equals(i, symbols.ICslaObject)))
+ {
+ return names;
+ }
+
+ for (var type = businessType; type is not null; type = type.ContainingType)
+ {
+ if (type.TypeParameters.Length > 0)
+ {
+ return names;
+ }
+ }
+
+ if (IsPrivate(businessType))
+ {
+ return names;
+ }
+
+ if (prefix.Length > 0 && !SyntaxFacts.IsValidIdentifier(prefix))
+ {
+ return names;
+ }
+
+ var isAsync = portalMethodName.EndsWith("Async", StringComparison.Ordinal);
+ if (!isAsync && !symbols.GenerateSync)
+ {
+ return names;
+ }
+
+ var kind = isAsync ? portalMethodName.Substring(0, portalMethodName.Length - "Async".Length) : portalMethodName;
+ var receiver = kind is "CreateChild" or "FetchChild" ? symbols.IChildDataPortal : symbols.IDataPortal;
+ var hiddenNames = receiver is null
+ ? ImmutableHashSet.Empty
+ : receiver.GetMembers().Select(m => m.Name).ToImmutableHashSet();
+ var operationAttributeName = $"Csla.{kind}Attribute";
+
+ // Operation methods of this kind that generated code can dispatch to;
+ // ref/out parameters and a single object[] parameter are only invoked through reflection.
+ var operations = businessType.GetMembers()
+ .OfType()
+ .Where(m => m.MethodKind == MethodKind.Ordinary && !m.IsStatic && !m.IsGenericMethod)
+ .Where(m => m.GetAttributes().Any(a => a.AttributeClass?.ToDisplayString() == operationAttributeName))
+ .Where(m => !m.Parameters.Any(p => p.RefKind is RefKind.Ref or RefKind.Out))
+ .Where(m => !(m.Parameters.Length == 1 && m.Parameters[0].Type is IArrayTypeSymbol { Rank: 1, ElementType.SpecialType: SpecialType.System_Object }))
+ .Select(m => (Method: m, Criteria: m.Parameters.Where(p => !IsInjected(p, symbols.InjectAttribute)).ToList()))
+ .ToList();
+
+ foreach (var (operation, criteria) in operations)
+ {
+ if (operation.GetAttributes().Any(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, symbols.NoDataPortalExtensionAttribute)))
+ {
+ continue;
+ }
+
+ // Methods with the same criteria types differ only in injected parameters;
+ // the generator only produces an extension for the one the runtime prefers.
+ if (!IsPreferredAmongSameCriteria(operation, criteria, operations))
+ {
+ continue;
+ }
+
+ if (criteria.Any(p => IsPrivate(p.Type)))
+ {
+ continue;
+ }
+
+ var baseName = operation.Name;
+ if (baseName.EndsWith("Async", StringComparison.Ordinal) && baseName.Length > "Async".Length)
+ {
+ baseName = baseName.Substring(0, baseName.Length - "Async".Length);
+ }
+
+ var name = prefix + baseName + (isAsync ? symbols.AsyncSuffix : string.Empty);
+ if (hiddenNames.Contains(name))
+ {
+ continue;
+ }
+
+ if (criteriaArguments is { } arguments && !AcceptsArguments(criteria, arguments, compilation))
+ {
+ continue;
+ }
+
+ if (!names.Contains(name))
+ {
+ names.Add(name);
+ }
+ }
+
+ return names;
+ }
+
+ private static bool IsPreferredAmongSameCriteria(IMethodSymbol operation, List criteria,
+ List<(IMethodSymbol Method, List Criteria)> operations)
+ {
+ var injectCount = operation.Parameters.Length - criteria.Count;
+ foreach (var (other, otherCriteria) in operations)
+ {
+ if (SymbolEqualityComparer.Default.Equals(other, operation) ||
+ !otherCriteria.Select(p => p.Type).SequenceEqual(criteria.Select(p => p.Type), SymbolEqualityComparer.Default))
+ {
+ continue;
+ }
+
+ var otherInjectCount = other.Parameters.Length - otherCriteria.Count;
+ if (otherInjectCount > injectCount)
+ {
+ return false;
+ }
+
+ // ties go to the first declared method
+ if (otherInjectCount == injectCount &&
+ operations.FindIndex(o => SymbolEqualityComparer.Default.Equals(o.Method, other)) <
+ operations.FindIndex(o => SymbolEqualityComparer.Default.Equals(o.Method, operation)))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// The criteria values can be passed to the criteria parameters of the
+ /// generated extension method.
+ ///
+ private static bool AcceptsArguments(List criteria, ImmutableArray arguments, Compilation compilation)
+ {
+ var required = criteria.Count(p => !p.HasExplicitDefaultValue && !p.IsParams);
+ var hasParams = criteria.Count > 0 && criteria[criteria.Count - 1].IsParams;
+ if (arguments.Length < required || (!hasParams && arguments.Length > criteria.Count))
+ {
+ return false;
+ }
+
+ for (var i = 0; i < arguments.Length; i++)
+ {
+ var parameter = criteria[Math.Min(i, criteria.Count - 1)];
+ var argument = arguments[i] is IConversionOperation { IsImplicit: true } conversion ? conversion.Operand : arguments[i];
+ if (parameter.IsParams && parameter.Type is IArrayTypeSymbol paramsArray)
+ {
+ var isLastExpandable = i >= criteria.Count - 1;
+ if (isLastExpandable && (IsConvertible(argument, paramsArray.ElementType, compilation) ||
+ (arguments.Length == criteria.Count && IsConvertible(argument, paramsArray, compilation))))
+ {
+ continue;
+ }
+
+ return false;
+ }
+
+ if (!IsConvertible(argument, parameter.Type, compilation))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool IsConvertible(IOperation argument, ITypeSymbol target, Compilation compilation)
+ {
+ if (argument.Type is null)
+ {
+ // null literal or a typeless expression
+ return argument.ConstantValue is not { HasValue: true, Value: null } ||
+ target.IsReferenceType || target.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T;
+ }
+
+ return compilation.ClassifyCommonConversion(argument.Type, target).IsImplicit;
+ }
+
+ ///
+ /// The criteria values passed to the data portal method, or null when
+ /// they are passed as an existing array.
+ ///
+ private static ImmutableArray? GetCriteriaArguments(IInvocationOperation invocation)
+ {
+ if (invocation.Arguments.Length == 0)
+ {
+ return ImmutableArray.Empty;
+ }
+
+ var argument = invocation.Arguments[invocation.Arguments.Length - 1];
+ if (argument.ArgumentKind == ArgumentKind.ParamArray && argument.Value is IArrayCreationOperation { Initializer: { } initializer })
+ {
+ return initializer.ElementValues;
+ }
+
+ return null;
+ }
+
+ private static bool IsInjected(IParameterSymbol parameter, INamedTypeSymbol? injectAttribute)
+ {
+ if (injectAttribute is null)
+ {
+ return false;
+ }
+
+ foreach (var attribute in parameter.GetAttributes())
+ {
+ for (var type = attribute.AttributeClass; type is not null; type = type.BaseType)
+ {
+ if (SymbolEqualityComparer.Default.Equals(type, injectAttribute))
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// The type, or a type it is composed of, is not accessible outside its containing type.
+ ///
+ private static bool IsPrivate(ITypeSymbol type)
+ {
+ switch (type)
+ {
+ case IArrayTypeSymbol array:
+ return IsPrivate(array.ElementType);
+ case INamedTypeSymbol named:
+ for (var current = named; current is not null; current = current.ContainingType)
+ {
+ if (current.DeclaredAccessibility is not (Accessibility.Public or Accessibility.Internal or Accessibility.ProtectedOrInternal))
+ {
+ return true;
+ }
+ }
+
+ return named.TypeArguments.Any(IsPrivate);
+ default:
+ return false;
+ }
+ }
+
+ private static Location GetLocation(IInvocationOperation invocation)
+ {
+ if (invocation.Syntax is InvocationExpressionSyntax invocationSyntax)
+ {
+ switch (invocationSyntax.Expression)
+ {
+ case MemberAccessExpressionSyntax memberAccess:
+ return memberAccess.Name.GetLocation();
+ case MemberBindingExpressionSyntax memberBinding:
+ return memberBinding.Name.GetLocation();
+ }
+ }
+
+ return invocation.Syntax.GetLocation();
+ }
+
+ ///
+ /// Mirrors the generator: synchronous extension methods are generated unless
+ /// the CslaGenerateSyncDataPortalExtensions MSBuild property is false, or
+ /// there is no async suffix so they would have the same names as the async methods.
+ ///
+ private static bool GenerateSync(AnalyzerConfigOptions options)
+ {
+ return GetAsyncSuffix(options).Length > 0 &&
+ !(options.TryGetValue("build_property.CslaGenerateSyncDataPortalExtensions", out var value) &&
+ string.Equals(value?.Trim(), "false", StringComparison.OrdinalIgnoreCase));
+ }
+
+ ///
+ /// Mirrors the generator: async extension method names end with the
+ /// CslaDataPortalExtensionsAsyncSuffix MSBuild property, which defaults
+ /// to Async, is none for no suffix, and is ignored when it is not valid in an identifier.
+ ///
+ private static string GetAsyncSuffix(AnalyzerConfigOptions options)
+ {
+ if (options.TryGetValue("build_property.CslaDataPortalExtensionsAsyncSuffix", out var value) && value?.Trim() is { Length: > 0 } suffix)
+ {
+ if (string.Equals(suffix, "none", StringComparison.OrdinalIgnoreCase))
+ {
+ return string.Empty;
+ }
+
+ if (SyntaxFacts.IsValidIdentifier("_" + suffix))
+ {
+ return suffix;
+ }
+ }
+
+ return "Async";
+ }
+
+ private sealed class KnownSymbols(
+ string asyncSuffix,
+ bool generateSync,
+ INamedTypeSymbol dataPortalExtensionsAttribute,
+ INamedTypeSymbol? iDataPortal,
+ INamedTypeSymbol? iChildDataPortal,
+ INamedTypeSymbol? dataPortal,
+ INamedTypeSymbol? noDataPortalExtensionAttribute,
+ INamedTypeSymbol? injectAttribute,
+ INamedTypeSymbol? iCslaObject)
+ {
+ public string AsyncSuffix { get; } = asyncSuffix;
+ public bool GenerateSync { get; } = generateSync;
+ public INamedTypeSymbol DataPortalExtensionsAttribute { get; } = dataPortalExtensionsAttribute;
+ public INamedTypeSymbol? IDataPortal { get; } = iDataPortal;
+ public INamedTypeSymbol? IChildDataPortal { get; } = iChildDataPortal;
+ public INamedTypeSymbol? DataPortal { get; } = dataPortal;
+ public INamedTypeSymbol? NoDataPortalExtensionAttribute { get; } = noDataPortalExtensionAttribute;
+ public INamedTypeSymbol? InjectAttribute { get; } = injectAttribute;
+ public INamedTypeSymbol? ICslaObject { get; } = iCslaObject;
+ }
+ }
+}
diff --git a/Source/Csla.Analyzers/Csla.Analyzers/UseGeneratedDataPortalExtensionAnalyzerConstants.cs b/Source/Csla.Analyzers/Csla.Analyzers/UseGeneratedDataPortalExtensionAnalyzerConstants.cs
new file mode 100644
index 0000000000..e55f509f56
--- /dev/null
+++ b/Source/Csla.Analyzers/Csla.Analyzers/UseGeneratedDataPortalExtensionAnalyzerConstants.cs
@@ -0,0 +1,31 @@
+using Csla.Analyzers.Properties;
+using Microsoft.CodeAnalysis;
+
+namespace Csla.Analyzers
+{
+ ///
+ ///
+ ///
+ public static class UseGeneratedDataPortalExtensionAnalyzerConstants
+ {
+ ///
+ ///
+ ///
+ public static readonly LocalizableResourceString Title = new(nameof(Resources.UseGeneratedDataPortalExtension_Title), Resources.ResourceManager, typeof(Resources));
+ ///
+ ///
+ ///
+ public static readonly LocalizableResourceString Message = new(nameof(Resources.UseGeneratedDataPortalExtension_Message), Resources.ResourceManager, typeof(Resources));
+ }
+
+ ///
+ ///
+ ///
+ public static class UseGeneratedDataPortalExtensionCodeFixConstants
+ {
+ ///
+ ///
+ ///
+ public static string UseExtensionDescription => Resources.UseGeneratedDataPortalExtension_UseExtensionDescription;
+ }
+}
diff --git a/Source/Csla.Analyzers/Csla.Analyzers/UseGeneratedDataPortalExtensionCodeFix.cs b/Source/Csla.Analyzers/Csla.Analyzers/UseGeneratedDataPortalExtensionCodeFix.cs
new file mode 100644
index 0000000000..dda94146f3
--- /dev/null
+++ b/Source/Csla.Analyzers/Csla.Analyzers/UseGeneratedDataPortalExtensionCodeFix.cs
@@ -0,0 +1,99 @@
+using System.Collections.Immutable;
+using System.Composition;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Csla.Analyzers
+{
+ ///
+ /// Replaces a call to an untyped, criteria-based data portal method with
+ /// the generated extension method, adding a using directive for the
+ /// extension method's namespace when it is not in scope.
+ ///
+ [ExportCodeFixProvider(LanguageNames.CSharp)]
+ [Shared]
+ public sealed class UseGeneratedDataPortalExtensionCodeFix
+ : CodeFixProvider
+ {
+ ///
+ ///
+ ///
+ public override ImmutableArray FixableDiagnosticIds => [Constants.AnalyzerIdentifiers.UseGeneratedDataPortalExtension];
+
+ ///
+ ///
+ ///
+ public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
+
+ ///
+ ///
+ ///
+ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
+ {
+ var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
+ if (root is null)
+ {
+ return;
+ }
+
+ var diagnostic = context.Diagnostics.First();
+ if (!diagnostic.Properties.TryGetValue(UseGeneratedDataPortalExtensionAnalyzer.ExtensionNamesProperty, out var extensionNames) ||
+ string.IsNullOrEmpty(extensionNames))
+ {
+ return;
+ }
+
+ if (root.FindToken(diagnostic.Location.SourceSpan.Start).Parent is not SimpleNameSyntax name ||
+ name.FirstAncestorOrSelf() is not { } invocation)
+ {
+ return;
+ }
+
+ var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false);
+ if (semanticModel?.GetSymbolInfo(invocation, context.CancellationToken).Symbol is not IMethodSymbol { ContainingType.TypeArguments: [INamedTypeSymbol businessType] })
+ {
+ return;
+ }
+
+ foreach (var extensionName in extensionNames!.Split([';'], StringSplitOptions.RemoveEmptyEntries))
+ {
+ var title = string.Format(UseGeneratedDataPortalExtensionCodeFixConstants.UseExtensionDescription, extensionName);
+ context.RegisterCodeFix(
+ CodeAction.Create(title,
+ _ => Task.FromResult(context.Document.WithSyntaxRoot(
+ ReplaceCall(root, semanticModel, invocation, name, extensionName, businessType))),
+ title),
+ diagnostic);
+ }
+ }
+
+ private static SyntaxNode ReplaceCall(SyntaxNode root, SemanticModel semanticModel, InvocationExpressionSyntax invocation,
+ SimpleNameSyntax name, string extensionName, INamedTypeSymbol businessType)
+ {
+ // Generic type arguments of the untyped method do not apply to the extension method.
+ var newName = SyntaxFactory.IdentifierName(extensionName).WithTriviaFrom(name);
+ var newInvocation = invocation.ReplaceNode(name, newName);
+ var newRoot = root.ReplaceNode(invocation, newInvocation);
+
+ // The extension class is declared in the business type's namespace.
+ var @namespace = businessType.ContainingNamespace;
+ if (@namespace is null || @namespace.IsGlobalNamespace ||
+ semanticModel.GetSpeculativeSymbolInfo(invocation.SpanStart, newInvocation, SpeculativeBindingOption.BindAsExpression).Symbol is not null ||
+ newRoot is not CompilationUnitSyntax compilationUnit)
+ {
+ return newRoot;
+ }
+
+ var namespaceName = @namespace.ToDisplayString();
+ if (compilationUnit.Usings.Any(u => u.Name?.ToString() == namespaceName))
+ {
+ return newRoot;
+ }
+
+ return compilationUnit.AddUsings(SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(namespaceName)));
+ }
+ }
+}
diff --git a/Source/Csla.Benchmarks/Csla.Benchmarks.csproj b/Source/Csla.Benchmarks/Csla.Benchmarks.csproj
index dba023d63f..b07477a812 100644
--- a/Source/Csla.Benchmarks/Csla.Benchmarks.csproj
+++ b/Source/Csla.Benchmarks/Csla.Benchmarks.csproj
@@ -10,6 +10,6 @@
-
+
diff --git a/Source/Csla.Generators/cs/AutoImplementProperties/Csla.Generator.AutoImplementProperties.Attributes.CSharp/Csla.Generator.AutoImplementProperties.Attributes.CSharp.csproj b/Source/Csla.Generators/cs/AutoImplementProperties/Csla.Generator.AutoImplementProperties.Attributes.CSharp/Csla.Generator.AutoImplementProperties.Attributes.CSharp.csproj
deleted file mode 100644
index db90f85d61..0000000000
--- a/Source/Csla.Generators/cs/AutoImplementProperties/Csla.Generator.AutoImplementProperties.Attributes.CSharp/Csla.Generator.AutoImplementProperties.Attributes.CSharp.csproj
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
- netstandard2.0
- false
- false
- false
- false
- false
- false
- false
- false
- false
- false
- ..\..\..\..\..\Bin
- ..\..\..\..\..\bin\packages\
- false
- true
-
-
diff --git a/Source/Csla.Generators/cs/AutoImplementProperties/Csla.Generator.AutoImplementProperties.CSharp/AutoImplement/ExtractedTypeDefinition.cs b/Source/Csla.Generators/cs/AutoImplementProperties/Csla.Generator.AutoImplementProperties.CSharp/AutoImplement/ExtractedTypeDefinition.cs
index 454cc8c442..6d22d89c27 100644
--- a/Source/Csla.Generators/cs/AutoImplementProperties/Csla.Generator.AutoImplementProperties.CSharp/AutoImplement/ExtractedTypeDefinition.cs
+++ b/Source/Csla.Generators/cs/AutoImplementProperties/Csla.Generator.AutoImplementProperties.CSharp/AutoImplement/ExtractedTypeDefinition.cs
@@ -1,4 +1,5 @@
-using Csla.Generator.AutoImplementProperties.CSharp.AutoImplement;
+using Csla.Generator.AutoImplementProperties.CSharp;
+using Csla.Generator.AutoImplementProperties.CSharp.AutoImplement;
///
/// The definition of a type, extracted from the syntax tree provided by Roslyn
diff --git a/Source/Csla.Generators/cs/AutoImplementProperties/Csla.Generator.AutoImplementProperties.CSharp/Csla.Generator.AutoImplementProperties.CSharp.csproj b/Source/Csla.Generators/cs/AutoImplementProperties/Csla.Generator.AutoImplementProperties.CSharp/Csla.Generator.AutoImplementProperties.CSharp.csproj
index fa8842bd4a..fb2304ec0b 100644
--- a/Source/Csla.Generators/cs/AutoImplementProperties/Csla.Generator.AutoImplementProperties.CSharp/Csla.Generator.AutoImplementProperties.CSharp.csproj
+++ b/Source/Csla.Generators/cs/AutoImplementProperties/Csla.Generator.AutoImplementProperties.CSharp/Csla.Generator.AutoImplementProperties.CSharp.csproj
@@ -1,4 +1,4 @@
-
+
netstandard2.0
@@ -13,33 +13,20 @@
false
false
true
+ $(NoWarn);RS2008
..\..\..\..\..\Bin
..\..\..\..\..\bin\packages\
Csla.Generator.AutoImplementProperties.CSharp
Csla.Generator.AutoImplementProperties.CSharp
- Csla.Generator.AutoImplementProperties.CSharp
- CSLA .NET Generator AutoImplementProperties for CSharp
+ CSLA .NET source generators for CSharp (bundled in the Csla package)
true
True
- CSLA .NET Generators
- CSLA;Roslyn;Generator
-
-
- true
- false
- true
+
+ false
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
diff --git a/Source/Csla.Generators/cs/AutoImplementProperties/Csla.Generator.AutoImplementProperties.CSharp/DataPortalExtensions/DataPortalExtensionsAnalyzer.cs b/Source/Csla.Generators/cs/AutoImplementProperties/Csla.Generator.AutoImplementProperties.CSharp/DataPortalExtensions/DataPortalExtensionsAnalyzer.cs
new file mode 100644
index 0000000000..fb7a313b87
--- /dev/null
+++ b/Source/Csla.Generators/cs/AutoImplementProperties/Csla.Generator.AutoImplementProperties.CSharp/DataPortalExtensions/DataPortalExtensionsAnalyzer.cs
@@ -0,0 +1,141 @@
+//-----------------------------------------------------------------------
+//
+// Copyright (c) Marimer LLC. All rights reserved.
+// Website: https://cslanet.com
+//
+// Reports data portal extension methods that are not generated
+//-----------------------------------------------------------------------
+
+using System.Collections.Immutable;
+using Csla.Generator.AutoImplementProperties.CSharp.DataPortalOperations;
+using Csla.Generator.AutoImplementProperties.CSharp.DataPortalOperations.Models;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace Csla.Generator.AutoImplementProperties.CSharp.DataPortalExtensions
+{
+ ///
+ /// Reports business types for which
+ /// does not generate some or all of the requested extension methods, and
+ /// why, along with invalid extension settings.
+ ///
+ [DiagnosticAnalyzer(LanguageNames.CSharp)]
+ public sealed class DataPortalExtensionsAnalyzer : DiagnosticAnalyzer
+ {
+ ///
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ [
+ DataPortalOperationsDiagnostics.GenericExtensionsNotGenerated,
+ DataPortalOperationsDiagnostics.InvalidExtensionsTarget,
+ DataPortalOperationsDiagnostics.ExtensionNameHidden,
+ DataPortalOperationsDiagnostics.InaccessibleParameterType,
+ DataPortalOperationsDiagnostics.DuplicateExtensionMethod,
+ DataPortalOperationsDiagnostics.InvalidExtensionPrefix,
+ DataPortalOperationsDiagnostics.InvalidAsyncSuffix,
+ DataPortalOperationsDiagnostics.SyncExtensionsNotGenerated
+ ];
+
+ ///
+ public override void Initialize(AnalysisContext context)
+ {
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.EnableConcurrentExecution();
+ context.RegisterCompilationStartAction(compilationContext =>
+ {
+ var compilation = compilationContext.Compilation;
+ if (compilation.GetTypeByMetadataName(OperationDiscovery.DataPortalExtensionsAttributeName) is not { } extensionsAttribute)
+ return;
+
+ var options = ExtensionOptions.Create(compilation, compilationContext.Options.AnalyzerConfigOptionsProvider.GlobalOptions);
+ var attributeKinds = DataPortalOperationsAnalyzer.GetOperationAttributeKinds(compilation);
+ var state = new AnalysisState();
+
+ compilationContext.RegisterSymbolAction(symbolContext =>
+ {
+ if (AnalyzeType(symbolContext, extensionsAttribute, attributeKinds, options))
+ state.ExtensionsRequested = true;
+ }, SymbolKind.NamedType);
+
+ if (options.AssemblyEnabled && options.AssemblyPrefix.Length > 0 && !SyntaxFacts.IsValidIdentifier(options.AssemblyPrefix))
+ {
+ compilationContext.RegisterSyntaxNodeAction(
+ syntaxContext => AnalyzeAssemblyAttributes(syntaxContext, extensionsAttribute, options), SyntaxKind.AttributeList);
+ }
+
+ if (options.InvalidAsyncSuffix is not null || (options.GenerateSyncRequested && !options.GenerateSync))
+ {
+ compilationContext.RegisterCompilationEndAction(endContext =>
+ {
+ if (!state.ExtensionsRequested)
+ return;
+ if (options.InvalidAsyncSuffix is not null)
+ endContext.ReportDiagnostic(Diagnostic.Create(DataPortalOperationsDiagnostics.InvalidAsyncSuffix, Location.None, options.InvalidAsyncSuffix));
+ if (options.GenerateSyncRequested && !options.GenerateSync)
+ endContext.ReportDiagnostic(Diagnostic.Create(DataPortalOperationsDiagnostics.SyncExtensionsNotGenerated, Location.None));
+ });
+ }
+ });
+ }
+
+ ///
+ /// Reports why extension methods are not generated for a type. Returns
+ /// true when extension methods are requested for the type.
+ ///
+ private static bool AnalyzeType(SymbolAnalysisContext context, INamedTypeSymbol extensionsAttribute,
+ Dictionary attributeKinds, ExtensionOptions options)
+ {
+ var type = (INamedTypeSymbol)context.Symbol;
+ if (type.TypeKind != TypeKind.Class)
+ return false;
+
+ var compilation = context.Compilation;
+ var operationType = OperationDiscovery.GetOperationType(type, attributeKinds, compilation, context.CancellationToken);
+ if (operationType is null)
+ {
+ // A type marked with the attribute is reported even without operation methods.
+ if (!type.GetAttributes().Any(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, extensionsAttribute))
+ || OperationDiscovery.BuildTypeHeader(type, compilation, context.CancellationToken) is not { } header)
+ return false;
+ operationType = new OperationTypeModel(header, new EquatableArray(Array.Empty()));
+ }
+
+ var model = IncrementalDataPortalExtensionsGenerator.CreateModel(operationType, options, LocationInfo.From(type.Locations.FirstOrDefault()));
+ if (model is null)
+ return false;
+
+ DataPortalExtensionsBuilder.Analyze(model, (descriptor, location, messageArgs)
+ => context.ReportDiagnostic(Diagnostic.Create(descriptor, location?.ToLocation(compilation), messageArgs)));
+ return true;
+ }
+
+ private static void AnalyzeAssemblyAttributes(SyntaxNodeAnalysisContext context, INamedTypeSymbol extensionsAttribute, ExtensionOptions options)
+ {
+ var attributeList = (AttributeListSyntax)context.Node;
+ if (attributeList.Target?.Identifier.IsKind(SyntaxKind.AssemblyKeyword) != true)
+ return;
+
+ foreach (var attribute in attributeList.Attributes)
+ {
+ if (context.SemanticModel.GetSymbolInfo(attribute, context.CancellationToken).Symbol?.ContainingType is { } attributeType
+ && SymbolEqualityComparer.Default.Equals(attributeType, extensionsAttribute))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ DataPortalOperationsDiagnostics.InvalidExtensionPrefix, attribute.GetLocation(), context.Compilation.AssemblyName, options.AssemblyPrefix));
+ }
+ }
+ }
+
+ private sealed class AnalysisState
+ {
+ private volatile bool _extensionsRequested;
+
+ public bool ExtensionsRequested
+ {
+ get => _extensionsRequested;
+ set => _extensionsRequested = value;
+ }
+ }
+ }
+}
diff --git a/Source/Csla.Generators/cs/AutoImplementProperties/Csla.Generator.AutoImplementProperties.CSharp/DataPortalExtensions/DataPortalExtensionsBuilder.cs b/Source/Csla.Generators/cs/AutoImplementProperties/Csla.Generator.AutoImplementProperties.CSharp/DataPortalExtensions/DataPortalExtensionsBuilder.cs
new file mode 100644
index 0000000000..0c3a4309f8
--- /dev/null
+++ b/Source/Csla.Generators/cs/AutoImplementProperties/Csla.Generator.AutoImplementProperties.CSharp/DataPortalExtensions/DataPortalExtensionsBuilder.cs
@@ -0,0 +1,294 @@
+//-----------------------------------------------------------------------
+//
+// Copyright (c) Marimer LLC. All rights reserved.
+// Website: https://cslanet.com
+//
+// Builds strongly typed data portal extension methods
+//-----------------------------------------------------------------------
+// Portions adapted from Csla.DataPortalExtensions by Stefan Ossendorf
+// (https://github.com/StefanOssendorf/Csla.DataPortalExtensions),
+// licensed under the MIT License. Copyright (c) 2023 Stefan Ossendorf.
+//-----------------------------------------------------------------------
+
+using System.CodeDom.Compiler;
+using Csla.Generator.AutoImplementProperties.CSharp.DataPortalOperations;
+using Csla.Generator.AutoImplementProperties.CSharp.DataPortalOperations.Models;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+
+namespace Csla.Generator.AutoImplementProperties.CSharp.DataPortalExtensions
+{
+ ///
+ /// Builds the static class containing the strongly typed data portal
+ /// extension methods for a business type.
+ ///
+ internal static class DataPortalExtensionsBuilder
+ {
+ private const string ReceiverName = "portal";
+ private const string InvokerName = "__invoker";
+
+ ///
+ /// Build the generated source for a business type.
+ /// Returns null when nothing is generated.
+ ///
+ public static string? Build(ExtensionGenerationModel model)
+ {
+ var type = model.Type;
+ var methods = Analyze(model);
+ if (methods.Count == 0)
+ return null;
+
+ using var stringWriter = new StringWriter();
+ var writer = new IndentedTextWriter(stringWriter, " ");
+
+ writer.WriteLine("//");
+ writer.WriteLine("#nullable enable");
+ writer.WriteLineNoTabs(string.Empty);
+
+ if (type.Namespace.Length > 0)
+ {
+ writer.WriteLine($"namespace {type.Namespace}");
+ OpenBlock(writer);
+ }
+
+ // Different business types can map to the same class name (for example
+ // a top-level Outer_Inner and a nested Outer.Inner). The partial parts
+ // then merge; only a public business type states the accessibility so
+ // the parts never conflict, and the methods carry their own accessibility.
+ var classModifiers = type.Visibility == TypeVisibility.Public ? "public static partial" : "static partial";
+ writer.WriteLine("/// ");
+ writer.WriteLine($"/// Strongly typed data portal extension methods for .");
+ writer.WriteLine("/// ");
+ writer.WriteLine($"{classModifiers} class {type.ExtensionClassName}");
+ OpenBlock(writer);
+
+ var isFirst = true;
+ foreach (var method in methods)
+ {
+ if (!isFirst)
+ writer.WriteLineNoTabs(string.Empty);
+ isFirst = false;
+ AppendMethod(writer, type, method);
+ }
+
+ CloseBlock(writer);
+
+ if (type.Namespace.Length > 0)
+ CloseBlock(writer);
+
+ return stringWriter.ToString();
+ }
+
+ ///
+ /// Reports a diagnostic about a data portal extension method that is not generated.
+ ///
+ public delegate void DiagnosticReporter(DiagnosticDescriptor descriptor, LocationInfo? location, params object[] messageArgs);
+
+ ///
+ /// Selects the extension methods generated for a business type. When
+ /// is supplied, also reports why any are not
+ /// generated. The generator passes no reporter, and the analyzer reports
+ /// the diagnostics, so both apply the same rules.
+ ///
+ public static List Analyze(ExtensionGenerationModel model, DiagnosticReporter? report = null)
+ {
+ var type = model.Type;
+ var none = new List();
+
+ if (type.IsGeneric)
+ {
+ report?.Invoke(DataPortalOperationsDiagnostics.GenericExtensionsNotGenerated, type.Location, type.TypeName);
+ return none;
+ }
+
+ var invalidReason = type switch
+ {
+ { IsCslaObject: false } => "the type does not implement Csla.Core.ICslaObject",
+ { IsAbstract: true } => "the type is abstract",
+ { Visibility: TypeVisibility.Private } => "the type is not accessible outside its containing type",
+ _ => null
+ };
+ if (invalidReason is not null)
+ {
+ report?.Invoke(DataPortalOperationsDiagnostics.InvalidExtensionsTarget, type.Location, type.TypeName, invalidReason);
+ return none;
+ }
+
+ if (type.Prefix.Length > 0 && !SyntaxFacts.IsValidIdentifier(type.Prefix))
+ {
+ // An invalid assembly prefix is reported once, on the assembly attribute.
+ if (!type.FromAssembly)
+ report?.Invoke(DataPortalOperationsDiagnostics.InvalidExtensionPrefix, type.Location, type.TypeName, type.Prefix);
+ return none;
+ }
+
+ return SelectMethods(model, report);
+ }
+
+ private static List SelectMethods(ExtensionGenerationModel model, DiagnosticReporter? report)
+ {
+ var type = model.Type;
+ var options = model.Options;
+ var result = new List();
+ var signatures = new HashSet(StringComparer.Ordinal);
+ var (_, collisions) = DataPortalOperationsBuilder.SelectNamedDispatchMethods(model.Methods);
+
+ foreach (var method in model.Methods)
+ {
+ if (method.NoExtension || !method.CanDispatch)
+ continue;
+
+ // Methods sharing an operation name differ only in injected parameters,
+ // so they would produce identical extension methods; the runtime's
+ // choice is reported by the operations analyzer.
+ if (collisions.Any(c => ReferenceEquals(c.Loser, method)))
+ continue;
+
+ var criteria = method.CriteriaParameters.ToList();
+ var inaccessible = criteria.FirstOrDefault(p => p.Visibility == TypeVisibility.Private);
+ if (inaccessible is not null)
+ {
+ report?.Invoke(DataPortalOperationsDiagnostics.InaccessibleParameterType, method.Location, method.MethodDisplay, inaccessible.Name.TrimStart('@'), inaccessible.PatternTypeDisplay);
+ continue;
+ }
+
+ var baseName = GetBaseName(method.MethodName.TrimStart('@'));
+ var isChild = method.Kind is "CreateChild" or "FetchChild";
+ var receiverInterface = isChild ? "IChildDataPortal" : "IDataPortal";
+ var hiddenNames = isChild ? options.ChildHiddenNames : options.RootHiddenNames;
+ var parameterTypes = string.Join(",", criteria.Select(p => p.PatternTypeDisplay + (p.IsNullableValueType ? "?" : string.Empty)));
+
+ foreach (var isAsync in options.GenerateSync ? new[] { true, false } : new[] { true })
+ {
+ var name = type.Prefix + baseName + (isAsync ? options.AsyncSuffix : string.Empty);
+ if (hiddenNames.Contains(name))
+ {
+ report?.Invoke(DataPortalOperationsDiagnostics.ExtensionNameHidden, method.Location, name, type.TypeName, $"{receiverInterface}.{name}");
+ continue;
+ }
+
+ if (!signatures.Add($"{receiverInterface}.{name}({parameterTypes})"))
+ {
+ report?.Invoke(DataPortalOperationsDiagnostics.DuplicateExtensionMethod, method.Location, name, type.TypeName, method.MethodDisplay);
+ continue;
+ }
+
+ result.Add(new ExtensionMethod(method, name, isAsync, isChild));
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// The operation method name without an Async suffix.
+ ///
+ internal static string GetBaseName(string methodName)
+ => methodName.EndsWith("Async", StringComparison.Ordinal) && methodName.Length > "Async".Length
+ ? methodName.Substring(0, methodName.Length - "Async".Length)
+ : methodName;
+
+ private static void AppendMethod(IndentedTextWriter writer, ExtensionTypeModel type, ExtensionMethod extension)
+ {
+ var method = extension.Method;
+ var criteria = method.CriteriaParameters.ToList();
+ var isDelete = method.Kind == "Delete";
+ var criteriaNames = new HashSet(criteria.Select(p => p.Name.TrimStart('@')), StringComparer.Ordinal);
+ var receiver = GetUniqueName(ReceiverName, criteriaNames);
+ var invoker = GetUniqueName(InvokerName, criteriaNames);
+ var visibility = type.Visibility == TypeVisibility.Public && criteria.All(p => p.Visibility == TypeVisibility.Public)
+ ? "public"
+ : "internal";
+
+ var returnType = (extension.IsAsync, isDelete) switch
+ {
+ (true, true) => "global::System.Threading.Tasks.Task",
+ (true, false) => $"global::System.Threading.Tasks.Task<{type.FullyQualifiedName}>",
+ (false, true) => "void",
+ (false, false) => type.FullyQualifiedName
+ };
+
+ var portalType = extension.IsChild
+ ? $"global::Csla.IChildDataPortal<{type.FullyQualifiedName}>"
+ : $"global::Csla.IDataPortal<{type.FullyQualifiedName}>";
+ var invokerType = extension.IsChild
+ ? $"global::Csla.IChildDataPortalOperationInvoker<{type.FullyQualifiedName}>"
+ : $"global::Csla.IDataPortalOperationInvoker<{type.FullyQualifiedName}>";
+
+ var parameters = new List { $"this {portalType} {receiver}" };
+ foreach (var parameter in criteria)
+ {
+ var declaration = $"{(parameter.IsParams ? "params " : string.Empty)}{parameter.TypeDisplay} {parameter.Name}";
+ if (parameter.DefaultValueExpression is not null)
+ declaration += $" = {parameter.DefaultValueExpression}";
+ parameters.Add(declaration);
+ }
+
+ var criteriaArray = criteria.Count == 0
+ ? "global::System.Array.Empty