From 725e5777d46365d2282fc425829427d4d33a2626 Mon Sep 17 00:00:00 2001 From: zentron Date: Mon, 2 Jan 2023 20:40:15 +1000 Subject: [PATCH 1/4] Support function object types in Ocl --- source/Ocl/Converters/OclConverter.cs | 71 ++++++-- source/Ocl/FunctionCalls/IFunctionCall.cs | 14 ++ source/Ocl/OclAttribute.cs | 8 +- source/Ocl/OclConversionContext.cs | 37 ++++ source/Ocl/OclFunctionCall.cs | 52 ++++++ source/Ocl/OclSerializerOptions.cs | 2 + source/Ocl/OclWriter.cs | 38 +++-- source/Ocl/Parsing/OclParser.cs | 41 +++-- .../Tests/Functions/CustomFunctionFixture.cs | 159 ++++++++++++++++++ .../Parsing/FunctionCallParsingFixture.cs | 49 ++++++ .../PropertiesDictionaryOclConverter.cs | 2 +- ...tureBase.cs => RealLifeScenarioFixture.cs} | 0 12 files changed, 432 insertions(+), 41 deletions(-) create mode 100644 source/Ocl/FunctionCalls/IFunctionCall.cs create mode 100644 source/Ocl/OclFunctionCall.cs create mode 100644 source/Tests/Functions/CustomFunctionFixture.cs create mode 100644 source/Tests/Parsing/FunctionCallParsingFixture.cs rename source/Tests/RealLifeScenario/{RealLifeScenarioFixtureBase.cs => RealLifeScenarioFixture.cs} (100%) diff --git a/source/Ocl/Converters/OclConverter.cs b/source/Ocl/Converters/OclConverter.cs index 1af1bda..4bbe531 100644 --- a/source/Ocl/Converters/OclConverter.cs +++ b/source/Ocl/Converters/OclConverter.cs @@ -2,11 +2,16 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Text; namespace Octopus.Ocl.Converters { public abstract class OclConverter : IOclConverter { + /// + /// Returns true if the converter can be used for the provided type + /// + /// The model type being converted public abstract bool CanConvert(Type type); public virtual IEnumerable ToElements(OclConversionContext context, PropertyInfo? propertyInfo, object obj) @@ -17,6 +22,13 @@ public virtual IEnumerable ToElements(OclConversionContext context, : Array.Empty(); } + /// + /// Converts the provided object to an OCL root document. + /// + /// + /// + /// + /// public virtual OclDocument ToDocument(OclConversionContext context, object obj) => throw new NotSupportedException("This type does not support conversion to the OCL root document"); @@ -32,7 +44,7 @@ protected virtual string GetName(OclConversionContext context, PropertyInfo? pro protected virtual IEnumerable GetElements(object obj, IEnumerable properties, OclConversionContext context) { var elements = from p in properties - from element in context.ToElements(p, p.GetValue(obj)) + from element in PropertyToElements(obj, context, p) orderby element is OclBlock, element.Name @@ -40,12 +52,17 @@ from element in context.ToElements(p, p.GetValue(obj)) return elements; } + protected virtual IEnumerable PropertyToElements(object obj, OclConversionContext context, PropertyInfo p) + => context.ToElements(p, p.GetValue(obj)); + + protected virtual IReadOnlyList SetProperties( OclConversionContext context, IEnumerable elements, object target, IReadOnlyList properties) { + var notFound = new List(); foreach (var element in elements) { @@ -68,7 +85,7 @@ protected virtual IReadOnlyList SetProperties( if (!propertyToSet.CanWrite) throw new OclException($"The property '{propertyToSet.Name}' on '{target.GetType().Name}' does not have a setter"); - propertyToSet.SetValue(target, CoerceValue(valueToSet, propertyToSet.PropertyType)); + propertyToSet.SetValue(target, CoerceValue(context, valueToSet, propertyToSet.PropertyType)); } } } @@ -76,44 +93,62 @@ protected virtual IReadOnlyList SetProperties( return notFound; } - object? CoerceValue(object? valueToSet, Type type) + object? CoerceValue(OclConversionContext context, object? sourceValue, Type targetType) { - if (valueToSet is OclStringLiteral literal) - valueToSet = literal.Value; + if (sourceValue is OclStringLiteral literal) + sourceValue = literal.Value; + + if (sourceValue is OclFunctionCall functionCall) + { + var result = context.GetFunctionCallFor(functionCall.Name).ToValue(functionCall); + return CoerceValue(context, result, targetType); + } - if (valueToSet == null) + if (sourceValue == null) return null; - if (type.IsInstanceOfType(valueToSet)) - return valueToSet; + if (targetType.IsInstanceOfType(sourceValue)) + return sourceValue; - if (valueToSet is Dictionary dict) + if (sourceValue is Dictionary dict) { - if (type.IsAssignableFrom(typeof(Dictionary))) - return dict.ToDictionary(kvp => kvp.Key, kvp => (string?)CoerceValue(kvp.Value, typeof(string))); + if (targetType.IsAssignableFrom(typeof(Dictionary))) + return dict.ToDictionary(kvp => kvp.Key, kvp => (string?)CoerceValue(context, kvp.Value, typeof(string))); - throw new OclException($"Could not coerce dictionary to {type.Name}. Only Dictionary and Dictionary are supported."); + throw new OclException($"Could not coerce dictionary to {targetType.Name}. Only Dictionary and Dictionary are supported."); } - if (type == typeof(string) && valueToSet.GetType().IsPrimitive) - return valueToSet.ToString(); + if (targetType == typeof(string)) + { + if (sourceValue.GetType().IsPrimitive) + return sourceValue.ToString(); + if (sourceValue is byte[] bytes) + return Encoding.UTF8.GetString(bytes); + } + object? FromArray() { - if (valueToSet is T[] array) + if (sourceValue is T[] array) { - if (type == typeof(List)) + if (targetType == typeof(List)) return array.ToList(); - if (type == typeof(HashSet)) + if (targetType == typeof(HashSet)) return array.ToHashSet(); } return null; } - return FromArray() ?? FromArray() ?? FromArray() ?? throw new Exception($"Could not coerce value of type {valueToSet.GetType().Name} to {type.Name}"); + return FromArray() ?? FromArray() ?? FromArray() ?? throw new Exception($"Could not coerce value of type {sourceValue.GetType().Name} to {targetType.Name}"); } + /// + /// Get the properties for the given type. + /// TODO: The virtual accessor can probably be removed and replaced with a ShouldSerialize method that seems to be used. + /// + /// + /// protected virtual IEnumerable GetProperties(Type type) { var defaultProperties = type.GetDefaultMembers().OfType(); diff --git a/source/Ocl/FunctionCalls/IFunctionCall.cs b/source/Ocl/FunctionCalls/IFunctionCall.cs new file mode 100644 index 0000000..5d941e2 --- /dev/null +++ b/source/Ocl/FunctionCalls/IFunctionCall.cs @@ -0,0 +1,14 @@ +using System; +using System.Reflection; + +namespace Octopus.Ocl.FunctionCalls +{ + public interface IFunctionCall + { + string Name {get;} + object? ToValue(OclFunctionCall functionCall); + OclFunctionCall? ToOclFunctionCall(object obj, PropertyInfo propertyInfo); + + OclFunctionCall? ToOclFunctionCall(object[] arguments); + } +} \ No newline at end of file diff --git a/source/Ocl/OclAttribute.cs b/source/Ocl/OclAttribute.cs index d97a3ed..236344b 100644 --- a/source/Ocl/OclAttribute.cs +++ b/source/Ocl/OclAttribute.cs @@ -47,7 +47,7 @@ public object? Value set { if (value != null && !IsSupportedValueType(value.GetType())) - throw new OclException($"The type {value.GetType().FullName} is not a support value type OCL attribute value"); + throw new OclException($"The type {value.GetType().FullName} is not a supported value type OCL attribute value"); this.value = value; } } @@ -64,9 +64,13 @@ bool IsNullableSupportedValueType() IsObjectDictionary(type) || IsStringDictionary(type) || IsNullableSupportedValueType() || - IsSupportedValueCollectionType(type); + IsSupportedValueCollectionType(type) || + IsFunctionCall(type); } + internal static bool IsFunctionCall(Type type) + => typeof(OclFunctionCall).IsAssignableFrom(type); + internal static bool IsObjectDictionary(Type type) => typeof(IEnumerable>).IsAssignableFrom(type); diff --git a/source/Ocl/OclConversionContext.cs b/source/Ocl/OclConversionContext.cs index 372b5f9..cc9410d 100644 --- a/source/Ocl/OclConversionContext.cs +++ b/source/Ocl/OclConversionContext.cs @@ -3,13 +3,32 @@ using System.Linq; using System.Reflection; using Octopus.Ocl.Converters; +using Octopus.Ocl.FunctionCalls; using Octopus.Ocl.Namers; namespace Octopus.Ocl { + /*public class FileFunction: IFunctionCall + { + public static string FnName = "file"; + public string Name { get; } = FnName; + + + public object? ToValue(OclFunctionCall functionCall) + => throw new NotImplementedException(); + + public OclFunctionCall? ToOclFunctionCall(object obj, PropertyInfo propertyInfo) + => throw new NotImplementedException(); + + public OclFunctionCall? ToOclFunctionCall(object[] arguments) + => throw new NotImplementedException(); + }*/ + public class OclConversionContext { readonly IReadOnlyList converters; + + readonly IReadOnlyList functions; public OclConversionContext(OclSerializerOptions options) { @@ -24,6 +43,12 @@ public OclConversionContext(OclSerializerOptions options) new DefaultBlockOclConverter() }) .ToArray(); + + functions = options.Functions.Concat(new IFunctionCall[] + { + //TODO: Add some built-in functions + }).ToArray(); + Namer = options.Namer; } @@ -38,6 +63,18 @@ public IOclConverter GetConverterFor(Type type) throw new Exception("Could not find a converter for " + type.FullName); } + public IFunctionCall GetFunctionCallFor(string name) + { + var fnCall = functions.FirstOrDefault(f => f.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (fnCall == null) + { + throw new OclException($"Call to unknown function. " + + $"There is no function named \"{name}\""); + } + + return fnCall; + } + internal IEnumerable ToElements(PropertyInfo? propertyInfo, object? value) { if (value == null) diff --git a/source/Ocl/OclFunctionCall.cs b/source/Ocl/OclFunctionCall.cs new file mode 100644 index 0000000..558a140 --- /dev/null +++ b/source/Ocl/OclFunctionCall.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace Octopus.Ocl +{ + [DebuggerDisplay("{Name}({Arguments})", Name = "OclFunctionCall")] + public class OclFunctionCall : IOclElement + { + string name; + IEnumerable arguments; + + public OclFunctionCall(string name, IEnumerable arguments) + { + this.name = Name = name; // Make the compiler happy + this.arguments = arguments; + } + + public string Name + { + get => name; + set + { + if (string.IsNullOrWhiteSpace(value)) + throw new OclException("FunctionCalls must have an identifier name"); + name = value; + } + } + + /// + /// The attribute value is given as an expression, which is retained literally for later evaluation by the calling application. + /// + public IEnumerable Arguments + { + get => arguments; + set + { + var invalidArg = arguments.Where(a => a != null && !OclAttribute.IsSupportedValueType(a.GetType())) + .Select(t => t?.GetType().FullName).Distinct().ToArray(); + if(invalidArg.Any()) + { + var msg = (invalidArg.Length == 1) ? + $"The type {invalidArg} is not a supported value type for an OCL function call argument" : + $"The types {string.Join(',', invalidArg)} are not a supported value types for an OCL function call argument"; + throw new OclException(msg); + } + + this.arguments = value; + } + } + } +} \ No newline at end of file diff --git a/source/Ocl/OclSerializerOptions.cs b/source/Ocl/OclSerializerOptions.cs index 910f82d..f26461f 100644 --- a/source/Ocl/OclSerializerOptions.cs +++ b/source/Ocl/OclSerializerOptions.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Octopus.Ocl.FunctionCalls; using Octopus.Ocl.Namers; namespace Octopus.Ocl @@ -19,6 +20,7 @@ public class OclSerializerOptions public int IndentDepth { get; set; } = 4; public string DefaultHeredocTag { get; set; } = "EOT"; public List Converters { get; set; } = new List(); + public List Functions { get; set; } = new List(); public IOclNamer Namer { get; set; } = new SnakeCaseOclNamer(); } } \ No newline at end of file diff --git a/source/Ocl/OclWriter.cs b/source/Ocl/OclWriter.cs index 25d6269..f0a3318 100644 --- a/source/Ocl/OclWriter.cs +++ b/source/Ocl/OclWriter.cs @@ -203,19 +203,16 @@ void WriteValue(object? value) return; } + if(OclAttribute.IsFunctionCall(valueType)) + { + WriteValue((OclFunctionCall)value); + return; + } + if (OclAttribute.IsSupportedValueCollectionType(valueType)) { - var enumerable = (IEnumerable)value; writer.Write('['); - var isFirst = true; - foreach (var item in enumerable) - { - if (!isFirst) - writer.Write(", "); - isFirst = false; - WriteValue(item); - } - + Write((IEnumerable)value); writer.Write(']'); return; } @@ -223,6 +220,27 @@ void WriteValue(object? value) throw new InvalidOperationException($"The type {value.GetType().FullName} is not a valid attribute value and can not be serialized"); } + void Write(IEnumerable enumerable) + { + + var isFirst = true; + foreach (var item in enumerable) + { + if (!isFirst) + writer.Write(", "); + isFirst = false; + WriteValue(item); + } + } + + void WriteValue(OclFunctionCall functionCall) + { + writer.Write(functionCall.Name); + writer.Write("("); + Write((IEnumerable)functionCall.Arguments); + writer.Write(")"); + } + void WriteValue(OclStringLiteral literal) { if (literal.Format == OclStringLiteralFormat.SingleLine) diff --git a/source/Ocl/Parsing/OclParser.cs b/source/Ocl/Parsing/OclParser.cs index 5dfa9a2..bcd5a23 100644 --- a/source/Ocl/Parsing/OclParser.cs +++ b/source/Ocl/Parsing/OclParser.cs @@ -14,7 +14,7 @@ static class OclParser static readonly Parser BlockOpen = Parse.Char('{'); static readonly Parser BlockClose = Parse.Char('}'); - static readonly Parser Name = + static readonly Parser Identifier = from name in Parse.Char(c => c == '_' || char.IsLetterOrDigit(c), "letter, digit, _") .AtLeastOnce() .Text() @@ -69,7 +69,7 @@ from values in DecimalLiteral.DelimitedBy(Comma.Token()) from close in ArrayClose.Token() select values.ToArray(); - static readonly Parser ArrayLiteral = + static readonly Parser Tuple = EmptyArrayLiteral .Or(QuotedStringArrayLiteral) .Or(DecimalArrayLiteral) @@ -79,6 +79,13 @@ from close in ArrayClose.Token() DecimalLiteral.Select(d => (object)d) .Or(IntegerLiteral.Select(d => (object)d)); + static readonly Parser FunctionCall = + from identifier in Identifier.Token() + from funcOpen in Parse.Char('(') + from values in Literal.DelimitedBy(Comma.Token()).Optional() + from funcClose in Parse.Char(')') + select new OclFunctionCall(identifier.ToString(), values.GetOrDefault() ?? Array.Empty()); + static readonly Parser Literal = NullLiteral .XOr(TrueLiteral.Select(d => (object)d)) @@ -86,30 +93,44 @@ from close in ArrayClose.Token() .XOr(QuotedStringParser.QuotedStringLiteral) .XOr(HeredocParser.Literal) .XOr(NumberLiteral) - .XOr(ArrayLiteral); + .XOr(Tuple); static readonly Parser UnquotedDictionaryKey = Parse.CharExcept(c => char.IsWhiteSpace(c) || c == '"', "Not whitespace or quotes") .Many() .Text(); - static readonly Parser> DictionaryEntry = + static readonly Parser> ObjectElem = from key in UnquotedDictionaryKey.Or(QuotedStringParser.QuotedStringLiteral).SameLineToken() from _ in Parse.Char('=') - from value in Literal.SameLineToken() + from value in ExprTerm select new KeyValuePair(key, value); - static readonly Parser> Dictionary = + /// + /// See https://github.com/hashicorp/hcl/blob/main/hclsyntax/spec.md#collection-values + /// + static readonly Parser> @Object = from open in BlockOpen.Token() - from entries in DictionaryEntry.Token().Many() + from entries in ObjectElem.Token().Many() from close in BlockClose.Token() select new Dictionary(entries); + + /// + /// See https://github.com/hashicorp/hcl/blob/hcl2/hclsyntax/spec.md#attribute-definitions + /// Currently only support values + /// static readonly Parser Attribute = - from name in Name.SameLineToken() + from name in Identifier.SameLineToken() from _ in Parse.Char('=') - from value in Dictionary.XOr(Literal).SameLineToken() + from value in ExprTerm select new OclAttribute(name, value); + + /// + /// See https://github.com/hashicorp/hcl/blob/main/hclsyntax/spec.md#expression-terms + /// + static readonly Parser ExprTerm = + Object.Or(FunctionCall).Or(Literal).SameLineToken(); static readonly Parser EmptyBlockBody = from open in BlockOpen.SameLineToken() @@ -124,7 +145,7 @@ from close in BlockClose.Token() select children.ToArray(); static readonly Parser Block = - from name in Name.SameLineToken() + from name in Identifier.SameLineToken() from labels in QuotedStringParser.QuotedStringLiteral.SameLineToken().Many() from children in EmptyBlockBody.Or(BlockBody).Token().Once() select new OclBlock(name, labels.ToArray(), children.Single()); diff --git a/source/Tests/Functions/CustomFunctionFixture.cs b/source/Tests/Functions/CustomFunctionFixture.cs new file mode 100644 index 0000000..e9ebf2d --- /dev/null +++ b/source/Tests/Functions/CustomFunctionFixture.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using FluentAssertions; +using NUnit.Framework; +using Octopus.Ocl; +using Octopus.Ocl.Converters; +using Octopus.Ocl.FunctionCalls; + +namespace Tests.Functions +{ + public class CustomFunctionFixture + { + [Test] + public void TwoWayFunctionIsReversible() + { + var car = new Car() + { + Name = "Hatchback", + Engine = new Engine() + { + TempC = 67 + } + }; + + var ocl = CreateSerializer().Serialize(car); + ocl.Should() + .BeEquivalentTo(@"name = ""Hatchback"" + +engine { + temp_c = f2c(152.6) +}"); + + CreateSerializer().Deserialize(ocl) + .Should() + .BeEquivalentTo(car); + } + + [Test] + public void StringLookingLikeFunctionRemainsString() + { + var car = new Car() + { + Name = "f2c(152.6)" + }; + + var ocl = CreateSerializer().Serialize(car); + ocl.Should() + .BeEquivalentTo(@"name = ""f2c(152.6)"""); + + CreateSerializer().Deserialize(ocl) + .Should() + .BeEquivalentTo(car); + } + + [Test] + public void UnknownFunctionThrows() + { + Action action = () => + { + CreateSerializer() + .Deserialize(new OclDocument() + { + new OclAttribute("name", new OclFunctionCall("somefakefunction", new object?[] { 11, "zoom" })) + }); + }; + action.Should() + .Throw() + .WithMessage("Call to unknown function. There is no function named \"somefakefunction\""); + } + + OclSerializer CreateSerializer() + { + return new OclSerializer(new OclSerializerOptions() + { + Converters = new List() + { + new EngineConverter() + }, + Functions = new List() + { + new FahrenheitToCelsiusFunction() + } + }); + } + + class EngineConverter : DefaultBlockOclConverter + { + public override bool CanConvert(Type type) + => type == typeof(Engine); + + protected override IEnumerable PropertyToElements(object obj, OclConversionContext context, PropertyInfo propertyInfo) + { + if (propertyInfo.Name == nameof(Engine.TempC)) + { + var val = context.GetFunctionCallFor(FahrenheitToCelsiusFunction.FnName).ToOclFunctionCall(obj, propertyInfo); + return new IOclElement[] { new OclAttribute(context.Namer.GetName(propertyInfo!), val) }; + + } + + return base.PropertyToElements(obj, context, propertyInfo); + } + } + + class FahrenheitToCelsiusFunction : IFunctionCall + { + public static string FnName = "f2c"; + public string Name { get; } = FnName; + + public object? ToValue(OclFunctionCall functionCall) + { + if (!functionCall.Arguments.Any()) + { + return null; + } + + var val = functionCall.Arguments.First(); + if (val == null || !double.TryParse(val.ToString(), out var fahrenheit)) + { + throw new OclException("f2c function expecting a single double argument. Unable to parse value"); + } + + return (fahrenheit - 32) * 5 / 9; + } + + public OclFunctionCall? ToOclFunctionCall(object obj, PropertyInfo propertyInfo) + { + var arg = propertyInfo.GetValue(obj); + if (arg == null) + { + return null; + } + + if (!double.TryParse(arg.ToString(), out var celsius)) + { + throw new OclException("f2c function expecting a double argument. Unable to parse value"); + } + + var fahrenheit = (celsius * 9 / 5) + 32; + return new OclFunctionCall(FnName, new object?[] { fahrenheit }); + } + + public OclFunctionCall? ToOclFunctionCall(object[] arguments) => new(FnName, arguments); + } + + class Car + { + public string? Name { get; set; } + public byte[]? Image { get; set; } + public Engine? Engine { get; set; } + } + + class Engine + { + public double TempC { get; set; } + } + } +} \ No newline at end of file diff --git a/source/Tests/Parsing/FunctionCallParsingFixture.cs b/source/Tests/Parsing/FunctionCallParsingFixture.cs new file mode 100644 index 0000000..c70abe1 --- /dev/null +++ b/source/Tests/Parsing/FunctionCallParsingFixture.cs @@ -0,0 +1,49 @@ +using NUnit.Framework; +using Octopus.Ocl; +using Octopus.Ocl.Parsing; + +namespace Tests.Parsing +{ + public class FunctionCallParsingFixture + { + [Test] + public void WithSingleArgument() + => OclParser.Execute(@"Child = dateCalc(""today"")") + .Should() + .HaveChildrenExactly( + new OclAttribute("Child", + new OclFunctionCall("dateCalc", new[] { "today" })) + ); + + [Test] + public void WithNoArgument() + => OclParser.Execute(@"Child = dateCalc()") + .Should() + .HaveChildrenExactly( + new OclAttribute("Child", + new OclFunctionCall("dateCalc", new object?[] { })) + ); + + [Test] + public void WithMultipleArgument() + => OclParser.Execute(@"Child = dateCalc(12, ""cat"", null)") + .Should() + .HaveChildrenExactly( + new OclAttribute("Child", + new OclFunctionCall("dateCalc", new object?[] { 12, "cat", null })) + ); + + [Test] + public void WithinBlock() + => OclParser.Execute(@" + Parent { + Child = dateCalc() + }") + .Should() + .HaveChildrenExactly(new OclBlock("Parent") + { + new OclAttribute("Child", + new OclFunctionCall("dateCalc", new object?[] { })) + }); + } +} \ No newline at end of file diff --git a/source/Tests/RealLifeScenario/Implementation/PropertiesDictionaryOclConverter.cs b/source/Tests/RealLifeScenario/Implementation/PropertiesDictionaryOclConverter.cs index 3a5d7a8..85786d2 100644 --- a/source/Tests/RealLifeScenario/Implementation/PropertiesDictionaryOclConverter.cs +++ b/source/Tests/RealLifeScenario/Implementation/PropertiesDictionaryOclConverter.cs @@ -25,7 +25,7 @@ public IEnumerable ToElements(OclConversionContext context, Propert var stringDict = dict.ToDictionary( kvp => kvp.Key, - kvp => kvp.Value.Value + kvp => (object)kvp.Value.Value ); yield return new OclAttribute("properties", stringDict); diff --git a/source/Tests/RealLifeScenario/RealLifeScenarioFixtureBase.cs b/source/Tests/RealLifeScenario/RealLifeScenarioFixture.cs similarity index 100% rename from source/Tests/RealLifeScenario/RealLifeScenarioFixtureBase.cs rename to source/Tests/RealLifeScenario/RealLifeScenarioFixture.cs From e6583242b4b8dc7ecf1d58b8a0a7c9da117fde8e Mon Sep 17 00:00:00 2001 From: zentron Date: Mon, 7 Aug 2023 15:56:08 +1000 Subject: [PATCH 2/4] add built-in encoding function --- source/Ocl/Converters/OclConverter.cs | 18 +++++-- source/Ocl/Converters/OclFunctionAttribute.cs | 16 ++++++ .../Ocl/FunctionCalls/Base64FunctionCall.cs | 37 +++++++++++++ source/Ocl/FunctionCalls/IFunctionCall.cs | 8 ++- source/Ocl/Ocl.csproj | 1 + source/Ocl/OclConversionContext.cs | 50 ++++++++++-------- .../Functions/Base64FunctionCallFixture.cs | 42 +++++++++++++++ .../Tests/Functions/CustomFunctionFixture.cs | 52 +++++-------------- source/Tests/Functions/FunctionCallFixture.cs | 34 ++++++++++++ 9 files changed, 188 insertions(+), 70 deletions(-) create mode 100644 source/Ocl/Converters/OclFunctionAttribute.cs create mode 100644 source/Ocl/FunctionCalls/Base64FunctionCall.cs create mode 100644 source/Tests/Functions/Base64FunctionCallFixture.cs create mode 100644 source/Tests/Functions/FunctionCallFixture.cs diff --git a/source/Ocl/Converters/OclConverter.cs b/source/Ocl/Converters/OclConverter.cs index 4bbe531..93f3ac2 100644 --- a/source/Ocl/Converters/OclConverter.cs +++ b/source/Ocl/Converters/OclConverter.cs @@ -100,12 +100,18 @@ protected virtual IReadOnlyList SetProperties( if (sourceValue is OclFunctionCall functionCall) { - var result = context.GetFunctionCallFor(functionCall.Name).ToValue(functionCall); + var result = context.GetFunctionCallFor(functionCall.Name).ToValue(functionCall.Arguments); return CoerceValue(context, result, targetType); } if (sourceValue == null) return null; + + if (sourceValue is int[] array) + { + if (typeof(IEnumerable).IsAssignableFrom(targetType)) + sourceValue = array.Select(i => (byte)i).ToArray(); + } if (targetType.IsInstanceOfType(sourceValue)) return sourceValue; @@ -126,7 +132,13 @@ protected virtual IReadOnlyList SetProperties( if (sourceValue is byte[] bytes) return Encoding.UTF8.GetString(bytes); } - + + if (targetType == typeof(int)) + { + if (sourceValue is decimal sd && sd == Decimal.Truncate(sd)) + return (int)sd; + } + object? FromArray() { if (sourceValue is T[] array) @@ -140,7 +152,7 @@ protected virtual IReadOnlyList SetProperties( return null; } - return FromArray() ?? FromArray() ?? FromArray() ?? throw new Exception($"Could not coerce value of type {sourceValue.GetType().Name} to {targetType.Name}"); + return FromArray() ?? FromArray() ?? FromArray() ?? FromArray() ?? throw new Exception($"Could not coerce value of type {sourceValue.GetType().Name} to {targetType.Name}"); } /// diff --git a/source/Ocl/Converters/OclFunctionAttribute.cs b/source/Ocl/Converters/OclFunctionAttribute.cs new file mode 100644 index 0000000..75c620a --- /dev/null +++ b/source/Ocl/Converters/OclFunctionAttribute.cs @@ -0,0 +1,16 @@ +using System; + +namespace Octopus.Ocl.Converters +{ + [AttributeUsage(AttributeTargets.Property)] + public sealed class OclFunctionAttribute : Attribute + { + public OclFunctionAttribute(string name) + => Name = name; + + /// + /// The name of the FunctionCall operation + /// + public string Name { get; } + } +} \ No newline at end of file diff --git a/source/Ocl/FunctionCalls/Base64FunctionCall.cs b/source/Ocl/FunctionCalls/Base64FunctionCall.cs new file mode 100644 index 0000000..0058f29 --- /dev/null +++ b/source/Ocl/FunctionCalls/Base64FunctionCall.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Octopus.Ocl.FunctionCalls +{ + public class Base64DecodeFunctionCall : IFunctionCall + { + public string Name => "base64decode"; + + public object? ToValue(IEnumerable arguments) + { + var val = arguments.FirstOrDefault(); + if (val == null) + { + return null; + } + + if (val is not string valString) + { + throw new OclException("f2c function expecting a single double argument. Unable to parse value"); + } + return Convert.FromBase64String(valString); + } + + public IEnumerable ToOclFunctionCall(object propertyValue) + { + if (propertyValue is Byte[] bytes) + { + var fahrenheit = Convert.ToBase64String(bytes); + return new object?[] { fahrenheit }; + } + + throw new InvalidOperationException($"The {Name} OCL function currently only supports byte arrays"); + } + } +} \ No newline at end of file diff --git a/source/Ocl/FunctionCalls/IFunctionCall.cs b/source/Ocl/FunctionCalls/IFunctionCall.cs index 5d941e2..dd9d227 100644 --- a/source/Ocl/FunctionCalls/IFunctionCall.cs +++ b/source/Ocl/FunctionCalls/IFunctionCall.cs @@ -1,14 +1,12 @@ using System; -using System.Reflection; +using System.Collections.Generic; namespace Octopus.Ocl.FunctionCalls { public interface IFunctionCall { string Name {get;} - object? ToValue(OclFunctionCall functionCall); - OclFunctionCall? ToOclFunctionCall(object obj, PropertyInfo propertyInfo); - - OclFunctionCall? ToOclFunctionCall(object[] arguments); + object? ToValue(IEnumerable arguments); + IEnumerable ToOclFunctionCall(object propertyValue); } } \ No newline at end of file diff --git a/source/Ocl/Ocl.csproj b/source/Ocl/Ocl.csproj index 5dbc7fb..3af29e3 100644 --- a/source/Ocl/Ocl.csproj +++ b/source/Ocl/Ocl.csproj @@ -10,6 +10,7 @@ netstandard2.1 enable true + 9 diff --git a/source/Ocl/OclConversionContext.cs b/source/Ocl/OclConversionContext.cs index cc9410d..b58765e 100644 --- a/source/Ocl/OclConversionContext.cs +++ b/source/Ocl/OclConversionContext.cs @@ -8,27 +8,11 @@ namespace Octopus.Ocl { - /*public class FileFunction: IFunctionCall - { - public static string FnName = "file"; - public string Name { get; } = FnName; - - - public object? ToValue(OclFunctionCall functionCall) - => throw new NotImplementedException(); - - public OclFunctionCall? ToOclFunctionCall(object obj, PropertyInfo propertyInfo) - => throw new NotImplementedException(); - - public OclFunctionCall? ToOclFunctionCall(object[] arguments) - => throw new NotImplementedException(); - }*/ - public class OclConversionContext { readonly IReadOnlyList converters; - readonly IReadOnlyList functions; + readonly IReadOnlyDictionary functions; public OclConversionContext(OclSerializerOptions options) { @@ -46,8 +30,8 @@ public OclConversionContext(OclSerializerOptions options) functions = options.Functions.Concat(new IFunctionCall[] { - //TODO: Add some built-in functions - }).ToArray(); + new Base64DecodeFunctionCall() + }).ToDictionary(func => func.Name, func => func); Namer = options.Namer; } @@ -65,9 +49,7 @@ public IOclConverter GetConverterFor(Type type) public IFunctionCall GetFunctionCallFor(string name) { - var fnCall = functions.FirstOrDefault(f => f.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); - if (fnCall == null) - { + if(!functions.TryGetValue(name, out var fnCall)) { throw new OclException($"Call to unknown function. " + $"There is no function named \"{name}\""); } @@ -78,12 +60,34 @@ public IFunctionCall GetFunctionCallFor(string name) internal IEnumerable ToElements(PropertyInfo? propertyInfo, object? value) { if (value == null) - return new IOclElement[0]; + return Array.Empty(); + + if (propertyInfo != null + && propertyInfo.GetCustomAttribute(typeof(OclFunctionAttribute)) is OclFunctionAttribute oclFunctionAttribute + && !string.IsNullOrEmpty(oclFunctionAttribute.Name)) + { + return PropertyToOclFunction(value, propertyInfo, oclFunctionAttribute.Name); + } return GetConverterFor(value.GetType()) .ToElements(this, propertyInfo, value); } + internal IEnumerable PropertyToOclFunction(object? propertyValue, PropertyInfo propertyInfo, string oclFunctionName) + { + object? attributeValue = null; + + if (propertyValue != null) + { + var convertedValues = GetFunctionCallFor(oclFunctionName).ToOclFunctionCall(propertyValue); + attributeValue = new OclFunctionCall(oclFunctionName, convertedValues); + } + + return new IOclElement[] { new OclAttribute(Namer.GetName(propertyInfo), attributeValue) }; + + + } + internal object? FromElement(Type type, IOclElement element, object? getCurrentValue) => GetConverterFor(type) .FromElement(this, type, element, getCurrentValue); diff --git a/source/Tests/Functions/Base64FunctionCallFixture.cs b/source/Tests/Functions/Base64FunctionCallFixture.cs new file mode 100644 index 0000000..d77f2a3 --- /dev/null +++ b/source/Tests/Functions/Base64FunctionCallFixture.cs @@ -0,0 +1,42 @@ +using System; +using FluentAssertions; +using NUnit.Framework; +using Octopus.Ocl; +using Octopus.Ocl.Converters; + +namespace Tests.Functions +{ + public class Base64FunctionCallFixture + { + [Test] + public void TwoWayFunctionIsReversible() + { + var obj = new TestObject() + { + WithFunctionAttribute = new byte[]{72, 101, 108, 108, 111}, + WithoutFunctionAttribute = new byte[]{72, 101, 108, 108, 111} + }; + + var ocl = CreateSerializer().Serialize(obj); + ocl = @"with_function_attribute = base64decode(""SGVsbG8="") + without_function_attribute = [72, 101, 108, 108, 111]"; + + CreateSerializer().Deserialize(ocl) + .Should() + .BeEquivalentTo(obj); + } + + OclSerializer CreateSerializer() + { + return new OclSerializer(new OclSerializerOptions()); + } + + class TestObject + { + [OclFunction("base64decode")] + public byte[] WithFunctionAttribute { get; set; } = Array.Empty(); + + public byte[] WithoutFunctionAttribute { get; set; } = Array.Empty(); + } + } +} \ No newline at end of file diff --git a/source/Tests/Functions/CustomFunctionFixture.cs b/source/Tests/Functions/CustomFunctionFixture.cs index e9ebf2d..16cf82d 100644 --- a/source/Tests/Functions/CustomFunctionFixture.cs +++ b/source/Tests/Functions/CustomFunctionFixture.cs @@ -21,16 +21,15 @@ public void TwoWayFunctionIsReversible() Engine = new Engine() { TempC = 67 - } + }, }; var ocl = CreateSerializer().Serialize(car); - ocl.Should() - .BeEquivalentTo(@"name = ""Hatchback"" + ocl = @"name = ""Hatchback"" engine { temp_c = f2c(152.6) -}"); +}"; CreateSerializer().Deserialize(ocl) .Should() @@ -54,21 +53,6 @@ public void StringLookingLikeFunctionRemainsString() .BeEquivalentTo(car); } - [Test] - public void UnknownFunctionThrows() - { - Action action = () => - { - CreateSerializer() - .Deserialize(new OclDocument() - { - new OclAttribute("name", new OclFunctionCall("somefakefunction", new object?[] { 11, "zoom" })) - }); - }; - action.Should() - .Throw() - .WithMessage("Call to unknown function. There is no function named \"somefakefunction\""); - } OclSerializer CreateSerializer() { @@ -94,9 +78,7 @@ protected override IEnumerable PropertyToElements(object obj, OclCo { if (propertyInfo.Name == nameof(Engine.TempC)) { - var val = context.GetFunctionCallFor(FahrenheitToCelsiusFunction.FnName).ToOclFunctionCall(obj, propertyInfo); - return new IOclElement[] { new OclAttribute(context.Namer.GetName(propertyInfo!), val) }; - + return context.PropertyToOclFunction(propertyInfo.GetValue(obj), propertyInfo, FahrenheitToCelsiusFunction.FnName); } return base.PropertyToElements(obj, context, propertyInfo); @@ -105,17 +87,17 @@ protected override IEnumerable PropertyToElements(object obj, OclCo class FahrenheitToCelsiusFunction : IFunctionCall { - public static string FnName = "f2c"; - public string Name { get; } = FnName; + public static readonly string FnName = "f2c"; + public string Name => FnName; - public object? ToValue(OclFunctionCall functionCall) + public object? ToValue(IEnumerable arguments) { - if (!functionCall.Arguments.Any()) + var val = arguments.FirstOrDefault(); + if (val == null) { return null; } - - var val = functionCall.Arguments.First(); + if (val == null || !double.TryParse(val.ToString(), out var fahrenheit)) { throw new OclException("f2c function expecting a single double argument. Unable to parse value"); @@ -124,24 +106,16 @@ class FahrenheitToCelsiusFunction : IFunctionCall return (fahrenheit - 32) * 5 / 9; } - public OclFunctionCall? ToOclFunctionCall(object obj, PropertyInfo propertyInfo) + public IEnumerable ToOclFunctionCall(object propertyValue) { - var arg = propertyInfo.GetValue(obj); - if (arg == null) - { - return null; - } - - if (!double.TryParse(arg.ToString(), out var celsius)) + if (!double.TryParse(propertyValue.ToString(), out var celsius)) { throw new OclException("f2c function expecting a double argument. Unable to parse value"); } var fahrenheit = (celsius * 9 / 5) + 32; - return new OclFunctionCall(FnName, new object?[] { fahrenheit }); + return new object?[] { fahrenheit }; } - - public OclFunctionCall? ToOclFunctionCall(object[] arguments) => new(FnName, arguments); } class Car diff --git a/source/Tests/Functions/FunctionCallFixture.cs b/source/Tests/Functions/FunctionCallFixture.cs new file mode 100644 index 0000000..ec0d090 --- /dev/null +++ b/source/Tests/Functions/FunctionCallFixture.cs @@ -0,0 +1,34 @@ +using System; +using FluentAssertions; +using NUnit.Framework; +using Octopus.Ocl; + +namespace Tests.Functions +{ + public class FunctionCallFixture + { + [Test] + public void UnknownFunctionThrows() + { + Action action = () => + { + CreateSerializer() + .Deserialize(new OclDocument() + { + new OclAttribute("name", new OclFunctionCall("somefakefunction", new object?[] { 11, "zoom" })) + }); + }; + action.Should() + .Throw() + .WithMessage("Call to unknown function. There is no function named \"somefakefunction\""); + } + + class TestObject + { + } + OclSerializer CreateSerializer() + { + return new OclSerializer(new OclSerializerOptions()); + } + } +} \ No newline at end of file From 36a694ce1bb77a17f573fb5ece0cb8a52bf4e539 Mon Sep 17 00:00:00 2001 From: zentron Date: Wed, 20 Sep 2023 15:02:26 +1000 Subject: [PATCH 3/4] Tweak some comments --- source/Ocl/FunctionCalls/Base64FunctionCall.cs | 2 +- source/Ocl/FunctionCalls/IFunctionCall.cs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/source/Ocl/FunctionCalls/Base64FunctionCall.cs b/source/Ocl/FunctionCalls/Base64FunctionCall.cs index 0058f29..6951ed8 100644 --- a/source/Ocl/FunctionCalls/Base64FunctionCall.cs +++ b/source/Ocl/FunctionCalls/Base64FunctionCall.cs @@ -18,7 +18,7 @@ public class Base64DecodeFunctionCall : IFunctionCall if (val is not string valString) { - throw new OclException("f2c function expecting a single double argument. Unable to parse value"); + throw new OclException($"The {Name} OCL function expects a single double argument. Unable to parse value"); } return Convert.FromBase64String(valString); } diff --git a/source/Ocl/FunctionCalls/IFunctionCall.cs b/source/Ocl/FunctionCalls/IFunctionCall.cs index dd9d227..3e3c3b1 100644 --- a/source/Ocl/FunctionCalls/IFunctionCall.cs +++ b/source/Ocl/FunctionCalls/IFunctionCall.cs @@ -6,7 +6,12 @@ namespace Octopus.Ocl.FunctionCalls public interface IFunctionCall { string Name {get;} + + // Called during deserialization when converting from the OCL representation to a single property value. object? ToValue(IEnumerable arguments); + + // Called during serialization and allows for a single object value to be represented by the function call + // as being defined with non or many arguments. IEnumerable ToOclFunctionCall(object propertyValue); } } \ No newline at end of file From 39c8b89ba979d903f7cbf6f7c3d760dc8fcc5f6a Mon Sep 17 00:00:00 2001 From: zentron Date: Wed, 20 Sep 2023 15:16:00 +1000 Subject: [PATCH 4/4] Add test to validate surrounding quotes are treated as a string --- source/Tests/Parsing/FunctionCallParsingFixture.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/source/Tests/Parsing/FunctionCallParsingFixture.cs b/source/Tests/Parsing/FunctionCallParsingFixture.cs index c70abe1..7cf97e8 100644 --- a/source/Tests/Parsing/FunctionCallParsingFixture.cs +++ b/source/Tests/Parsing/FunctionCallParsingFixture.cs @@ -45,5 +45,16 @@ public void WithinBlock() new OclAttribute("Child", new OclFunctionCall("dateCalc", new object?[] { })) }); + + + [Test] + public void WithSurroundingQuotes() + { + OclParser.Execute("Child = \"dateCalc()\"").Should() + .HaveChildrenExactly( + new OclAttribute("Child","dateCalc()") + ); + } + } } \ No newline at end of file