diff --git a/Directory.Packages.props b/Directory.Packages.props index 26f027485..2a77e63f5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -32,6 +32,7 @@ + diff --git a/docs/_data/reference.fieldmaps.fieldcalculationmap.yaml b/docs/_data/reference.fieldmaps.fieldcalculationmap.yaml new file mode 100644 index 000000000..be7d1c87a --- /dev/null +++ b/docs/_data/reference.fieldmaps.fieldcalculationmap.yaml @@ -0,0 +1,69 @@ +optionsClassName: FieldCalculationMapOptions +optionsClassFullName: MigrationTools.Tools.FieldCalculationMapOptions +configurationSamples: +- name: defaults + order: 2 + description: + code: >- + { + "MigrationTools": { + "Version": "16.0", + "CommonTools": { + "FieldMappingTool": { + "FieldMaps": [ + { + "FieldMapType": "FieldCalculationMap", + "ApplyTo": [ + "*" + ] + } + ] + } + } + } + } + sampleFor: MigrationTools.Tools.FieldCalculationMapOptions +- name: sample + order: 1 + description: + code: There is no sample, but you can check the classic below for a general feel. + sampleFor: MigrationTools.Tools.FieldCalculationMapOptions +- name: classic + order: 3 + description: + code: >- + { + "$type": "FieldCalculationMapOptions", + "expression": null, + "parameters": {}, + "targetField": null, + "ApplyTo": [ + "*" + ] + } + sampleFor: MigrationTools.Tools.FieldCalculationMapOptions +description: Performs mathematical calculations on numeric fields using NCalc expressions during migration. +className: FieldCalculationMap +typeName: FieldMaps +architecture: +options: +- parameterName: ApplyTo + type: List + description: missing XML code comments + defaultValue: missing XML code comments +- parameterName: expression + type: String + description: Gets or sets the NCalc expression to evaluate. Variables in the expression should be enclosed in square brackets (e.g., "[x]*2"). + defaultValue: null +- parameterName: parameters + type: Dictionary + description: Gets or sets a dictionary mapping variable names used in the expression to source field reference names. + defaultValue: '{}' +- parameterName: targetField + type: String + description: Gets or sets the target field reference name where the calculated result will be stored. + defaultValue: null +status: missing XML code comments +processingTarget: missing XML code comments +classFile: src/MigrationTools.Clients.TfsObjectModel/Tools/FieldMappingTool/FieldMaps/FieldCalculationMap.cs +optionsClassFile: src/MigrationTools/Tools/FieldMappingTool/FieldMaps/FieldCalculationMapOptions.cs diff --git a/docs/collections/_reference/reference.fieldmaps.fieldcalculationmap.md b/docs/collections/_reference/reference.fieldmaps.fieldcalculationmap.md new file mode 100644 index 000000000..de6f197f0 --- /dev/null +++ b/docs/collections/_reference/reference.fieldmaps.fieldcalculationmap.md @@ -0,0 +1,91 @@ +--- +optionsClassName: FieldCalculationMapOptions +optionsClassFullName: MigrationTools.Tools.FieldCalculationMapOptions +configurationSamples: +- name: defaults + order: 2 + description: + code: >- + { + "MigrationTools": { + "Version": "16.0", + "CommonTools": { + "FieldMappingTool": { + "FieldMaps": [ + { + "FieldMapType": "FieldCalculationMap", + "ApplyTo": [ + "*" + ] + } + ] + } + } + } + } + sampleFor: MigrationTools.Tools.FieldCalculationMapOptions +- name: sample + order: 1 + description: + code: There is no sample, but you can check the classic below for a general feel. + sampleFor: MigrationTools.Tools.FieldCalculationMapOptions +- name: classic + order: 3 + description: + code: >- + { + "$type": "FieldCalculationMapOptions", + "expression": null, + "parameters": {}, + "targetField": null, + "ApplyTo": [ + "*" + ] + } + sampleFor: MigrationTools.Tools.FieldCalculationMapOptions +description: Performs mathematical calculations on numeric fields using NCalc expressions during migration. +className: FieldCalculationMap +typeName: FieldMaps +architecture: +options: +- parameterName: ApplyTo + type: List + description: missing XML code comments + defaultValue: missing XML code comments +- parameterName: expression + type: String + description: Gets or sets the NCalc expression to evaluate. Variables in the expression should be enclosed in square brackets (e.g., "[x]*2"). + defaultValue: null +- parameterName: parameters + type: Dictionary + description: Gets or sets a dictionary mapping variable names used in the expression to source field reference names. + defaultValue: '{}' +- parameterName: targetField + type: String + description: Gets or sets the target field reference name where the calculated result will be stored. + defaultValue: null +status: missing XML code comments +processingTarget: missing XML code comments +classFile: src/MigrationTools.Clients.TfsObjectModel/Tools/FieldMappingTool/FieldMaps/FieldCalculationMap.cs +optionsClassFile: src/MigrationTools/Tools/FieldMappingTool/FieldMaps/FieldCalculationMapOptions.cs + +redirectFrom: +- /Reference/FieldMaps/FieldCalculationMapOptions/ +layout: reference +toc: true +permalink: /Reference/FieldMaps/FieldCalculationMap/ +title: FieldCalculationMap +categories: +- FieldMaps +- +topics: +- topic: notes + path: /docs/Reference/FieldMaps/FieldCalculationMap-notes.md + exists: false + markdown: '' +- topic: introduction + path: /docs/Reference/FieldMaps/FieldCalculationMap-introduction.md + exists: false + markdown: '' + +--- \ No newline at end of file diff --git a/src/MigrationTools.Clients.TfsObjectModel/MigrationTools.Clients.TfsObjectModel.csproj b/src/MigrationTools.Clients.TfsObjectModel/MigrationTools.Clients.TfsObjectModel.csproj index 4742bd98d..0102a479a 100644 --- a/src/MigrationTools.Clients.TfsObjectModel/MigrationTools.Clients.TfsObjectModel.csproj +++ b/src/MigrationTools.Clients.TfsObjectModel/MigrationTools.Clients.TfsObjectModel.csproj @@ -36,6 +36,7 @@ + diff --git a/src/MigrationTools.Clients.TfsObjectModel/Tools/FieldMappingTool/FieldMaps/FieldCalculationMap.cs b/src/MigrationTools.Clients.TfsObjectModel/Tools/FieldMappingTool/FieldMaps/FieldCalculationMap.cs new file mode 100644 index 000000000..53160fa6b --- /dev/null +++ b/src/MigrationTools.Clients.TfsObjectModel/Tools/FieldMappingTool/FieldMaps/FieldCalculationMap.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Microsoft.Extensions.Logging; +using Microsoft.TeamFoundation.WorkItemTracking.Client; +using MigrationTools.Tools; +using MigrationTools.Tools.Infrastructure; +using NCalc; + +namespace MigrationTools.FieldMaps.AzureDevops.ObjectModel +{ + /// + /// Performs mathematical calculations on numeric fields using NCalc expressions during migration. + /// + public class FieldCalculationMap : FieldMapBase + { + /// + /// Initializes a new instance of the FieldCalculationMap class. + /// + /// Logger for the field map operations + /// Telemetry logger for tracking operations + public FieldCalculationMap(ILogger logger, ITelemetryLogger telemetryLogger) + : base(logger, telemetryLogger) + { + } + + private FieldCalculationMapOptions Config { get { return (FieldCalculationMapOptions)_Config; } } + + /// + /// Configures the field map with the specified options and validates required settings. + /// + /// The field map configuration options + /// Thrown when required fields are not specified + public override void Configure(IFieldMapOptions config) + { + base.Configure(config); + + if (string.IsNullOrEmpty(Config.expression)) + { + throw new ArgumentNullException(nameof(Config.expression), "The expression field must be specified."); + } + + if (string.IsNullOrEmpty(Config.targetField)) + { + throw new ArgumentNullException(nameof(Config.targetField), "The target field must be specified."); + } + + if (Config.parameters == null || Config.parameters.Count == 0) + { + throw new ArgumentNullException(nameof(Config.parameters), "At least one parameter mapping must be specified."); + } + } + + public override string MappingDisplayName => $"{Config.expression} -> {Config.targetField}"; + + internal override void InternalExecute(WorkItem source, WorkItem target) + { + try + { + // Validate that target field exists + if (!target.Fields.Contains(Config.targetField)) + { + Log.LogWarning("FieldCalculationMap: Target field '{TargetField}' does not exist on work item {WorkItemId}. Skipping calculation.", Config.targetField, target.Id); + return; + } + + // Validate that all source fields exist and collect their values + var parameterValues = new Dictionary(); + foreach (var parameter in Config.parameters) + { + if (!source.Fields.Contains(parameter.Value)) + { + Log.LogWarning("FieldCalculationMap: Source field '{SourceField}' does not exist on work item {WorkItemId}. Skipping calculation.", parameter.Value, source.Id); + return; + } + + var fieldValue = source.Fields[parameter.Value].Value; + if (fieldValue == null) + { + Log.LogWarning("FieldCalculationMap: Source field '{SourceField}' is null on work item {WorkItemId}. Skipping calculation.", parameter.Value, source.Id); + return; + } + + // Convert field value to numeric + if (!TryConvertToNumeric(fieldValue, out var numericValue)) + { + Log.LogWarning("FieldCalculationMap: Source field '{SourceField}' with value '{FieldValue}' is not numeric on work item {WorkItemId}. Skipping calculation.", parameter.Value, fieldValue, source.Id); + return; + } + + parameterValues[parameter.Key] = numericValue; + } + + // Evaluate the expression + var expression = new Expression(Config.expression); + + // Set parameters + foreach (var param in parameterValues) + { + expression.Parameters[param.Key] = param.Value; + } + + // Evaluate and get result + var result = expression.Evaluate(); + + if (expression.HasErrors()) + { + Log.LogError("FieldCalculationMap: Expression evaluation failed with error: {Error} for work item {WorkItemId}", expression.Error, source.Id); + return; + } + + // Convert result to appropriate numeric type and set target field + if (TryConvertToTargetFieldType(result, target.Fields[Config.targetField], out var convertedResult)) + { + target.Fields[Config.targetField].Value = convertedResult; + Log.LogDebug("FieldCalculationMap: Successfully calculated and set field '{TargetField}' to '{Result}' for work item {WorkItemId}", Config.targetField, convertedResult, target.Id); + } + else + { + Log.LogWarning("FieldCalculationMap: Could not convert calculation result '{Result}' to target field type for work item {WorkItemId}", result, target.Id); + } + } + catch (Exception ex) + { + Log.LogError(ex, "FieldCalculationMap: Unexpected error during calculation for work item {WorkItemId}", target.Id); + } + } + + /// + /// Attempts to convert a field value to a numeric type. + /// + /// The field value to convert + /// The converted numeric value + /// True if conversion was successful, false otherwise + private static bool TryConvertToNumeric(object value, out object numericValue) + { + numericValue = null; + + if (value is int || value is long || value is decimal || value is double || value is float) + { + numericValue = value; + return true; + } + + var stringValue = value.ToString().Trim(); + + // Try different numeric types + if (int.TryParse(stringValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue)) + { + numericValue = intValue; + return true; + } + + if (long.TryParse(stringValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var longValue)) + { + numericValue = longValue; + return true; + } + + if (double.TryParse(stringValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var doubleValue)) + { + numericValue = doubleValue; + return true; + } + + return false; + } + + /// + /// Attempts to convert the calculation result to the appropriate type for the target field. + /// + /// The calculation result + /// The target field + /// The converted result + /// True if conversion was successful, false otherwise + private static bool TryConvertToTargetFieldType(object result, Field targetField, out object convertedResult) + { + convertedResult = null; + + try + { + // Check target field type and convert accordingly + var fieldType = targetField.FieldDefinition.FieldType; + + switch (fieldType) + { + case FieldType.Integer: + if (result is double doubleResult) + { + convertedResult = (int)Math.Round(doubleResult); + } + else + { + convertedResult = Convert.ToInt32(result); + } + return true; + + case FieldType.Double: + convertedResult = Convert.ToDouble(result); + return true; + + case FieldType.String: + // Allow setting string fields with numeric results + convertedResult = result.ToString(); + return true; + + default: + // For other field types, try direct assignment + convertedResult = result; + return true; + } + } + catch (Exception) + { + return false; + } + } + } +} \ No newline at end of file diff --git a/src/MigrationTools.Tests/MigrationTools.Tests.csproj b/src/MigrationTools.Tests/MigrationTools.Tests.csproj index 418c2f54e..d191af44b 100644 --- a/src/MigrationTools.Tests/MigrationTools.Tests.csproj +++ b/src/MigrationTools.Tests/MigrationTools.Tests.csproj @@ -11,6 +11,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/MigrationTools.Tests/Tools/FieldMaps/FieldCalculationMapTests.cs b/src/MigrationTools.Tests/Tools/FieldMaps/FieldCalculationMapTests.cs new file mode 100644 index 000000000..ac1cc03e6 --- /dev/null +++ b/src/MigrationTools.Tests/Tools/FieldMaps/FieldCalculationMapTests.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using MigrationTools.Tools; +using NCalc; + +namespace MigrationTools.Tests.Tools.FieldMaps +{ + [TestClass] + public class FieldCalculationMapTests + { + [TestMethod] + public void FieldCalculationMapOptions_Constructor_ShouldInitializeParametersDictionary() + { + // Arrange & Act + var options = new FieldCalculationMapOptions(); + + // Assert + Assert.IsNotNull(options.parameters); + Assert.AreEqual(0, options.parameters.Count); + } + + [TestMethod] + public void FieldCalculationMapOptions_SetExampleConfigDefaults_ShouldSetCorrectValues() + { + // Arrange + var options = new FieldCalculationMapOptions(); + + // Act + options.SetExampleConfigDefaults(); + + // Assert + Assert.AreEqual("[x]*2", options.expression); + Assert.AreEqual("Custom.FieldC", options.targetField); + Assert.AreEqual(1, options.parameters.Count); + Assert.AreEqual("Custom.FieldB", options.parameters["x"]); + CollectionAssert.Contains(options.ApplyTo, "SomeWorkItemType"); + } + + [TestMethod] + public void NCalcExpression_SimpleCalculation_ShouldEvaluateCorrectly() + { + // Arrange + var expression = new Expression("[x] * 2"); + expression.Parameters["x"] = 5; + + // Act + var result = expression.Evaluate(); + + // Assert + Assert.AreEqual(10, result); + } + + [TestMethod] + public void NCalcExpression_ComplexCalculation_ShouldEvaluateCorrectly() + { + // Arrange + var expression = new Expression("([a] + [b]) * [c]"); + expression.Parameters["a"] = 10; + expression.Parameters["b"] = 5; + expression.Parameters["c"] = 2; + + // Act + var result = expression.Evaluate(); + + // Assert + Assert.AreEqual(30, result); + } + + [TestMethod] + public void NCalcExpression_DivisionCalculation_ShouldEvaluateCorrectly() + { + // Arrange + var expression = new Expression("[total] / [count]"); + expression.Parameters["total"] = 100; + expression.Parameters["count"] = 4; + + // Act + var result = expression.Evaluate(); + + // Assert - NCalc returns double for division + Assert.AreEqual(25.0, result); + } + + [TestMethod] + public void NCalcExpression_MathFunctions_ShouldEvaluateCorrectly() + { + // Arrange - Test Abs function alone to avoid type conflicts + var expression = new Expression("Abs([value])"); + expression.Parameters["value"] = -25; + + // Act + var result = expression.Evaluate(); + + // Assert - NCalc returns decimal for Abs + Assert.AreEqual(25m, result); + } + + [TestMethod] + public void NCalcExpression_SqrtFunction_ShouldEvaluateCorrectly() + { + // Arrange - Test Sqrt function separately + var expression = new Expression("Sqrt([square])"); + expression.Parameters["square"] = 16.0; + + // Act + var result = expression.Evaluate(); + + // Assert + Assert.AreEqual(4.0, result); + } + + [TestMethod] + public void NCalcExpression_InvalidExpression_ShouldThrowException() + { + // Arrange + var expression = new Expression("[x] + ["); // Invalid syntax + + // Act & Assert - NCalc throws EvaluationException for invalid syntax + Assert.ThrowsException(() => expression.Evaluate()); + } + + [TestMethod] + public void NCalcExpression_UndefinedVariable_ShouldThrowException() + { + // Arrange + var expression = new Expression("[undefined] * 2"); + + // Act & Assert + Assert.ThrowsException(() => expression.Evaluate()); + } + } +} \ No newline at end of file diff --git a/src/MigrationTools/Tools/FieldMappingTool/FieldMaps/FieldCalculationMapOptions.cs b/src/MigrationTools/Tools/FieldMappingTool/FieldMaps/FieldCalculationMapOptions.cs new file mode 100644 index 000000000..7a55e8a29 --- /dev/null +++ b/src/MigrationTools/Tools/FieldMappingTool/FieldMaps/FieldCalculationMapOptions.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using MigrationTools.Tools.Infrastructure; + +namespace MigrationTools.Tools +{ + /// + /// Performs mathematical calculations on numeric fields using NCalc expressions during migration. + /// + /// ready + /// Work Item Field + public class FieldCalculationMapOptions : FieldMapOptions + { + /// + /// Gets or sets the NCalc expression to evaluate. Variables in the expression should be enclosed in square brackets (e.g., "[x]*2"). + /// + /// null + public string expression { get; set; } + + /// + /// Gets or sets a dictionary mapping variable names used in the expression to source field reference names. + /// + /// {} + public Dictionary parameters { get; set; } + + /// + /// Gets or sets the target field reference name where the calculated result will be stored. + /// + /// null + public string targetField { get; set; } + + /// + /// Sets example configuration defaults for documentation purposes. + /// + public void SetExampleConfigDefaults() + { + ApplyTo = new List() { "SomeWorkItemType" }; + expression = "[x]*2"; + parameters = new Dictionary + { + { "x", "Custom.FieldB" } + }; + targetField = "Custom.FieldC"; + } + + /// + /// Initializes a new instance of the FieldCalculationMapOptions class. + /// + public FieldCalculationMapOptions() + { + parameters = new Dictionary(); + } + } +} \ No newline at end of file