From 7e7f7fb1e9c3549f4a6613a0495fde866152b550 Mon Sep 17 00:00:00 2001 From: lbuesching <85355760+lbuesching@users.noreply.github.com> Date: Thu, 11 Jan 2024 13:36:31 -0500 Subject: [PATCH] Add URLENCODE, FORMATNUMBER, BUILDROWSETFROMSTRING (#26) * Add additional AMPscript functions Adds the following functions: URLENCODE - Modifies a string to only include characters that are safe to use in URLs. BUILDROWSETFROMSTRING - Creates a rowset from a character string by splitting the string at the specified delimiter. FORMATNUMBER - Formats a number as a numeric type, such as a decimal, date, or currency value. There is a known issue with FORMATNUMBER for rounding digits. Specifically: https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-numeric-format-strings On .NET Framework and .NET Core up to .NET Core 2.0, the runtime selects the result with the greater least significant digit (that is, using MidpointRounding.AwayFromZero). On .NET Core 2.1 and later, the runtime selects the result with an even least significant digit (that is, using MidpointRounding.ToEven). The in-production AMPscript implementation is on .net framework, this is .net 8. --- src/Sage.Engine.Tests/Corpus/CorpusLoader.cs | 8 +- .../Corpus/Function/http.txt | 41 +++++++- .../Corpus/Function/strings.txt | 45 ++++++++- .../Corpus/Function/utility.txt | 97 ++++++++++++++++++- .../Incompatible/Compatibility.ampscript | 23 ++++- src/Sage.Engine.Tests/EngineTestAttribute.cs | 4 +- .../Functions/FunctionTests.cs | 13 +-- src/Sage.Engine.Tests/TestUtils.cs | 4 +- .../CompatibleGlobalizationSettings.cs | 51 ++++++++-- src/Sage.Engine/Runtime/Functions/Content.cs | 12 +-- src/Sage.Engine/Runtime/Functions/Data.cs | 4 +- src/Sage.Engine/Runtime/Functions/DateTime.cs | 5 - src/Sage.Engine/Runtime/Functions/Http.cs | 79 +++++++++++++++ src/Sage.Engine/Runtime/Functions/String.cs | 30 ++++++ src/Sage.Engine/Runtime/Functions/Utility.cs | 47 ++++++++- src/Sage.Engine/Runtime/RuntimeContext.cs | 11 ++- 16 files changed, 429 insertions(+), 45 deletions(-) diff --git a/src/Sage.Engine.Tests/Corpus/CorpusLoader.cs b/src/Sage.Engine.Tests/Corpus/CorpusLoader.cs index 72d7b23..2beb802 100644 --- a/src/Sage.Engine.Tests/Corpus/CorpusLoader.cs +++ b/src/Sage.Engine.Tests/Corpus/CorpusLoader.cs @@ -120,8 +120,8 @@ private static IEnumerable GetTestsFromFileUsingHandmadeParser(strin testsFromFile.Add(new CorpusData(testName, code.ToString().Trim(), file, lineStart) { - Output = expectedOutput, - ParseTree = parseTree.ToString().Trim() + Output = expectedOutput.ReplaceLineEndings("\n"), + ParseTree = parseTree.ToString().Trim().ReplaceLineEndings("\n") }); parseTree = new StringBuilder(); @@ -182,8 +182,8 @@ private static IEnumerable GetTestsFromFileUsingHandmadeParser(strin expectedOutput = programOutput.ToString().Trim(); testsFromFile.Add(new CorpusData(testName, code.ToString().Trim(), file, lineStart) { - Output = expectedOutput, - ParseTree = parseTree.ToString().Trim() + Output = expectedOutput.ReplaceLineEndings("\n"), + ParseTree = parseTree.ToString().Trim().ReplaceLineEndings("\n") }); return testsFromFile; diff --git a/src/Sage.Engine.Tests/Corpus/Function/http.txt b/src/Sage.Engine.Tests/Corpus/Function/http.txt index c7b4c23..881dcb4 100644 --- a/src/Sage.Engine.Tests/Corpus/Function/http.txt +++ b/src/Sage.Engine.Tests/Corpus/Function/http.txt @@ -3,4 +3,43 @@ REDIRECTO ========== %%=REDIRECTTO("http://example.com")=%% ++++++++++ -http://example.com \ No newline at end of file +http://example.com +========== +URLENCODE("Hello World", false, false) +========== +%%=URLENCODE("Hello World")=%% +++++++++++ +Hello World +========== +URLENCODE("Hello World", false, false) +========== +%%=URLENCODE("Hello World", 0, 1)=%% +++++++++++ +Hello%20World +========== +URLENCODE Only Spaces As HTML +========== +%%=URLENCODE("http://example.com?query=Hello World,",false,false)=%% +++++++++++ +http://example.com?query=Hello%20World, +========== +URLENCODE Spaces Plus And All Others +========== +%%=URLENCODE("http://example.com?query=Hello World,",true,false)=%% +++++++++++ +http://example.com?query%3dHello+World%2c +========== +URLENCODE Unicode +========== +%%=URLEncode('Sample Text: サンプルテキスト', true, true)=%% +++++++++++ +Sample+Text%3a+%e3%82%b5%e3%83%b3%e3%83%97%e3%83%ab%e3%83%86%e3%82%ad%e3%82%b9%e3%83%88 +========== +URLENCODE AMPscript In URL +========== +%%[ + SET @url = "http://example.com?add=%%=ADD(1,2)=%%" +]%% +%%=URLEncode(@url)=%% +++++++++++ +http://example.com?add=3 \ No newline at end of file diff --git a/src/Sage.Engine.Tests/Corpus/Function/strings.txt b/src/Sage.Engine.Tests/Corpus/Function/strings.txt index 73bb6d9..b03f645 100644 --- a/src/Sage.Engine.Tests/Corpus/Function/strings.txt +++ b/src/Sage.Engine.Tests/Corpus/Function/strings.txt @@ -244,4 +244,47 @@ set @lowercase = "^[a-z]+$" 45678 abc -ABC \ No newline at end of file +ABC +========== +BUILDROWSETFROMSTRING +========== +%%[ +SET @STRING = "ONE|TWO|THREE|FOUR" +SET @ROWS=BUILDROWSETFROMSTRING(@STRING, "|") +SET @ROWCOUNT=ROWCOUNT(@ROWS) +]%% +%%=V(@ROWCOUNT)=%% +%%=V(FIELD(ROW(@ROWS, 1), 1))=%% +%%=V(FIELD(ROW(@ROWS, 2), 1))=%% +%%=V(FIELD(ROW(@ROWS, 3), 1))=%% +%%=V(FIELD(ROW(@ROWS, 4), 1))=%% +++++++++++ +4 +ONE +TWO +THREE +FOUR +========== +BUILDROWSETFROMSTRING bad row +========== +%%[ +SET @STRING = "ONE|TWO|THREE|FOUR" +SET @ROWS=BUILDROWSETFROMSTRING(@STRING, "|") +SET @ROWCOUNT=ROWCOUNT(@ROWS) +]%% +%%=V(@ROWCOUNT)=%% +%%=V(FIELD(ROW(@ROWS, 5), 1))=%% +++++++++++ +! +========== +BUILDROWSETFROMSTRING bad field +========== +%%[ +SET @STRING = "ONE|TWO|THREE|FOUR" +SET @ROWS=BUILDROWSETFROMSTRING(@STRING, "|") +SET @ROWCOUNT=ROWCOUNT(@ROWS) +]%% +%%=V(@ROWCOUNT)=%% +%%=V(FIELD(ROW(@ROWS, 1), 2))=%% +++++++++++ +! \ No newline at end of file diff --git a/src/Sage.Engine.Tests/Corpus/Function/utility.txt b/src/Sage.Engine.Tests/Corpus/Function/utility.txt index 6009e0f..710e31b 100644 --- a/src/Sage.Engine.Tests/Corpus/Function/utility.txt +++ b/src/Sage.Engine.Tests/Corpus/Function/utility.txt @@ -49,4 +49,99 @@ set @num = 12345 ]%% %%=Format(@num,"C", "Numeric")=%% ++++++++++ -$12,345.00 \ No newline at end of file +$12,345.00 +========== +GETSENDTIME matches NOW +========== +%%[ +SET @NOW1 = FORMAT(NOW(), "YYYY-MM-dd hh:mm") +SET @SENDTIME = FORMAT(GETSENDTIME(), "YYYY-MM-dd hh:mm") +SET @NOW2 = FORMAT(NOW(), "YYYY-MM-dd hh:mm") + +/* +This tests the time twice (now1 and now2) because if the test runs during a minute boundary, now1 may be :59 and SENDTIME might be :00. +By doing it before & after, it'll guarantee to catch a minute rollover. +*/ + + +IF (@NOW1 == @SENDTIME) THEN +]%% +PASS +%%[ +ELSEIF (@SENDTIME == @NOW2) THEN +]%% +PASS +%%[ +ELSE +]%% +FAIL +%%[ +ENDIF +]%% +++++++++++ +PASS +========== +GETSENDTIME(TRUE) matches NOW +========== +%%[ +SET @NOW1 = FORMAT(NOW(), "YYYY-MM-dd hh:mm") +SET @SENDTIME = FORMAT(GETSENDTIME(TRUE), "YYYY-MM-dd hh:mm") +SET @NOW2 = FORMAT(NOW(), "YYYY-MM-dd hh:mm") + +/* +This tests the time twice (now1 and now2) because if the test runs during a minute boundary, now1 may be :59 and SENDTIME might be :00. +By doing it before & after, it'll guarantee to catch a minute rollover. +*/ + +IF (@NOW1 == @SENDTIME) THEN +]%% +PASS +%%[ +ELSEIF (@SENDTIME == @NOW2) THEN +]%% +PASS +%%[ +ELSE +]%% +FAIL +%%[ +ENDIF +]%% +++++++++++ +PASS +========== +FORMATNUMBER Rounding +========== +%%=FormatNumber(123.4451,"N2")=%% +++++ +123.45 +========== +FORMATNUMBER Converting Strings to Numbers +========== +%%=FormatNumber("1234.56", "N")=%% +++++ +1,234.56 +========== +FORMATNUMBER Normalizing Numbers +========== +%%=FormatNumber("1,234.56","G","en_US")=%% +++++ +1234.56 +========== +FORMATNUMBER Localizing Numbers +========== +%%=FormatNumber("123.4451","C2","de_DE")=%% +++++ +123,45 € +========== +FORMATNUMBER Converting Numbers to Percentages +========== +%%[ + Set @val1 = 42 + Set @val2 = 326 + Set @pct = Divide(@val1, @val2) +]%% + +%%=FormatNumber(@pct, "P3")=%% +++++ +12.883% \ No newline at end of file diff --git a/src/Sage.Engine.Tests/Corpus/Incompatible/Compatibility.ampscript b/src/Sage.Engine.Tests/Corpus/Incompatible/Compatibility.ampscript index 0e9d514..63169de 100644 --- a/src/Sage.Engine.Tests/Corpus/Incompatible/Compatibility.ampscript +++ b/src/Sage.Engine.Tests/Corpus/Incompatible/Compatibility.ampscript @@ -35,4 +35,25 @@ set @result = DATEPARSE("2023-05-01 2:30PM-1:00", 0) ]%% %%=v(@result)=%% ++++++++++ -5/1/2023 11:30:00 AM \ No newline at end of file +5/1/2023 11:30:00 AM +========== +FORMATNUMBER Rounding +========== +%%[ +/* +https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-numeric-format-strings + +Specifically: +On .NET Framework and .NET Core up to .NET Core 2.0, the runtime selects the result with the greater least significant digit (that is, using MidpointRounding.AwayFromZero). +On .NET Core 2.1 and later, the runtime selects the result with an even least significant digit (that is, using MidpointRounding.ToEven). + +The existing implementation is on .net framework, this is .net core 2.1. + +This means that rounding will be different for situations like below. + +The AMPscript implementation is 123.45, AMPscript Core is 123.44 due to this difference. +*/ +]%% +%%=FormatNumber(123.445,"N2")=%% +++++ +123.44 \ No newline at end of file diff --git a/src/Sage.Engine.Tests/EngineTestAttribute.cs b/src/Sage.Engine.Tests/EngineTestAttribute.cs index 9029fe2..23866c8 100644 --- a/src/Sage.Engine.Tests/EngineTestAttribute.cs +++ b/src/Sage.Engine.Tests/EngineTestAttribute.cs @@ -99,7 +99,7 @@ public ParserTestAttribute(string baseDirectory) : base(baseDirectory) protected override object GetTestValidationData(CorpusData data) { - return new ParserTestResult(data.ParseTree); + return new ParserTestResult(data.ParseTree?.ReplaceLineEndings("\n") ?? string.Empty); } } @@ -114,7 +114,7 @@ public RuntimeTestAttribute(string baseDirectory) : base(baseDirectory) protected override object GetTestValidationData(CorpusData data) { - return new EngineTestResult(data.Output); + return new EngineTestResult(data.Output?.ReplaceLineEndings("\n") ?? string.Empty); } } } diff --git a/src/Sage.Engine.Tests/Functions/FunctionTests.cs b/src/Sage.Engine.Tests/Functions/FunctionTests.cs index eb8ca55..cb38f7b 100644 --- a/src/Sage.Engine.Tests/Functions/FunctionTests.cs +++ b/src/Sage.Engine.Tests/Functions/FunctionTests.cs @@ -3,6 +3,8 @@ // SPDX-License-Identifier: Apache-2.0 // For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/Apache-2.0 +using MarketingCloudIntegration.Render; + namespace Sage.Engine.Tests.Functions { using NUnit.Framework; @@ -23,23 +25,18 @@ public EngineTestResult TestFunctionsFromScriptCode(CorpusData test) [Category("Compatibility")] public EngineTestResult TestFunctionsFromScriptCodeCompatibility(CorpusData test) { - var result = TestUtils.GetOutputFromTest(_serviceProvider, test); - Assert.That(result.Output, Is.EqualTo(test.Output)); - if (test.Output == "!") { Assert.Throws(() => TestCompatibility(test.Code, test.SubscriberContext?.GetAttributes()).Wait() ); + return new EngineTestResult("!"); } else { - var compatibilityResult = TestCompatibility(test.Code, test.SubscriberContext?.GetAttributes()).Result; - - Assert.That(compatibilityResult.renderedContent?.Trim().ReplaceLineEndings("\n"), Is.EqualTo(test.Output?.ReplaceLineEndings("\n"))); + RenderResponse compatibilityResult = TestCompatibility(test.Code, test.SubscriberContext?.GetAttributes()).Result; + return new EngineTestResult(compatibilityResult.renderedContent?.Trim().ReplaceLineEndings("\n")); } - - return result; } } } diff --git a/src/Sage.Engine.Tests/TestUtils.cs b/src/Sage.Engine.Tests/TestUtils.cs index 220df30..30bbb4a 100644 --- a/src/Sage.Engine.Tests/TestUtils.cs +++ b/src/Sage.Engine.Tests/TestUtils.cs @@ -22,7 +22,7 @@ public static ParserTestResult GetParseResultFromTest(CorpusData test) ParserTestResult result; IParseTree parseTree = AntlrParser.Parse(test.Code, Console.Out, Console.Error); - string serializedTree = AntlrParser.SerializeTree(parseTree); + string serializedTree = AntlrParser.SerializeTree(parseTree).ReplaceLineEndings("\n"); result = new ParserTestResult(serializedTree); File.WriteAllText(Path.Combine(TestContext.CurrentContext.WorkDirectory, $"{test.FileFriendlyName}_expected.txt"), @@ -52,7 +52,7 @@ public static EngineTestResult GetOutputFromTest(IServiceProvider serviceProvide try { - var engineResult = new EngineTestResult(CSharpCompiler.CompileAndExecute(options, GetTestRuntimeContext(serviceProvider, options, test), out CompileResult compileResult).Trim()); + var engineResult = new EngineTestResult(CSharpCompiler.CompileAndExecute(options, GetTestRuntimeContext(serviceProvider, options, test), out CompileResult compileResult).ReplaceLineEndings("\n").Trim()); File.WriteAllText(Path.Combine(TestContext.CurrentContext.WorkDirectory, $"{test.FileFriendlyName}_transpiled.cs"), compileResult.TranspiledSource); diff --git a/src/Sage.Engine/Globalization/CompatibleGlobalizationSettings.cs b/src/Sage.Engine/Globalization/CompatibleGlobalizationSettings.cs index 83b42d1..5b3dbc8 100644 --- a/src/Sage.Engine/Globalization/CompatibleGlobalizationSettings.cs +++ b/src/Sage.Engine/Globalization/CompatibleGlobalizationSettings.cs @@ -9,23 +9,62 @@ namespace Sage.Engine.Runtime { public static class CompatibleGlobalizationSettings { + /// + /// AMPscript supports both "en-us" AND "en_us". In order to quickly support this lookup, a dictionary of names->cultures is made and populated + /// on demand. + /// + private static readonly Lazy> s_cultureLookup = new(CacheCultureNameLookup, LazyThreadSafetyMode.PublicationOnly); + + /// + /// Caches a lookup of name->culture, along with any modifications that are needed to be compatible with the existing language. + /// + /// This cache is populated at first access. + /// + private static Dictionary CacheCultureNameLookup() + { + CultureInfo[] validCultures = CultureInfo.GetCultures(CultureTypes.AllCultures); + var returnDictionary = new Dictionary(validCultures.Length, StringComparer.InvariantCultureIgnoreCase); + foreach (CultureInfo culture in validCultures) + { + CultureInfo usedCulture = GenerateCompatibleCulture(culture); + returnDictionary[usedCulture.Name] = usedCulture; + + if (usedCulture.Name.IndexOf("-", StringComparison.Ordinal) > 0) + { + returnDictionary[usedCulture.Name.Replace("-", "_")] = usedCulture; + } + } + + return returnDictionary; + } + /// /// There will be many differences between linux, mac, windows for globalization settings. /// Example, see: https://github.com/dotnet/runtime/issues/18345 /// In that issue, there is no desire to support common settings between different OSs. /// In order to maintain compatibility with existing code, the culture infos are hardcoded. - /// For now, just support en-US. In the future, a more robust test stratgy and validation needs created. + /// For now, just support en-US. In the future, a more robust test strategy and validation needs created. /// - public static CultureInfo GetCulture(string culture) + private static CultureInfo GenerateCompatibleCulture(CultureInfo culture) { - CultureInfo returnCulture = (CultureInfo)(new CultureInfo(culture, false)).Clone(); - - if (culture.ToLower() == "en-us") + if (culture.Name.ToLower() == "en-us") { + var returnCulture = (CultureInfo)culture.Clone(); returnCulture.DateTimeFormat.ShortDatePattern = "M/d/yyyy"; + returnCulture.NumberFormat.NumberDecimalDigits = 2; + + return returnCulture; } - return returnCulture; + return culture; + } + + /// + /// Obtains the compatible CultureInfo from the given culture name + /// + public static CultureInfo GetCulture(string culture) + { + return s_cultureLookup.Value[culture]; } } } \ No newline at end of file diff --git a/src/Sage.Engine/Runtime/Functions/Content.cs b/src/Sage.Engine/Runtime/Functions/Content.cs index b17da61..9072527 100644 --- a/src/Sage.Engine/Runtime/Functions/Content.cs +++ b/src/Sage.Engine/Runtime/Functions/Content.cs @@ -73,7 +73,7 @@ public string CONTENTAREA(object contentAreaId, object? impressionRegionName = n string id = this.ThrowIfStringNullOrEmpty(contentAreaId); string? executionResults = - CompileAndExecuteEmbeddedCodeAsync($"contentareaid__{id}", + CompileAndExecuteEmbeddedCode($"contentareaid__{id}", () => GetClassicContentClient().GetContentById(this.ThrowIfStringNullOrEmpty(id))); return ReturnContentBasedOnInput(executionResults, id, throwIfNotFound, defaultContent, success); @@ -94,7 +94,7 @@ public string CONTENTAREABYNAME(object contentAreaName, object? impressionRegion string id = this.ThrowIfStringNullOrEmpty(contentAreaName); string? executionResults = - CompileAndExecuteEmbeddedCodeAsync($"contentareaname__{id}", + CompileAndExecuteEmbeddedCode($"contentareaname__{id}", () => GetClassicContentClient().GetContentByName(this.ThrowIfStringNullOrEmpty(id))); return ReturnContentBasedOnInput(executionResults, id, throwIfNotFound, defaultContent, success); @@ -115,7 +115,7 @@ public string CONTENTBLOCKBYNAME(object contentBlockName, object? impressionRegi string id = this.ThrowIfStringNullOrEmpty(contentBlockName); string? executionResults = - CompileAndExecuteEmbeddedCodeAsync($"contentblockname__{id}", + CompileAndExecuteEmbeddedCode($"contentblockname__{id}", () => GetContentBuilderContentClient().GetContentByName(this.ThrowIfStringNullOrEmpty(id))); return ReturnContentBasedOnInput(executionResults, id, throwIfNotFound, defaultContent, success); @@ -136,7 +136,7 @@ public string CONTENTBLOCKBYID(object contentBlockId, object? impressionRegionNa string id = this.ThrowIfStringNullOrEmpty(contentBlockId); string? executionResults = - CompileAndExecuteEmbeddedCodeAsync($"contentblockid__{id}", + CompileAndExecuteEmbeddedCode($"contentblockid__{id}", () => GetContentBuilderContentClient().GetContentById(this.ThrowIfStringNullOrEmpty(id))); return ReturnContentBasedOnInput(executionResults, id, throwIfNotFound, defaultContent, success); @@ -157,7 +157,7 @@ public string CONTENTBLOCKBYKEY(object contentBlockKey, object? impressionRegion string id = this.ThrowIfStringNullOrEmpty(contentBlockKey); string? executionResults = - CompileAndExecuteEmbeddedCodeAsync($"contentblockkey__{id}", + CompileAndExecuteEmbeddedCode($"contentblockkey__{id}", () => GetContentBuilderContentClient().GetContentByCustomerKey(this.ThrowIfStringNullOrEmpty(id))); return ReturnContentBasedOnInput(executionResults, id, throwIfNotFound, defaultContent, success); @@ -177,7 +177,7 @@ public string TREATASCONTENT(object? content) return contentString; } - return CompileAndExecuteEmbeddedCodeAsync($"treatascontent__{_stackFrame.Peek().CurrentLineNumber}", contentString) ?? string.Empty; + return CompileAndExecuteEmbeddedCode($"treatascontent", contentString) ?? string.Empty; } /// diff --git a/src/Sage.Engine/Runtime/Functions/Data.cs b/src/Sage.Engine/Runtime/Functions/Data.cs index 624ba39..911799e 100644 --- a/src/Sage.Engine/Runtime/Functions/Data.cs +++ b/src/Sage.Engine/Runtime/Functions/Data.cs @@ -166,7 +166,7 @@ public object FIELD( if (SageValue.TryToInt(index, out int intResult) != SageValue.UnboxResult.Fail) { - if (intResult > dataRow.Table?.Columns?.Count) + if (intResult <= dataRow.Table?.Columns?.Count) { returnResult = dataRow[intResult - 1]; } @@ -178,7 +178,7 @@ public object FIELD( if (returnResult == null && SageValue.ToBoolean(missingIsFailure)) { - throw new RuntimeException("Invalid attribute name passed to the FIELD function. No attribute exists.", this); + throw new RuntimeException($"Invalid attribute passed to the FIELD function. No attribute exists at index {index}", this); } return returnResult ?? string.Empty; diff --git a/src/Sage.Engine/Runtime/Functions/DateTime.cs b/src/Sage.Engine/Runtime/Functions/DateTime.cs index 6bc9ddb..5031e09 100644 --- a/src/Sage.Engine/Runtime/Functions/DateTime.cs +++ b/src/Sage.Engine/Runtime/Functions/DateTime.cs @@ -19,11 +19,6 @@ public partial class RuntimeContext /// public DateTimeOffset NOW(object? useSendTimeStarted = null) { - if (useSendTimeStarted != null) - { - throw new NotImplementedException("useSendTimeStarted is not implemented"); - } - // TODO: Use CST without daylight savings return DateTimeOffset.Now; } diff --git a/src/Sage.Engine/Runtime/Functions/Http.cs b/src/Sage.Engine/Runtime/Functions/Http.cs index 0908488..7f1a8ea 100644 --- a/src/Sage.Engine/Runtime/Functions/Http.cs +++ b/src/Sage.Engine/Runtime/Functions/Http.cs @@ -3,6 +3,8 @@ // SPDX-License-Identifier: Apache-2.0 // For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/Apache-2.0 +using System.Web; + namespace Sage.Engine.Runtime { public partial class RuntimeContext @@ -15,5 +17,82 @@ public string REDIRECTTO(object? url) { return url?.ToString() ?? string.Empty; } + + /// + /// Modifies a string to only include characters that are safe to use in URLs. + /// + /// The string to convert to a format that is safe to include in URLs. + /// + /// If true, the function converts all spaces and non-ASCII characters in a URL parameter string to their hexadecimal character codes. + /// + /// If false, the function only converts spaces in a URL parameter string to the hexadecimal character code %20, and leaves other characters unchanged. + /// + /// The default value is false. + /// + /// If true, the function converts any text string that you pass as the first parameter into a version that is safe to use in URLs. + /// + /// If false, the function only converts a string into a URL-safe version if the unsafe characters are part of a URL parameter string. + /// + /// The default value is false. + public string URLENCODE(object urlToEncode, object? encodeAllCharacters = null, object? encodeAllStrings = null) + { + bool boolEncodeAllCharacters = false; + + string urlString = urlToEncode?.ToString() ?? string.Empty; + if (string.IsNullOrWhiteSpace(urlString)) + { + return urlString; + } + + if (encodeAllCharacters != null) + { + boolEncodeAllCharacters = SageValue.ToBoolean(encodeAllCharacters); + } + + bool boolEncodeAllStrings = false; + if (encodeAllStrings != null) + { + boolEncodeAllStrings = SageValue.ToBoolean(encodeAllStrings); + } + + string renderedUrlResults = CompileAndExecuteEmbeddedCode("urlencode", urlString) ?? string.Empty; + + string prefixPart = string.Empty; + string partToEncode = string.Empty; + + if (boolEncodeAllStrings) + { + // No prefix, encoding all strings + partToEncode = renderedUrlResults; + } + else + { + int queryStringStart = renderedUrlResults.IndexOf('?'); + + if (queryStringStart > 0) + { + prefixPart = renderedUrlResults.Substring(0, queryStringStart + 1); + partToEncode = renderedUrlResults.Substring(queryStringStart + 1); + } + else + { + prefixPart = renderedUrlResults; + // No part to encode, no query string + } + } + + string encodedString = null; + + if (boolEncodeAllCharacters) + { + encodedString = HttpUtility.UrlEncode(partToEncode); + } + else + { + encodedString = partToEncode.Replace(" ", "%20"); + } + + return prefixPart + encodedString; + } } } diff --git a/src/Sage.Engine/Runtime/Functions/String.cs b/src/Sage.Engine/Runtime/Functions/String.cs index da4b6d3..7287016 100644 --- a/src/Sage.Engine/Runtime/Functions/String.cs +++ b/src/Sage.Engine/Runtime/Functions/String.cs @@ -3,6 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 // For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/Apache-2.0 +using System.Data; using System.Text; using System.Text.RegularExpressions; @@ -280,6 +281,35 @@ public string REPLACELIST(object subject, object replace, params object[] search return ReplaceWithCaseInsensitiveSearch(subjectString, replaceString, searchStrings); } + /// + /// Creates a rowset from a character string by splitting the string at the specified delimiter. + /// + /// A string that contains the data to load into a rowset. + /// The character (such as a comma) that is used as a delimiter in the source data. + /// The FIELD must always be accessed from the "1" index. + /// A datatable representing the rowset to be used by ROWCOUNT, ROW and FIELD functions. + public DataTable BUILDROWSETFROMSTRING(object? sourceData, object? delimiter) + { + string sourceDataString = sourceData?.ToString() ?? string.Empty; + string delimiterString = delimiter?.ToString() ?? string.Empty; + + var dataTable = new DataTable(); + dataTable.Columns.Add(new DataColumn("Value", typeof(string))); + + if (!string.IsNullOrEmpty(sourceDataString)) + { + // Just split the string and add a row to the data table for each of the resulting strings + string[] split = sourceDataString.Split(delimiterString, StringSplitOptions.None); + foreach (string str in split) + { + dataTable.Rows.Add(new object[] { str }); + } + dataTable.AcceptChanges(); + } + + return dataTable; + } + /// /// Replaces the passed in string with the replacement string in a case-insensitive manner. /// diff --git a/src/Sage.Engine/Runtime/Functions/Utility.cs b/src/Sage.Engine/Runtime/Functions/Utility.cs index 9b9578b..4d7386d 100644 --- a/src/Sage.Engine/Runtime/Functions/Utility.cs +++ b/src/Sage.Engine/Runtime/Functions/Utility.cs @@ -36,7 +36,7 @@ public string FORMAT(object subject, object formatStringObject, object? type = n string? cultureString = culture.ToString(); if (cultureString != null) { - cultureInfo = new CultureInfo(cultureString); + cultureInfo = CompatibleGlobalizationSettings.GetCulture(cultureString); } } @@ -64,6 +64,40 @@ public string FORMAT(object subject, object formatStringObject, object? type = n } } + /// + /// Formats a number as a numeric type, such as a decimal, date, or currency value. + /// + /// You can also use this function to convert numbers stored in strings to a number data type, and to round numbers to a certain number of decimal places. + /// + /// The number that you want to format. This function assumes that the input number uses a period (.) as a decimal separator. + /// + /// The number type to convert the number to. Accepted values: + /// + /// C - Formats the number as a currency value. + /// D - Formats the number as a decimal number. + /// E - Formats the number using scientific notation. + /// F - Formats the number to a fixed number of decimal places (two decimal places by default). + /// G - Formats the number without thousands separators. + /// N - Formats the number with thousands separators. + /// P - Formats the number as a percentage. + /// R - Round-trip (format ensures value parsed to string can be parsed back to numeric value) + /// X - Formats the number as a hexadecimal value. + /// You can optionally follow this code with a number to indicate the precision of the number. For example, a currency value with two decimal places uses the parameter C2. + /// + /// A POSIX locale code, such as en_US or zh-TW. When you provide this value, the resulting number is formatted using patterns that suit the specified locale. + public string FORMATNUMBER(object? number, object? formatType, object? culutreCode = null) + { + if (SageValue.TryToDouble(number, out double numberDouble) == SageValue.UnboxResult.Fail) + { + return string.Empty; + } + + string formatTypeString = this.ThrowIfStringNullOrEmpty(formatType); + CultureInfo cultureInfo = CompatibleGlobalizationSettings.GetCulture(culutreCode?.ToString() ?? "en_US"); + + return numberDouble.ToString(formatTypeString, cultureInfo); + } + /// /// Similar to a ternary operator - evaluates and if it evaluates to true, returns . Otherwise, returns /// @@ -132,5 +166,16 @@ public string V(object? data) return GetSubscriberContext().GetAttribute(attributeNameString); } + + /// + /// Returns a timestamp for the beginning or end of a list, data extension (DE), or manual send at the job or individual subscriber level. + /// + /// For now, this only returns NOW() since there is no send that's part of this context. + /// + /// Ignored + public DateTimeOffset GETSENDTIME(object? useSendTimeStarted = null) + { + return NOW(useSendTimeStarted); + } } } diff --git a/src/Sage.Engine/Runtime/RuntimeContext.cs b/src/Sage.Engine/Runtime/RuntimeContext.cs index 1cf6a6b..3f6ac74 100644 --- a/src/Sage.Engine/Runtime/RuntimeContext.cs +++ b/src/Sage.Engine/Runtime/RuntimeContext.cs @@ -203,12 +203,13 @@ public SubscriberContext GetSubscriberContext() return _subscriberContext; } - internal string? CompileAndExecuteEmbeddedCodeAsync(string id, string code) + internal string? CompileAndExecuteEmbeddedCode(string id, string code) { + id = $"{_stackFrame.Peek().Name}__{id}__{_stackFrame.Peek().CurrentLineNumber}"; CompilerOptionsBuilder fromCodeString = new CompilerOptionsBuilder(_rootCompilationOptions).WithSourceCode(id, code); - return CompileAndExecuteEmbeddedCodeAsync(fromCodeString.Build(), code); + return CompileAndExecuteEmbeddedCode(fromCodeString.Build(), code); } /// @@ -223,7 +224,7 @@ public SubscriberContext GetSubscriberContext() /// of content retrieval. /// /// The result of executing the code - internal string? CompileAndExecuteEmbeddedCodeAsync(string id, Func getCode) + internal string? CompileAndExecuteEmbeddedCode(string id, Func getCode) { FileInfo? code = getCode(); if (code == null) @@ -234,10 +235,10 @@ public SubscriberContext GetSubscriberContext() CompilerOptionsBuilder fromFileOptions = new CompilerOptionsBuilder(_rootCompilationOptions).WithInputFile(code); - return CompileAndExecuteEmbeddedCodeAsync(fromFileOptions.Build(), code); + return CompileAndExecuteEmbeddedCode(fromFileOptions.Build(), code); } - internal string? CompileAndExecuteEmbeddedCodeAsync(CompilationOptions currentOptions, object fileInfoOrString) + internal string? CompileAndExecuteEmbeddedCode(CompilationOptions currentOptions, object fileInfoOrString) { CompileResult compileResult = CSharpCompiler.GenerateAssemblyFromSource(currentOptions);