using System.Collections; using System.Runtime.Serialization; using Umbraco.Cms.Core.Collections; using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Extensions; namespace Umbraco.Cms.Core.Models; /// /// Represents a property. /// [Serializable] [DataContract(IsReference = true)] public class Property : EntityBase, IProperty { private static readonly DelegateEqualityComparer PropertyValueComparer = new( (o, o1) => { if (o == null && o1 == null) { return true; } // custom comparer for strings. // if one is null and another is empty then they are the same if (o is string || o1 is string) { return ((o as string).IsNullOrWhiteSpace() && (o1 as string).IsNullOrWhiteSpace()) || (o != null && o1 != null && o.Equals(o1)); } if (o == null || o1 == null) { return false; } // custom comparer for enumerable // ReSharper disable once MergeCastWithTypeCheck if (o is IEnumerable && o1 is IEnumerable enumerable) { return ((IEnumerable)o).Cast().UnsortedSequenceEqual(enumerable.Cast()); } return o.Equals(o1); }, o => o!.GetHashCode()); // _pvalue contains the invariant-neutral property value private IPropertyValue? _pvalue; // _values contains all property values, including the invariant-neutral value private List _values = new(); // _vvalues contains the (indexed) variant property values private Dictionary? _vvalues; /// /// Initializes a new instance of the class. /// public Property(IPropertyType propertyType) => PropertyType = propertyType; /// /// Initializes a new instance of the class. /// public Property(int id, IPropertyType propertyType) { Id = id; PropertyType = propertyType; } /// /// Returns the PropertyType, which this Property is based on /// [IgnoreDataMember] public IPropertyType PropertyType { get; private set; } /// /// Gets the list of values. /// [DataMember] public IReadOnlyCollection Values { get => _values; set { // make sure we filter out invalid variations // make sure we leave _vvalues null if possible _values = value.Where(x => PropertyType?.SupportsVariation(x.Culture, x.Segment) ?? false).ToList(); _pvalue = _values.FirstOrDefault(x => x.Culture == null && x.Segment == null); _vvalues = _values.Count > (_pvalue == null ? 0 : 1) ? _values.Where(x => x != _pvalue) .ToDictionary(x => new CompositeNStringNStringKey(x.Culture, x.Segment), x => x) : null; } } /// /// Returns the Alias of the PropertyType, which this Property is based on /// [DataMember] public string Alias => PropertyType.Alias; /// /// Returns the Id of the PropertyType, which this Property is based on /// [IgnoreDataMember] public int PropertyTypeId => PropertyType.Id; /// /// Returns the DatabaseType that the underlaying DataType is using to store its values /// /// /// Only used internally when saving the property value. /// [IgnoreDataMember] public ValueStorageType ValueStorageType => PropertyType.ValueStorageType; /// /// Creates a new instance for existing /// /// /// /// /// Generally will contain a published and an unpublished property values /// /// public static Property CreateWithValues(int id, IPropertyType propertyType, params InitialPropertyValue[] values) { var property = new Property(propertyType); try { property.DisableChangeTracking(); property.Id = id; foreach (InitialPropertyValue value in values) { property.FactorySetValue(value.Culture, value.Segment, value.Published, value.Value); } property.ResetDirtyProperties(false); return property; } finally { property.EnableChangeTracking(); } } /// /// Gets the value. /// public object? GetValue(string? culture = null, string? segment = null, bool published = false) { // ensure null or whitespace are nulls culture = culture?.NullOrWhiteSpaceAsNull(); segment = segment?.NullOrWhiteSpaceAsNull(); if (!PropertyType.SupportsVariation(culture, segment)) { return null; } if (culture == null && segment == null) { return GetPropertyValue(_pvalue, published); } if (_vvalues == null) { return null; } return _vvalues.TryGetValue(new CompositeNStringNStringKey(culture, segment), out IPropertyValue? pvalue) ? GetPropertyValue(pvalue, published) : null; } // internal - must be invoked by the content item // does *not* validate the value - content item must validate first public void PublishPartialValues(IDataEditor dataEditor, string? culture) { if (PropertyType.VariesByCulture()) { throw new NotSupportedException("Cannot publish merged culture values for culture variant properties"); } culture = culture?.NullOrWhiteSpaceAsNull(); var value = dataEditor.MergePartialPropertyValueForCulture(_pvalue?.EditedValue, _pvalue?.PublishedValue, culture); PublishValue(_pvalue, value); } // internal - must be invoked by the content item // does *not* validate the value - content item must validate first public void PublishValues(string? culture = "*", string? segment = "*") { culture = culture?.NullOrWhiteSpaceAsNull(); segment = segment?.NullOrWhiteSpaceAsNull(); // if invariant or all, and invariant-neutral is supported, publish invariant-neutral if ((culture == null || culture == "*") && (segment == null || segment == "*") && PropertyType.SupportsVariation(null, null)) { PublishValue(_pvalue); } // then deal with everything that varies if (_vvalues == null) { return; } // get the property values that are still relevant (wrt the property type variation), // and match the specified culture and segment (or anything when '*'). IEnumerable pvalues = _vvalues.Where(x => PropertyType.SupportsVariation(x.Value.Culture, x.Value.Segment, true) && // the value variation is ok (culture == "*" || x.Value.Culture.InvariantEquals(culture)) && // the culture matches (segment == "*" || x.Value.Segment.InvariantEquals(segment))) // the segment matches .Select(x => x.Value); foreach (IPropertyValue pvalue in pvalues) { PublishValue(pvalue); } } // internal - must be invoked by the content item public void UnpublishValues(string? culture = "*", string? segment = "*") { culture = culture?.NullOrWhiteSpaceAsNull(); segment = segment?.NullOrWhiteSpaceAsNull(); // if invariant or all, and invariant-neutral is supported, publish invariant-neutral if ((culture == null || culture == "*") && (segment == null || segment == "*") && PropertyType.SupportsVariation(null, null)) { UnpublishValue(_pvalue); } // then deal with everything that varies if (_vvalues == null) { return; } // get the property values that are still relevant (wrt the property type variation), // and match the specified culture and segment (or anything when '*'). IEnumerable pvalues = _vvalues.Where(x => PropertyType.SupportsVariation(x.Value.Culture, x.Value.Segment, true) && // the value variation is ok (culture == "*" || (x.Value.Culture?.InvariantEquals(culture) ?? false)) && // the culture matches (segment == "*" || (x.Value.Segment?.InvariantEquals(segment) ?? false))) // the segment matches .Select(x => x.Value); foreach (IPropertyValue pvalue in pvalues) { UnpublishValue(pvalue); } } /// /// Sets a value. /// public void SetValue(object? value, string? culture = null, string? segment = null) { culture = culture?.NullOrWhiteSpaceAsNull(); segment = segment?.NullOrWhiteSpaceAsNull(); if (!PropertyType.SupportsVariation(culture, segment)) { throw new NotSupportedException( $"Variation \"{culture ?? ""},{segment ?? ""}\" is not supported by the property type."); } (IPropertyValue? pvalue, var change) = GetPValue(culture, segment, true); if (pvalue is not null) { var origValue = pvalue.EditedValue; var setValue = ConvertAssignedValue(value); pvalue.EditedValue = setValue; DetectChanges(setValue, origValue, nameof(Values), PropertyValueComparer, change); } } public object? ConvertAssignedValue(object? value) => TryConvertAssignedValue(value, true, out var converted) ? converted : null; protected override void PerformDeepClone(object clone) { base.PerformDeepClone(clone); var clonedEntity = (Property)clone; // need to manually assign since this is a readonly property clonedEntity.PropertyType = (PropertyType)PropertyType.DeepClone(); } private object? GetPropertyValue(IPropertyValue? pvalue, bool published) { if (pvalue == null) { return null; } return PropertyType.SupportsPublishing ? published ? pvalue.PublishedValue : pvalue.EditedValue : pvalue.EditedValue; } private void PublishValue(IPropertyValue? pvalue) { if (pvalue == null) { return; } PublishValue(pvalue, ConvertAssignedValue(pvalue.EditedValue)); } private void PublishValue(IPropertyValue? pvalue, object? newPublishedValue) { if (pvalue == null) { return; } if (!PropertyType.SupportsPublishing) { throw new NotSupportedException("Property type does not support publishing."); } var origValue = pvalue.PublishedValue; pvalue.PublishedValue = newPublishedValue; DetectChanges(pvalue.EditedValue, origValue, nameof(Values), PropertyValueComparer, false); } private void UnpublishValue(IPropertyValue? pvalue) { if (pvalue == null) { return; } if (!PropertyType.SupportsPublishing) { throw new NotSupportedException("Property type does not support publishing."); } var origValue = pvalue.PublishedValue; pvalue.PublishedValue = ConvertAssignedValue(null); DetectChanges(pvalue.EditedValue, origValue, nameof(Values), PropertyValueComparer, false); } // bypasses all changes detection and is the *only* way to set the published value private void FactorySetValue(string? culture, string? segment, bool published, object? value) { (IPropertyValue? pvalue, _) = GetPValue(culture, segment, true); if (pvalue is not null) { if (published && PropertyType.SupportsPublishing) { pvalue.PublishedValue = value; } else { pvalue.EditedValue = value; } } } private (IPropertyValue?, bool) GetPValue(bool create) { var change = false; if (_pvalue == null) { if (!create) { return (null, false); } _pvalue = new PropertyValue(); _values.Add(_pvalue); change = true; } return (_pvalue, change); } private (IPropertyValue?, bool) GetPValue(string? culture, string? segment, bool create) { if (culture == null && segment == null) { return GetPValue(create); } var change = false; if (_vvalues == null) { if (!create) { return (null, false); } _vvalues = new Dictionary(); change = true; } var k = new CompositeNStringNStringKey(culture, segment); if (!_vvalues.TryGetValue(k, out IPropertyValue? pvalue)) { if (!create) { return (null, false); } pvalue = _vvalues[k] = new PropertyValue(); pvalue.Culture = culture; pvalue.Segment = segment; _values.Add(pvalue); change = true; } return (pvalue, change); } private static void ThrowTypeException(object? value, Type expected, string alias) => throw new InvalidOperationException( $"Cannot assign value \"{value}\" of type \"{value?.GetType()}\" to property \"{alias}\" expecting type \"{expected}\"."); /// /// Tries to convert a value assigned to a property. /// /// /// /// private bool TryConvertAssignedValue(object? value, bool throwOnError, out object? converted) { var isOfExpectedType = IsOfExpectedPropertyType(value); if (isOfExpectedType) { converted = value; return true; } // isOfExpectedType is true if value is null - so if false, value is *not* null // "garbage-in", accept what we can & convert // throw only if conversion is not possible var s = value?.ToString(); converted = null; switch (ValueStorageType) { case ValueStorageType.Nvarchar: case ValueStorageType.Ntext: { converted = s; return true; } case ValueStorageType.Integer: if (s.IsNullOrWhiteSpace()) { return true; // assume empty means null } Attempt convInt = value.TryConvertTo(); if (convInt.Success) { converted = convInt.Result; return true; } if (throwOnError) { ThrowTypeException(value, typeof(int), Alias); } return false; case ValueStorageType.Decimal: if (s.IsNullOrWhiteSpace()) { return true; // assume empty means null } Attempt convDecimal = value.TryConvertTo(); if (convDecimal.Success) { // need to normalize the value (change the scaling factor and remove trailing zeros) // because the underlying database is going to mess with the scaling factor anyways. converted = convDecimal.Result.Normalize(); return true; } if (throwOnError) { ThrowTypeException(value, typeof(decimal), Alias); } return false; case ValueStorageType.Date: if (s.IsNullOrWhiteSpace()) { return true; // assume empty means null } Attempt convDateTime = value.TryConvertTo(); if (convDateTime.Success) { converted = convDateTime.Result; return true; } if (throwOnError) { ThrowTypeException(value, typeof(DateTime), Alias); } return false; default: throw new NotSupportedException($"Not supported storage type \"{ValueStorageType}\"."); } } /// /// Determines whether a value is of the expected type for this property type. /// /// /// /// If the value is of the expected type, it can be directly assigned to the property. /// Otherwise, some conversion is required. /// /// private bool IsOfExpectedPropertyType(object? value) { // null values are assumed to be ok if (value == null) { return true; } // check if the type of the value matches the type from the DataType/PropertyEditor // then it can be directly assigned, anything else requires conversion Type valueType = value.GetType(); switch (ValueStorageType) { case ValueStorageType.Integer: return valueType == typeof(int); case ValueStorageType.Decimal: return valueType == typeof(decimal); case ValueStorageType.Date: return valueType == typeof(DateTime); case ValueStorageType.Nvarchar: return valueType == typeof(string); case ValueStorageType.Ntext: return valueType == typeof(string); default: throw new NotSupportedException($"Not supported storage type \"{ValueStorageType}\"."); } } /// /// Used for constructing a new instance /// public class InitialPropertyValue { public InitialPropertyValue(string? culture, string? segment, bool published, object? value) { Culture = culture; Segment = segment; Published = published; Value = value; } public string? Culture { get; } public string? Segment { get; } public bool Published { get; } public object? Value { get; } } /// /// Represents a property value. /// public class PropertyValue : IPropertyValue, IDeepCloneable, IEquatable { // TODO: Either we allow change tracking at this class level, or we add some special change tracking collections to the Property // class to deal with change tracking which variants have changed /// /// Gets or sets the culture of the property. /// /// /// The culture is either null (invariant) or a non-empty string. If the property is /// set with an empty or whitespace value, its value is converted to null. /// public string? Culture { get; set; } public object DeepClone() => Clone(); public bool Equals(PropertyValue? other) => other != null && Culture == other.Culture && Segment == other.Segment && EqualityComparer.Default.Equals(EditedValue, other.EditedValue) && EqualityComparer.Default.Equals(PublishedValue, other.PublishedValue); /// /// Gets or sets the segment of the property. /// /// /// The segment is either null (neutral) or a non-empty string. If the property is /// set with an empty or whitespace value, its value is converted to null. /// public string? Segment { get; set; } /// /// Gets or sets the edited value of the property. /// public object? EditedValue { get; set; } /// /// Gets or sets the published value of the property. /// public object? PublishedValue { get; set; } /// /// Clones the property value. /// public IPropertyValue Clone() => new PropertyValue { Culture = Culture, Segment = Segment, PublishedValue = PublishedValue, EditedValue = EditedValue, }; public override bool Equals(object? obj) => Equals(obj as PropertyValue); public override int GetHashCode() { var hashCode = 1885328050; if (Culture is not null) { hashCode = (hashCode * -1521134295) + EqualityComparer.Default.GetHashCode(Culture); } if (Segment is not null) { hashCode = (hashCode * -1521134295) + EqualityComparer.Default.GetHashCode(Segment); } if (EditedValue is not null) { hashCode = (hashCode * -1521134295) + EqualityComparer.Default.GetHashCode(EditedValue); } if (PublishedValue is not null) { hashCode = (hashCode * -1521134295) + EqualityComparer.Default.GetHashCode(PublishedValue); } return hashCode; } } }