diff --git a/src/libraries/Microsoft.PowerFx.Core/Localization/Strings.cs b/src/libraries/Microsoft.PowerFx.Core/Localization/Strings.cs index b744d5ad57..52b49cf048 100644 --- a/src/libraries/Microsoft.PowerFx.Core/Localization/Strings.cs +++ b/src/libraries/Microsoft.PowerFx.Core/Localization/Strings.cs @@ -530,6 +530,11 @@ internal static class TexlStrings public static StringGetter AboutRemove = (b) => StringResources.Get("AboutRemove", b); public static StringGetter RemoveDataSourceArg = (b) => StringResources.Get("RemoveDataSourceArg", b); public static StringGetter RemoveRecordsArg = (b) => StringResources.Get("RemoveRecordsArg", b); + public static StringGetter AboutUpdate = (b) => StringResources.Get("AboutUpdate", b); + public static StringGetter UpdateDataSourceArg = (b) => StringResources.Get("UpdateDataSourceArg", b); + public static StringGetter UpdateOldRecordArg = (b) => StringResources.Get("UpdateOldRecordArg", b); + public static StringGetter UpdateNewRecordArg = (b) => StringResources.Get("UpdateNewRecordArg", b); + public static StringGetter UpdateAllArg = (b) => StringResources.Get("UpdateAllArg", b); public static StringGetter AboutDec2Hex = (b) => StringResources.Get("AboutDec2Hex", b); public static StringGetter Dec2HexArg1 = (b) => StringResources.Get("Dec2HexArg1", b); diff --git a/src/libraries/Microsoft.PowerFx.Core/Public/Values/CollectionTableValue.cs b/src/libraries/Microsoft.PowerFx.Core/Public/Values/CollectionTableValue.cs index fee7d280c7..d628e5a093 100644 --- a/src/libraries/Microsoft.PowerFx.Core/Public/Values/CollectionTableValue.cs +++ b/src/libraries/Microsoft.PowerFx.Core/Public/Values/CollectionTableValue.cs @@ -255,6 +255,52 @@ protected override async Task> PatchCoreAsync(RecordValue ba } } + internal override async Task> UpdateAsync(RecordValue oldRecord, RecordValue replacementRecord, bool all, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (_sourceMutableIndex == null) + { + return await base.UpdateAsync(oldRecord, replacementRecord, all, cancellationToken).ConfigureAwait(false); + } + + var found = false; + + for (var index = 0; index < _sourceMutableIndex.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var current = Marshal(_sourceMutableIndex[index]); + if (current.IsError) + { + return DValue.Of(current.Error); + } + + if (current.IsBlank) + { + continue; + } + + if (await MatchesAsync(current.Value, oldRecord, cancellationToken).ConfigureAwait(false)) + { + found = true; + _sourceMutableIndex[index] = MarshalInverse((RecordValue)replacementRecord.MaybeShallowCopy()); + + if (!all) + { + break; + } + } + } + + if (!found) + { + return DValue.Of(FormulaValue.NewError(new ExpressionError() { Message = "The specified record was not found.", Kind = ErrorKind.NotFound })); + } + + return DValue.Of(New(true)); + } + /// /// Execute a linear search for the matching record. /// diff --git a/src/libraries/Microsoft.PowerFx.Core/Public/Values/TableValue.cs b/src/libraries/Microsoft.PowerFx.Core/Public/Values/TableValue.cs index 42e70f6847..791edc3946 100644 --- a/src/libraries/Microsoft.PowerFx.Core/Public/Values/TableValue.cs +++ b/src/libraries/Microsoft.PowerFx.Core/Public/Values/TableValue.cs @@ -249,6 +249,18 @@ public virtual async Task> ClearAsync(CancellationToken can return DValue.Of(NotImplementedError(IRContext)); } + internal virtual async Task> UpdateAsync(RecordValue oldRecord, RecordValue replacementRecord, bool all, CancellationToken cancellationToken) + { + var result = await PatchAsync(oldRecord, replacementRecord, cancellationToken).ConfigureAwait(false); + + if (result.IsError) + { + return DValue.Of(result.Error); + } + + return DValue.Of(FormulaValue.New(true)); + } + /// /// Patch implementation for derived classes. /// diff --git a/src/libraries/Microsoft.PowerFx.Interpreter/Environment/PowerFxConfigExtensions.cs b/src/libraries/Microsoft.PowerFx.Interpreter/Environment/PowerFxConfigExtensions.cs index 85c28390c9..31e34a2c88 100644 --- a/src/libraries/Microsoft.PowerFx.Interpreter/Environment/PowerFxConfigExtensions.cs +++ b/src/libraries/Microsoft.PowerFx.Interpreter/Environment/PowerFxConfigExtensions.cs @@ -50,6 +50,7 @@ public static void EnableMutationFunctions(this SymbolTable symbolTable) symbolTable.AddFunction(new PatchAggregateImpl()); symbolTable.AddFunction(new PatchAggregateSingleTableImpl()); symbolTable.AddFunction(new RemoveFunction()); + symbolTable.AddFunction(new UpdateFunction()); symbolTable.AddFunction(new ClearImpl()); symbolTable.AddFunction(new ClearCollectImpl()); symbolTable.AddFunction(new ClearCollectScalarImpl()); diff --git a/src/libraries/Microsoft.PowerFx.Interpreter/Functions/Mutation/UpdateFunction.cs b/src/libraries/Microsoft.PowerFx.Interpreter/Functions/Mutation/UpdateFunction.cs new file mode 100644 index 0000000000..d6e595c362 --- /dev/null +++ b/src/libraries/Microsoft.PowerFx.Interpreter/Functions/Mutation/UpdateFunction.cs @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerFx.Core.App.ErrorContainers; +using Microsoft.PowerFx.Core.Binding; +using Microsoft.PowerFx.Core.Errors; +using Microsoft.PowerFx.Core.Functions; +using Microsoft.PowerFx.Core.Functions.DLP; +using Microsoft.PowerFx.Core.Localization; +using Microsoft.PowerFx.Core.Types; +using Microsoft.PowerFx.Core.Utils; +using Microsoft.PowerFx.Syntax; +using Microsoft.PowerFx.Types; +using static Microsoft.PowerFx.Core.Localization.TexlStrings; + +namespace Microsoft.PowerFx.Functions +{ + internal class UpdateFunction : RemoveFunctionBase, IFunctionInvoker + { + public override bool SupportsParamCoercion => true; + + public override RequiredDataSourcePermissions FunctionPermission => RequiredDataSourcePermissions.Update; + + public UpdateFunction() + : base("Update", AboutUpdate, FunctionCategories.Table | FunctionCategories.Behavior, DType.Unknown, 0, 3, 4, DType.EmptyTable, DType.EmptyRecord, DType.EmptyRecord) + { + } + + public override IEnumerable GetSignatures() + { + yield return new[] { UpdateDataSourceArg, UpdateOldRecordArg, UpdateNewRecordArg }; + yield return new[] { UpdateDataSourceArg, UpdateOldRecordArg, UpdateNewRecordArg, UpdateAllArg }; + } + + public override bool CheckTypes(CheckTypesContext context, TexlNode[] args, DType[] argTypes, IErrorContainer errors, out DType returnType, out Dictionary nodeToCoercedTypeMap) + { + Contracts.AssertValue(args); + Contracts.AssertAllValues(args); + Contracts.AssertValue(argTypes); + Contracts.Assert(args.Length == argTypes.Length); + Contracts.AssertValue(errors); + Contracts.Assert(MinArity <= args.Length && args.Length <= MaxArity); + + var fValid = base.CheckTypes(context, args, argTypes, errors, out returnType, out nodeToCoercedTypeMap); + var collectionType = argTypes[0]; + + if (!collectionType.IsTable) + { + errors.EnsureError(args[0], ErrNeedTable_Func, Name); + fValid = false; + } + + for (var i = 1; i <= 2; i++) + { + var argType = argTypes[i]; + if (!argType.IsRecord) + { + fValid = false; + errors.EnsureError(args[i], ErrNeedRecord, args[i]); + continue; + } + + if (!argType.CheckAggregateNames(collectionType, args[i], errors, context.Features, SupportsParamCoercion)) + { + fValid = false; + if (!SetErrorForMismatchedColumns(collectionType, argType, args[i], errors, context.Features)) + { + errors.EnsureError(DocumentErrorSeverity.Severe, args[i], ErrTableDoesNotAcceptThisType); + } + } + else if (SupportsParamCoercion && !collectionType.Accepts(argType.ToTable(), exact: true, useLegacyDateTimeAccepts: false, usePowerFxV1CompatibilityRules: context.Features.PowerFxV1CompatibilityRules)) + { + if (argType.TryGetCoercionSubType(collectionType.ToRecord(), out DType coercionType, out bool coercionNeeded, context.Features) && coercionNeeded) + { + CollectionUtils.Add(ref nodeToCoercedTypeMap, args[i], coercionType); + } + } + } + + if (args.Length == 4) + { + if (!DType.String.Accepts(argTypes[3], exact: true, useLegacyDateTimeAccepts: false, usePowerFxV1CompatibilityRules: context.Features.PowerFxV1CompatibilityRules) || + args[3] is not StrLitNode strNode || + strNode.Value.ToUpperInvariant() != "ALL") + { + fValid = false; + errors.EnsureError(args[3], ErrRemoveAllArg, args[3]); + } + } + + returnType = context.Features.PowerFxV1CompatibilityRules ? DType.Void : collectionType; + + return fValid; + } + + public override void CheckSemantics(TexlBinding binding, TexlNode[] args, DType[] argTypes, IErrorContainer errors) + { + base.CheckSemantics(binding, args, argTypes, errors); + base.ValidateArgumentIsMutable(binding, args[0], errors); + } + + public async Task InvokeAsync(FunctionInvokeInfo invokeInfo, CancellationToken cancellationToken) + { + var args = invokeInfo.Args; + var returnType = invokeInfo.ReturnType; + + var validArgs = CheckArgs(args, out FormulaValue faultyArg); + + if (!validArgs) + { + return faultyArg; + } + + var arg0 = args[0]; + if (arg0 is LambdaFormulaValue arg0lazy) + { + arg0 = await arg0lazy.EvalAsync().ConfigureAwait(false); + } + + if (arg0 is BlankValue) + { + return arg0; + } + + if (arg0 is not TableValue tableValue) + { + return arg0; + } + + if (args[1] is not RecordValue oldRecord) + { + return args[1]; + } + + if (args[2] is not RecordValue newRecord) + { + return args[2]; + } + + var updateAll = args.Count == 4 && args[3] is StringValue stringValue && stringValue.Value.ToUpperInvariant() == "ALL"; + var replacementRecord = await BuildReplacementRecordAsync(tableValue, newRecord, cancellationToken).ConfigureAwait(false); + var result = await tableValue.UpdateAsync(oldRecord, replacementRecord, updateAll, cancellationToken).ConfigureAwait(false); + + FormulaValue output; + if (result.IsError) + { + output = FormulaValue.NewError(result.Error.Errors, returnType == FormulaType.Void ? FormulaType.Void : FormulaType.Blank); + } + else + { + output = returnType == FormulaType.Void ? FormulaValue.NewVoid() : FormulaValue.NewBlank(); + } + + return output; + } + + private static async Task BuildReplacementRecordAsync(TableValue tableValue, RecordValue newRecord, CancellationToken cancellationToken) + { + var fields = new List(); + + foreach (var fieldName in tableValue.Type.FieldNames) + { + cancellationToken.ThrowIfCancellationRequested(); + FormulaValue value; + + if (newRecord.Type.FieldNames.Contains(fieldName, StringComparer.Ordinal)) + { + value = await newRecord.GetFieldAsync(fieldName, cancellationToken).ConfigureAwait(false); + } + else + { + value = FormulaValue.NewBlank(tableValue.Type.GetFieldType(fieldName)); + } + + fields.Add(new NamedValue(fieldName, value)); + } + + return FormulaValue.NewRecordFromFields(tableValue.Type.ToRecord(), fields); + } + } +} diff --git a/src/strings/PowerFxResources.en-US.resx b/src/strings/PowerFxResources.en-US.resx index d5e350d6d7..ec56280a89 100644 --- a/src/strings/PowerFxResources.en-US.resx +++ b/src/strings/PowerFxResources.en-US.resx @@ -3322,6 +3322,26 @@ If provided, last argument must be 'RemoveFlags.All'. Is there a typo? {Locked=RemoveFlags.All} Error Message, RemoveFlags.All is an enum value that does not get localized. + + Replaces a record in a data source. + Description of 'Update' function. + + + data_source + function_parameter - First parameter for the Update function. The data source that contains the record that you want to replace. Translate this string. When translating, maintain as a single word (i.e., do not add spaces). + + + old_record + function_parameter - Second parameter for the Update function. The record to replace. Translate this string. When translating, maintain as a single word (i.e., do not add spaces). + + + new_record + function_parameter - Third parameter for the Update function. The replacement record. Translate this string. When translating, maintain as a single word (i.e., do not add spaces). + + + all + function_parameter - Optional fourth parameter for the Update function. Indicates all matching records should be updated. Translate this string. When translating, maintain as a single word (i.e., do not add spaces). + Error Display text representing the Error value of NotificationType enum (NotificationType_Error_Name). This describes showing an error notification. The possible values for this enumeration are: Error, Warning, Success, Information. diff --git a/src/tests/Microsoft.PowerFx.Interpreter.Tests.Shared/MutationFunctionsTests.cs b/src/tests/Microsoft.PowerFx.Interpreter.Tests.Shared/MutationFunctionsTests.cs index 9f179ece1e..965913e745 100644 --- a/src/tests/Microsoft.PowerFx.Interpreter.Tests.Shared/MutationFunctionsTests.cs +++ b/src/tests/Microsoft.PowerFx.Interpreter.Tests.Shared/MutationFunctionsTests.cs @@ -538,6 +538,35 @@ public void AppendErrorTests() Assert.IsType(result); } + [Fact] + public async Task UpdateAllUpdatesEachPrimaryKeyMatchOnce() + { + var rows = new List() + { + new TestDatabaseRecordValue(1, "first", "old"), + new TestDatabaseRecordValue(1, "second", "old"), + new TestDatabaseRecordValue(2, "third", "old") + }; + + var table = new KeyedCollectionTableValue(rows); + var oldRecord = new TestDatabaseRecordValue(1, "ignored", "ignored"); + var replacementRecord = FormulaValue.NewRecordFromFields( + TestDatabaseRecordValue.CustomRecordType, + new NamedValue("Id", FormulaValue.New(1)), + new NamedValue("Name", FormulaValue.New("updated")), + new NamedValue("Val", FormulaValue.New("new"))); + + var result = await table.UpdateAsync(oldRecord, replacementRecord, all: true, CancellationToken.None); + + Assert.True(result.IsValue); + Assert.Equal("updated", rows[0].Name); + Assert.Equal("new", rows[0].Val); + Assert.Equal("updated", rows[1].Name); + Assert.Equal("new", rows[1].Val); + Assert.Equal("third", rows[2].Name); + Assert.Equal("old", rows[2].Val); + } + /// /// Meant to test PatchSingleRecordCoreAsync override. Only tables with primary key column are supported. /// @@ -622,6 +651,28 @@ public override Task> AppendAsync(RecordValue record, Cancel } } + internal class KeyedCollectionTableValue : CollectionTableValue + { + public KeyedCollectionTableValue(IList records) + : base(TestDatabaseRecordValue.CustomRecordType, records) + { + } + + protected override DValue Marshal(TestDatabaseRecordValue item) + { + return DValue.Of(item); + } + + protected override TestDatabaseRecordValue MarshalInverse(RecordValue row) + { + var id = Convert.ToInt32(((DecimalValue)row.GetField("Id")).Value); + var name = ((StringValue)row.GetField("Name")).Value; + var val = ((StringValue)row.GetField("Val")).Value; + + return new TestDatabaseRecordValue(id, name, val); + } + } + internal class FileObjectRecordValue : InMemoryRecordValue { public string SomeProperty { get; set; } diff --git a/src/tests/Microsoft.PowerFx.Interpreter.Tests.Shared/MutationScripts/Update_V1Compat.txt b/src/tests/Microsoft.PowerFx.Interpreter.Tests.Shared/MutationScripts/Update_V1Compat.txt new file mode 100644 index 0000000000..3886d162e0 --- /dev/null +++ b/src/tests/Microsoft.PowerFx.Interpreter.Tests.Shared/MutationScripts/Update_V1Compat.txt @@ -0,0 +1,22 @@ +#SETUP: PowerFxV1CompatibilityRules + +>> Set(list, Table({ Name: "One", ID: 1, Stock: 10}, { Name: "Two", ID: 2, Stock: 20})) +Table({ID:1,Name:"One",Stock:10},{ID:2,Name:"Two",Stock:20}) + +>> Update(list, First(list), { Name: "Uno", ID: 1}) +If(true, {test:1}, "Void value (result of the expression can't be used).") + +>> list +Table({ID:1,Name:"Uno",Stock:Blank()},{ID:2,Name:"Two",Stock:20}) + +>> Set(dupes, Table({ Name: "Same", ID: 1}, { Name: "Same", ID: 1}, { Name: "Other", ID: 2})) +Table({ID:1,Name:"Same"},{ID:1,Name:"Same"},{ID:2,Name:"Other"}) + +>> Update(dupes, { Name: "Same", ID: 1}, { Name: "Changed", ID: 9}, "ALL") +If(true, {test:1}, "Void value (result of the expression can't be used).") + +>> dupes +Table({ID:9,Name:"Changed"},{ID:9,Name:"Changed"},{ID:2,Name:"Other"}) + +>> Update(dupes, { Name: "Missing", ID: 3}, { Name: "Nope", ID: 0}) +Error({Kind:ErrorKind.NotFound}) diff --git a/src/tests/Microsoft.PowerFx.Interpreter.Tests.Shared/MutationScripts/Update_V1CompatDisabled.txt b/src/tests/Microsoft.PowerFx.Interpreter.Tests.Shared/MutationScripts/Update_V1CompatDisabled.txt new file mode 100644 index 0000000000..97f079eae2 --- /dev/null +++ b/src/tests/Microsoft.PowerFx.Interpreter.Tests.Shared/MutationScripts/Update_V1CompatDisabled.txt @@ -0,0 +1,22 @@ +#SETUP: disable:PowerFxV1CompatibilityRules + +>> Set(list, Table({ Name: "One", ID: 1, Stock: 10}, { Name: "Two", ID: 2, Stock: 20})) +Table({ID:1,Name:"One",Stock:10},{ID:2,Name:"Two",Stock:20}) + +>> Update(list, First(list), { Name: "Uno", ID: 1}) +Blank() + +>> list +Table({ID:1,Name:"Uno",Stock:Blank()},{ID:2,Name:"Two",Stock:20}) + +>> Set(dupes, Table({ Name: "Same", ID: 1}, { Name: "Same", ID: 1}, { Name: "Other", ID: 2})) +Table({ID:1,Name:"Same"},{ID:1,Name:"Same"},{ID:2,Name:"Other"}) + +>> Update(dupes, { Name: "Same", ID: 1}, { Name: "Changed", ID: 9}, "ALL") +Blank() + +>> dupes +Table({ID:9,Name:"Changed"},{ID:9,Name:"Changed"},{ID:2,Name:"Other"}) + +>> Update(dupes, { Name: "Missing", ID: 3}, { Name: "Nope", ID: 0}) +Error({Kind:ErrorKind.NotFound})