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);