From 8f0b156ae7a9a0bb879bf356254c13c1059a2775 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Sat, 24 Jan 2026 18:36:57 +0100 Subject: [PATCH 1/3] Add "InitPrimaryKeyFields" setting for TransferFields to TransferFieldsSchemaCompatibility analyzer --- ...nvocationWithInitPrimaryKeyFieldsIsTrue.al | 28 +++++++ ...vocationWithInitPrimaryKeyFieldsIsFalse.al | 28 +++++++ .../TransferFieldsNameMismatch.cs | 2 + ...nvocationWithInitPrimaryKeyFieldsIsTrue.al | 28 +++++++ .../NoDiagnostic/InvocationCodeToText.al | 28 +++++++ ...vocationWithInitPrimaryKeyFieldsIsFalse.al | 28 +++++++ .../TransferFieldsTypeMismatch.cs | 3 + .../ALCops.PlatformCopAnalyzers.cs | 2 +- .../ALCops.PlatformCopAnalyzers.resx | 2 +- .../TransferFieldsSchemaCompatibility.cs | 80 +++++++++++++------ .../DiagnosticDescriptors.cs | 2 +- 11 files changed, 205 insertions(+), 26 deletions(-) create mode 100644 src/ALCops.PlatformCop.Test/Rules/TransferFieldsNameMismatch/HasDiagnostic/InvocationWithInitPrimaryKeyFieldsIsTrue.al create mode 100644 src/ALCops.PlatformCop.Test/Rules/TransferFieldsNameMismatch/NoDiagnostic/InvocationWithInitPrimaryKeyFieldsIsFalse.al create mode 100644 src/ALCops.PlatformCop.Test/Rules/TransferFieldsTypeMismatch/HasDiagnostic/InvocationWithInitPrimaryKeyFieldsIsTrue.al create mode 100644 src/ALCops.PlatformCop.Test/Rules/TransferFieldsTypeMismatch/NoDiagnostic/InvocationCodeToText.al create mode 100644 src/ALCops.PlatformCop.Test/Rules/TransferFieldsTypeMismatch/NoDiagnostic/InvocationWithInitPrimaryKeyFieldsIsFalse.al diff --git a/src/ALCops.PlatformCop.Test/Rules/TransferFieldsNameMismatch/HasDiagnostic/InvocationWithInitPrimaryKeyFieldsIsTrue.al b/src/ALCops.PlatformCop.Test/Rules/TransferFieldsNameMismatch/HasDiagnostic/InvocationWithInitPrimaryKeyFieldsIsTrue.al new file mode 100644 index 0000000..9f7d71f --- /dev/null +++ b/src/ALCops.PlatformCop.Test/Rules/TransferFieldsNameMismatch/HasDiagnostic/InvocationWithInitPrimaryKeyFieldsIsTrue.al @@ -0,0 +1,28 @@ +codeunit 50100 MyCodeunit +{ + procedure MyProcedure() + var + FromRec: Record MyTableA; + ToRec: Record MyTableB; + begin + [|ToRec.TransferFields(FromRec, true)|]; + end; +} + +table 50100 MyTableA +{ + fields + { + [|field(1; "Primary Key"; Code[20]) { }|] + field(2; MyField; Integer) { } + } +} + +table 50101 MyTableB +{ + fields + { + [|field(1; "Other Primary Key"; Code[20]) { }|] // Same ID (1) as in MyTableA, different name + field(2; MyField; Integer) { } + } +} \ No newline at end of file diff --git a/src/ALCops.PlatformCop.Test/Rules/TransferFieldsNameMismatch/NoDiagnostic/InvocationWithInitPrimaryKeyFieldsIsFalse.al b/src/ALCops.PlatformCop.Test/Rules/TransferFieldsNameMismatch/NoDiagnostic/InvocationWithInitPrimaryKeyFieldsIsFalse.al new file mode 100644 index 0000000..57804af --- /dev/null +++ b/src/ALCops.PlatformCop.Test/Rules/TransferFieldsNameMismatch/NoDiagnostic/InvocationWithInitPrimaryKeyFieldsIsFalse.al @@ -0,0 +1,28 @@ +codeunit 50100 MyCodeunit +{ + procedure MyProcedure() + var + FromRec: Record MyTableA; + ToRec: Record MyTableB; + begin + [|ToRec.TransferFields(FromRec, false)|]; + end; +} + +table 50100 MyTableA +{ + fields + { + [|field(1; "Primary Key"; Code[20]) { }|] + field(2; MyField; Integer) { } + } +} + +table 50101 MyTableB +{ + fields + { + [|field(1; "Other Primary Key"; Code[20]) { }|] // Same ID (1) as in MyTableA, different name + field(2; MyField; Integer) { } + } +} \ No newline at end of file diff --git a/src/ALCops.PlatformCop.Test/Rules/TransferFieldsNameMismatch/TransferFieldsNameMismatch.cs b/src/ALCops.PlatformCop.Test/Rules/TransferFieldsNameMismatch/TransferFieldsNameMismatch.cs index 7a5b785..5bdf3da 100644 --- a/src/ALCops.PlatformCop.Test/Rules/TransferFieldsNameMismatch/TransferFieldsNameMismatch.cs +++ b/src/ALCops.PlatformCop.Test/Rules/TransferFieldsNameMismatch/TransferFieldsNameMismatch.cs @@ -24,6 +24,7 @@ public void Setup() [TestCase("InvocationRecWithTable")] [TestCase("InvocationRecWithTablexRec")] [TestCase("InvocationSkipFieldsNotMatchingType")] + [TestCase("InvocationWithInitPrimaryKeyFieldsIsTrue")] [TestCase("InvocationWithReturnValue")] [TestCase("InvocationWithVarGlobals")] [TestCase("InvocationWithVarLocalAndGlobal")] @@ -49,6 +50,7 @@ public async Task HasDiagnostic(string testCase) [TestCase("BuiltInInvocation")] [TestCase("Invocation_Pragma")] [TestCase("InvocationSkipFieldsNotMatchingType")] + [TestCase("InvocationWithInitPrimaryKeyFieldsIsFalse")] [TestCase("TableExt_Paired_Extension_Pragma")] [TestCase("TableExt_Paired_SingleTableExt")] [TestCase("TableExt_Unpaired")] diff --git a/src/ALCops.PlatformCop.Test/Rules/TransferFieldsTypeMismatch/HasDiagnostic/InvocationWithInitPrimaryKeyFieldsIsTrue.al b/src/ALCops.PlatformCop.Test/Rules/TransferFieldsTypeMismatch/HasDiagnostic/InvocationWithInitPrimaryKeyFieldsIsTrue.al new file mode 100644 index 0000000..aed78b7 --- /dev/null +++ b/src/ALCops.PlatformCop.Test/Rules/TransferFieldsTypeMismatch/HasDiagnostic/InvocationWithInitPrimaryKeyFieldsIsTrue.al @@ -0,0 +1,28 @@ +codeunit 50100 MyCodeunit +{ + procedure MyProcedure() + var + FromRec: Record MyTableA; + ToRec: Record MyTableB; + begin + [|ToRec.TransferFields(FromRec, true)|]; + end; +} + +table 50100 MyTableA +{ + fields + { + [|field(1; "Primary Key"; Code[20]) { }|] + field(2; MyField; Integer) { } + } +} + +table 50101 MyTableB +{ + fields + { + [|field(1; "Primary Key"; Integer) { }|] // Same ID (1) as in MyTableA, different type + field(2; MyField; Integer) { } + } +} \ No newline at end of file diff --git a/src/ALCops.PlatformCop.Test/Rules/TransferFieldsTypeMismatch/NoDiagnostic/InvocationCodeToText.al b/src/ALCops.PlatformCop.Test/Rules/TransferFieldsTypeMismatch/NoDiagnostic/InvocationCodeToText.al new file mode 100644 index 0000000..5c1689c --- /dev/null +++ b/src/ALCops.PlatformCop.Test/Rules/TransferFieldsTypeMismatch/NoDiagnostic/InvocationCodeToText.al @@ -0,0 +1,28 @@ +codeunit 50100 MyCodeunit +{ + procedure MyProcedure() + var + FromRec: Record MyTableA; + ToRec: Record MyTableB; + begin + [|ToRec.TransferFields(FromRec)|]; + end; +} + +table 50100 MyTableA +{ + fields + { + field(1; "Primary Key"; Code[20]) { } + [|field(2; MyField; Code[20]) { }|] + } +} + +table 50101 MyTableB +{ + fields + { + field(1; "Primary Key"; Code[20]) { } + [|field(2; MyField; Text[20]) { }|] // Same ID (2) as in MyTableA, where a Code can safely convert into a Text + } +} \ No newline at end of file diff --git a/src/ALCops.PlatformCop.Test/Rules/TransferFieldsTypeMismatch/NoDiagnostic/InvocationWithInitPrimaryKeyFieldsIsFalse.al b/src/ALCops.PlatformCop.Test/Rules/TransferFieldsTypeMismatch/NoDiagnostic/InvocationWithInitPrimaryKeyFieldsIsFalse.al new file mode 100644 index 0000000..40d218e --- /dev/null +++ b/src/ALCops.PlatformCop.Test/Rules/TransferFieldsTypeMismatch/NoDiagnostic/InvocationWithInitPrimaryKeyFieldsIsFalse.al @@ -0,0 +1,28 @@ +codeunit 50100 MyCodeunit +{ + procedure MyProcedure() + var + FromRec: Record MyTableA; + ToRec: Record MyTableB; + begin + [|ToRec.TransferFields(FromRec, false)|]; + end; +} + +table 50100 MyTableA +{ + fields + { + [|field(1; "Primary Key"; Code[20]) { }|] + field(2; MyField; Integer) { } + } +} + +table 50101 MyTableB +{ + fields + { + [|field(1; "Primary Key"; Integer) { }|] // Same ID (1) as in MyTableA, different type + field(2; MyField; Integer) { } + } +} \ No newline at end of file diff --git a/src/ALCops.PlatformCop.Test/Rules/TransferFieldsTypeMismatch/TransferFieldsTypeMismatch.cs b/src/ALCops.PlatformCop.Test/Rules/TransferFieldsTypeMismatch/TransferFieldsTypeMismatch.cs index 7114250..bd7b580 100644 --- a/src/ALCops.PlatformCop.Test/Rules/TransferFieldsTypeMismatch/TransferFieldsTypeMismatch.cs +++ b/src/ALCops.PlatformCop.Test/Rules/TransferFieldsTypeMismatch/TransferFieldsTypeMismatch.cs @@ -24,6 +24,7 @@ public void Setup() [TestCase("InvocationRecWithTable")] [TestCase("InvocationRecWithTablexRec")] [TestCase("InvocationSkipFieldsNotMatchingType")] + [TestCase("InvocationWithInitPrimaryKeyFieldsIsTrue")] [TestCase("InvocationWithReturnValue")] [TestCase("InvocationWithVarGlobals")] [TestCase("InvocationWithVarLocalAndGlobal")] @@ -50,7 +51,9 @@ public async Task HasDiagnostic(string testCase) [Test] [TestCase("BuiltInInvocation")] [TestCase("Invocation_Pragma")] + [TestCase("InvocationCodeToText")] [TestCase("InvocationSkipFieldsNotMatchingType")] + [TestCase("InvocationWithInitPrimaryKeyFieldsIsFalse")] [TestCase("InvocationWithType")] [TestCase("InvocationWithTypeLength")] [TestCase("TableExt_Paired_Extension_Pragma")] diff --git a/src/ALCops.PlatformCop/ALCops.PlatformCopAnalyzers.cs b/src/ALCops.PlatformCop/ALCops.PlatformCopAnalyzers.cs index 71d596d..1c98340 100644 --- a/src/ALCops.PlatformCop/ALCops.PlatformCopAnalyzers.cs +++ b/src/ALCops.PlatformCop/ALCops.PlatformCopAnalyzers.cs @@ -859,7 +859,7 @@ internal static string TransferFieldsTypeMismatchDescription { } /// - /// Looks up a localized string similar to Field with ID {0} has incompatible field types ({3} → {4}) between TransferFields-coupled tables {1} and {2}.. + /// Looks up a localized string similar to Fields {5} and {6} with ID {0} have a possible incompatible field type ({3} → {4}) between TransferFields-coupled tables {1} and {2}.. /// internal static string TransferFieldsTypeMismatchMessageFormat { get { diff --git a/src/ALCops.PlatformCop/ALCops.PlatformCopAnalyzers.resx b/src/ALCops.PlatformCop/ALCops.PlatformCopAnalyzers.resx index 59823b4..70c12ff 100644 --- a/src/ALCops.PlatformCop/ALCops.PlatformCopAnalyzers.resx +++ b/src/ALCops.PlatformCop/ALCops.PlatformCopAnalyzers.resx @@ -385,7 +385,7 @@ Incompatible field types across TransferFields - Field with ID {0} has incompatible field types ({3} → {4}) between TransferFields-coupled tables {1} and {2}. + Fields {5} and {6} with ID {0} have a possible incompatible field type ({3} → {4}) between TransferFields-coupled tables {1} and {2}. Tables coupled via TransferFields must define matching field types for the same field ID. A type mismatch will result in a runtime error when TransferFields is executed, as incompatible field types cannot be safely copied. diff --git a/src/ALCops.PlatformCop/Analyzers/TransferFieldsSchemaCompatibility.cs b/src/ALCops.PlatformCop/Analyzers/TransferFieldsSchemaCompatibility.cs index 0ec9db1..369ef0a 100644 --- a/src/ALCops.PlatformCop/Analyzers/TransferFieldsSchemaCompatibility.cs +++ b/src/ALCops.PlatformCop/Analyzers/TransferFieldsSchemaCompatibility.cs @@ -101,7 +101,7 @@ private static void AnalyzeInvocation(OperationAnalysisContext ctx) var tableExtensions = GetCachedTableExtensions(ctx.Compilation); var sourceFields = BuildEffectiveFields(sourceTable, tableExtensions); - var targetFields = BuildEffectiveFields(targetTable, tableExtensions); + var targetFields = BuildEffectiveFields(targetTable, tableExtensions, IsInitPrimaryKeyFieldsEnabled(invocation)); if (sourceFields.IsEmpty || targetFields.IsEmpty) return; @@ -196,7 +196,9 @@ private static void AnalyzeInvocation(OperationAnalysisContext ctx) sourceDisplay, targetDisplay, GetToDisplayStringSafe(sourceById[minTypeMismatchId]), - GetToDisplayStringSafe(targetById[minTypeMismatchId]))); + GetToDisplayStringSafe(targetById[minTypeMismatchId]), + sourceById[minTypeMismatchId].Name.QuoteIdentifierIfNeededWithReflection(), + targetById[minTypeMismatchId].Name.QuoteIdentifierIfNeededWithReflection())); } } @@ -289,6 +291,15 @@ private static bool IsFieldSuppressed(string diagnosticId, IFieldSymbol field) return false; } + private static bool IsInitPrimaryKeyFieldsEnabled(IInvocationExpression invocation) + { + if (invocation.Arguments.Length < 2) + return true; // Default is true + + var constant = invocation.Arguments[1].Value.ConstantValue; + return constant.HasValue && constant.Value is true; + } + private static bool IsSkipFieldsNotMatchingTypeEnabled(IInvocationExpression invocation) { if (invocation.Arguments.Length < 3) @@ -313,39 +324,51 @@ private static bool IsSkipFieldsNotMatchingTypeEnabled(IInvocationExpression inv private static ImmutableArray BuildEffectiveFields( ITableTypeSymbol table, - ImmutableArray allTableExtensions) + ImmutableArray allTableExtensions, + bool includePrimaryKeyFields = true) { - var baseFields = table.Fields; + var pkFields = + !includePrimaryKeyFields && table.PrimaryKey is not null + ? table.PrimaryKey.Fields + : default; + + var baseBuilder = ImmutableArray.CreateBuilder(table.Fields.Length); + + foreach (var field in table.Fields) + { + var id = field.Id; + + if (id == 0 || id >= 2_000_000_000) + continue; + + if (!pkFields.IsDefaultOrEmpty && + pkFields.Any(pk => pk.Id == id)) + continue; + + baseBuilder.Add(field); + } var extensionFields = allTableExtensions .Where(ext => SameApplicationObject(ext.Target, table)) - .SelectMany(ext => ext.AddedFields) - .ToImmutableArray(); - - if (extensionFields.IsEmpty) - return baseFields; + .SelectMany(ext => ext.AddedFields); - return baseFields.AddRange(extensionFields); + return baseBuilder.ToImmutable(); } private static Dictionary BuildFieldMapById(IEnumerable fields) { - var map = new Dictionary(); + var map = fields is ICollection collection + ? new Dictionary(collection.Count) + : new Dictionary(); foreach (var field in fields) { - if (field is ISymbolWithId withId) - { - var id = (int)withId.Id; - if (id >= 2000000000) - continue; - - if (field.FieldClass != EnumProvider.FieldClassKind.Normal) - continue; + if (field.FieldClass != EnumProvider.FieldClassKind.Normal) + continue; - map[id] = field; - } + var id = field.Id; + map[id] = field; } return map; @@ -386,6 +409,13 @@ private static bool AreFieldTypesEquivalent(IFieldSymbol source, IFieldSymbol ta return false; } + // Explicitly allow Code → Text assignments + if (sourceKind == EnumProvider.NavTypeKind.Code && + targetKind == EnumProvider.NavTypeKind.Text) + { + return true; + } + return sourceKind == targetKind; } @@ -457,7 +487,9 @@ private static void ReportField( sourceDisplay, targetDisplay, GetToDisplayStringSafe(sourceField), - GetToDisplayStringSafe(targetField))); + GetToDisplayStringSafe(targetField), + sourceField.Name.QuoteIdentifierIfNeededWithReflection(), + targetField.Name.QuoteIdentifierIfNeededWithReflection())); return; } } @@ -504,7 +536,9 @@ private static void ReportField( sourceDisplay, targetDisplay, GetToDisplayStringSafe(sourceField), - GetToDisplayStringSafe(targetField))); + GetToDisplayStringSafe(targetField), + sourceField.Name.QuoteIdentifierIfNeededWithReflection(), + targetField.Name.QuoteIdentifierIfNeededWithReflection())); return; } } diff --git a/src/ALCops.PlatformCop/DiagnosticDescriptors.cs b/src/ALCops.PlatformCop/DiagnosticDescriptors.cs index 281cfae..cad4fb0 100644 --- a/src/ALCops.PlatformCop/DiagnosticDescriptors.cs +++ b/src/ALCops.PlatformCop/DiagnosticDescriptors.cs @@ -260,7 +260,7 @@ public static class DiagnosticDescriptors title: PlatformCopAnalyzers.TransferFieldsTypeMismatchTitle, messageFormat: PlatformCopAnalyzers.TransferFieldsTypeMismatchMessageFormat, category: Category.Design, - defaultSeverity: DiagnosticSeverity.Error, + defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true, description: PlatformCopAnalyzers.TransferFieldsTypeMismatchDescription, helpLinkUri: GetHelpUri(DiagnosticIds.TransferFieldsTypeMismatch)); From 413e949363ca1912370baa1665aed4f37f0d2a77 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Sat, 24 Jan 2026 19:34:19 +0100 Subject: [PATCH 2/3] Use .Equals --- .../Analyzers/TransferFieldsSchemaCompatibility.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ALCops.PlatformCop/Analyzers/TransferFieldsSchemaCompatibility.cs b/src/ALCops.PlatformCop/Analyzers/TransferFieldsSchemaCompatibility.cs index 369ef0a..c7d6f0f 100644 --- a/src/ALCops.PlatformCop/Analyzers/TransferFieldsSchemaCompatibility.cs +++ b/src/ALCops.PlatformCop/Analyzers/TransferFieldsSchemaCompatibility.cs @@ -416,7 +416,7 @@ private static bool AreFieldTypesEquivalent(IFieldSymbol source, IFieldSymbol ta return true; } - return sourceKind == targetKind; + return sourceKind.Equals(targetKind); } private static bool IsNumeric(NavTypeKind kind) From 92ab86e3120023bafeb03bfdfd2b414dc56394ec Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Sat, 24 Jan 2026 19:39:00 +0100 Subject: [PATCH 3/3] Allow Enum to Integer assignments in TransferFieldsSchemaCompatibility analyzer --- .../Analyzers/TransferFieldsSchemaCompatibility.cs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/ALCops.PlatformCop/Analyzers/TransferFieldsSchemaCompatibility.cs b/src/ALCops.PlatformCop/Analyzers/TransferFieldsSchemaCompatibility.cs index c7d6f0f..02f4337 100644 --- a/src/ALCops.PlatformCop/Analyzers/TransferFieldsSchemaCompatibility.cs +++ b/src/ALCops.PlatformCop/Analyzers/TransferFieldsSchemaCompatibility.cs @@ -387,6 +387,16 @@ private static bool AreFieldTypesEquivalent(IFieldSymbol source, IFieldSymbol ta if (sourceType is null || targetType is null) return false; + var sourceKind = sourceType.GetNavTypeKindSafe(); + var targetKind = targetType.GetNavTypeKindSafe(); + + // Explicitly allow Enum → Integer assignments + if (sourceKind == EnumProvider.NavTypeKind.Enum && + targetKind == EnumProvider.NavTypeKind.Integer) + { + return true; + } + if (sourceType is IApplicationObjectTypeSymbol && targetType is IApplicationObjectTypeSymbol) { @@ -395,9 +405,6 @@ private static bool AreFieldTypesEquivalent(IFieldSymbol source, IFieldSymbol ta targetType.OriginalDefinition); } - var sourceKind = sourceType.GetNavTypeKindSafe(); - var targetKind = targetType.GetNavTypeKindSafe(); - if (IsNumeric(sourceKind) && IsNumeric(targetKind)) { return IsNumericAssignmentSafe(sourceKind, targetKind);