Skip to content

Commit 8b7dffe

Browse files
committed
Merge branch 'feature/Arbitrary-Attribute-Support' into develop
Introduces Programmatic Support for Arbitrary Attributes (#19) Arbitrary attributes are those added by developers, but which aren't part of the `ContentTypeDescriptor` schema. They may be useful for adding one-off metadata that is useful to add some some topics, but doesn't align well with any individual content type. While rarely used, they're a useful tool for developers—and especially when developing proof-of-concepts (e.g., where the attribute may be formalized later). Previously, arbitrary attributes could be added to the database. But they couldn't be added, modified, or deleted programmatically using the OnTopic library. In fact, this could be a source of confusion as no error was produced in this scenario; the arbitrary attribute was just ignored entirely. As such, a programmatic modification might appear to work, only to revert when the application restarts. To mitigate this, while supporting these ad hoc scenarios, we've formalized support for working with arbitrary attributes programmatically, thus providing assurances that they will be properly persisted to the underlying data store. This includes: - Support for saving arbitrary attributes (c3c01ef) - Support for deleting arbitrary attributes (2151a08) - Deleting orphaned indexed attributes (7a61005) - Excluding empty values from `GetAttributes()` (c035ca0) - Ignoring phantom attributes on `SetValue()` (a0b659d)
2 parents fb3366e + a0b659d commit 8b7dffe

5 files changed

Lines changed: 185 additions & 5 deletions

File tree

OnTopic.Data.Sql/SqlTopicRepository.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,21 +306,35 @@ public override int Save([NotNull]Topic topic, bool isRecursive = false, bool is
306306
extendedAttributes.Append("<attributes>");
307307

308308
foreach (var attributeValue in GetAttributes(topic, true, null)) {
309+
309310
extendedAttributes.Append(
310311
"<attribute key=\"" + attributeValue.Key + "\"><![CDATA[" + attributeValue.Value + "]]></attribute>"
311312
);
312313
attributeValue.IsDirty = false;
314+
315+
//###NOTE JJC20200502: By treating extended attributes as unmatched, we ensure that any indexed attributes with the same
316+
//value are overwritten with an empty attribute. This is useful for cases where an indexed attribute is moved to an
317+
//extended attribute, as it persists that version history, while removing ambiguity over which record is authoritative.
318+
//This is also useful for supporting arbitrary attribute values, since they may be moved from indexed to extended
319+
//attributes if their length exceeds 255.
320+
addUnmatchedAttribute(attributeValue.Key);
321+
313322
}
314323

315324
extendedAttributes.Append("</attributes>");
316325

317326
/*------------------------------------------------------------------------------------------------------------------------
318-
| Add unmatch attributes
327+
| Add unmatched attributes
319328
\-----------------------------------------------------------------------------------------------------------------------*/
329+
320330
//Loop through the content type's supported attributes and add attribute to null attributes if topic does not contain it
321331
foreach (var attribute in GetUnmatchedAttributes(topic)) {
332+
addUnmatchedAttribute(attribute.Key);
333+
}
334+
335+
void addUnmatchedAttribute(string key) {
322336
var record = attributes.NewRow();
323-
record["AttributeKey"] = attribute.Key;
337+
record["AttributeKey"] = key;
324338
record["AttributeValue"]= null;
325339
attributes.Rows.Add(record);
326340
}

OnTopic.Tests/AttributeValueCollectionTest.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,45 @@ public void Add_InvalidAttributeValue_ThrowsException() {
303303
topic.Attributes.Add(new AttributeValue("Key", "# ?"));
304304
}
305305

306+
/*==========================================================================================================================
307+
| TEST: SET VALUE: EMPTY ATTRIBUTE VALUE: SKIPS
308+
\-------------------------------------------------------------------------------------------------------------------------*/
309+
/// <summary>
310+
/// Adds a new attribute with an empty value, and confirms that it is <i>not</i> added as a new <see
311+
/// cref="AttributeValue"/>. Empty values are treated as the same as non-existent attributes. They are stored for the sake
312+
/// of tracking <i>deleted</i> attributes, but should not be stored for <i>new</i> attributes.
313+
/// </summary>
314+
[TestMethod]
315+
public void SetValue_EmptyAttributeValue_Skips() {
316+
317+
var topic = TopicFactory.Create("Test", "Container");
318+
319+
topic.Attributes.SetValue("Attribute", "");
320+
321+
Assert.IsFalse(topic.Attributes.Contains("Attribute"));
322+
323+
}
324+
325+
/*==========================================================================================================================
326+
| TEST: SET VALUE: UPDATE EMPTY ATTRIBUTE VALUE: REPLACES
327+
\-------------------------------------------------------------------------------------------------------------------------*/
328+
/// <summary>
329+
/// Adds a new attribute with an empty value, and confirms that it is <i>is</i> added as a new <see
330+
/// cref="AttributeValue"/> assuming the value previously existed. Empty values are treated as the same as non-existent
331+
/// attributes, but they should be stored for the sake of tracking <i>deleted</i> attributes.
332+
/// </summary>
333+
[TestMethod]
334+
public void SetValue_EmptyAttributeValue_Replaces() {
335+
336+
var topic = TopicFactory.Create("Test", "Container");
337+
338+
topic.Attributes.SetValue("Attribute", "New Value");
339+
topic.Attributes.SetValue("Attribute", "");
340+
341+
Assert.IsTrue(topic.Attributes.Contains("Attribute"));
342+
343+
}
344+
306345
/*==========================================================================================================================
307346
| TEST: GET VALUE: INHERIT FROM PARENT: RETURNS PARENT VALUE
308347
\-------------------------------------------------------------------------------------------------------------------------*/

OnTopic.Tests/TopicRepositoryBaseTest.cs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,29 @@ public void GetAttributes_AnyAttributes_ReturnsAllAttributes() {
6868

6969
}
7070

71+
/*==========================================================================================================================
72+
| TEST: GET ATTRIBUTES: EMPTY ATTRIBUTES: SKIPS
73+
\-------------------------------------------------------------------------------------------------------------------------*/
74+
/// <summary>
75+
/// Retrieves a list of attributes from a topic, without any filtering by whether or not the attribute is an <see
76+
/// cref="AttributeDescriptor.IsExtendedAttribute"/>. Any <see cref="AttributeValue"/>s with a null or empty value should
77+
/// be skipped.
78+
/// </summary>
79+
[TestMethod]
80+
public void GetAttributes_EmptyAttributes_Skips() {
81+
82+
var topic = TopicFactory.Create("Test", "ContentTypes");
83+
84+
topic.Attributes.SetValue("EmptyAttribute", "");
85+
topic.Attributes.SetValue("NullAttribute", null);
86+
87+
var attributes = _topicRepository.GetAttributesProxy(topic, null);
88+
89+
Assert.IsFalse(attributes.Any(a => a.Key == "EmptyAttribute"));
90+
Assert.IsFalse(attributes.Any(a => a.Key == "NullAttribute"));
91+
92+
}
93+
7194
/*==========================================================================================================================
7295
| TEST: GET ATTRIBUTES: INDEXED ATTRIBUTES: RETURNS INDEXED ATTRIBUTES
7396
\-------------------------------------------------------------------------------------------------------------------------*/
@@ -106,6 +129,48 @@ public void GetAttributes_ExtendedAttributes_ReturnsExtendedAttributes() {
106129

107130
}
108131

132+
/*==========================================================================================================================
133+
| TEST: GET ATTRIBUTES: ARBITRARY ATTRIBUTE WITH SHORT VALUE: RETURNS AS INDEXED ATTRIBUTE
134+
\-------------------------------------------------------------------------------------------------------------------------*/
135+
/// <summary>
136+
/// Sets an arbitrary (unmatched) attribute on a <see cref="Topic"/> with a value shorter than 255 characters, then
137+
/// ensures that it is returned as an an <i>indexed</i> <see cref="AttributeValue"/> when calling <see
138+
/// cref="TopicRepositoryBase.GetAttributes(Topic, Boolean?, Boolean?)"/>.
139+
/// </summary>
140+
[TestMethod]
141+
public void GetAttributes_ArbitraryAttributeWithShortValue_ReturnsAsIndexedAttributes() {
142+
143+
var topic = TopicFactory.Create("Test", "ContentTypes");
144+
145+
topic.Attributes.SetValue("ArbitraryAttribute", "Value");
146+
147+
var attributes = _topicRepository.GetAttributesProxy(topic, false);
148+
149+
Assert.IsTrue(attributes.Any(a => a.Key.Equals("ArbitraryAttribute", StringComparison.InvariantCultureIgnoreCase)));
150+
151+
}
152+
153+
/*==========================================================================================================================
154+
| TEST: GET ATTRIBUTES: ARBITRARY ATTRIBUTE WITH LONG VALUE: RETURNS AS EXTENDED ATTRIBUTE
155+
\-------------------------------------------------------------------------------------------------------------------------*/
156+
/// <summary>
157+
/// Sets an arbitrary (unmatched) attribute on a <see cref="Topic"/> with a value longer than 255 characters, then
158+
/// ensures that it is returned as an an <see cref="AttributeDescriptor.IsExtendedAttribute"/> when calling <see
159+
/// cref="TopicRepositoryBase.GetAttributes(Topic, Boolean?, Boolean?)"/>.
160+
/// </summary>
161+
[TestMethod]
162+
public void GetAttributes_ArbitraryAttributeWithLongValue_ReturnsAsExtendedAttributes() {
163+
164+
var topic = TopicFactory.Create("Test", "ContentTypes");
165+
166+
topic.Attributes.SetValue("ArbitraryAttribute", new string('x', 256));
167+
168+
var attributes = _topicRepository.GetAttributesProxy(topic, true);
169+
170+
Assert.IsTrue(attributes.Any(a => a.Key.Equals("ArbitraryAttribute", StringComparison.InvariantCultureIgnoreCase)));
171+
172+
}
173+
109174
/*==========================================================================================================================
110175
| TEST: GET UNMATCHED ATTRIBUTES: RETURNS ATTRIBUTES
111176
\-------------------------------------------------------------------------------------------------------------------------*/
@@ -127,6 +192,34 @@ public void GetUnmatchedAttributes_ReturnsAttributes() {
127192

128193
}
129194

195+
/*==========================================================================================================================
196+
| TEST: GET UNMATCHED ATTRIBUTES: EMPTY ARBITRARY ATTRIBUTES: RETURNS ATTRIBUTES
197+
\-------------------------------------------------------------------------------------------------------------------------*/
198+
/// <summary>
199+
/// Using <see cref="TopicRepositoryBase.GetUnmatchedAttributes(Topic)"/>, ensures that any attributes that exist on the
200+
/// <see cref="Topic"/> but not the <see cref="ContentTypeDescriptor"/> <i>and</i> are either <c>null</c> or empty are
201+
/// returned. This ensures that arbitrary attributes can be deleted programmatically, instead of lingering as orphans in
202+
/// the database.
203+
/// </summary>
204+
[TestMethod]
205+
public void GetUnmatchedAttributes_EmptyArbitraryAttributes_ReturnsAttributes() {
206+
207+
var topic = TopicFactory.Create("Test", "ContentTypeDescriptor", 1);
208+
209+
topic.Attributes.SetValue("ArbitraryAttribute", "Value");
210+
topic.Attributes.SetValue("ArbitraryAttribute", "");
211+
topic.Attributes.SetValue("AnotherArbitraryAttribute", "Value");
212+
topic.Attributes.SetValue("YetAnotherArbitraryAttribute", "Value");
213+
topic.Attributes.SetValue("YetAnotherArbitraryAttribute", null);
214+
215+
var attributes = _topicRepository.GetUnmatchedAttributesProxy(topic);
216+
217+
Assert.IsTrue(attributes.Any(a => a.Key.Equals("ArbitraryAttribute", StringComparison.InvariantCulture)));
218+
Assert.IsTrue(attributes.Any(a => a.Key.Equals("YetAnotherArbitraryAttribute", StringComparison.InvariantCulture)));
219+
Assert.IsFalse(attributes.Any(a => a.Key.Equals("AnotherArbitraryAttribute", StringComparison.InvariantCulture)));
220+
221+
}
222+
130223
/*==========================================================================================================================
131224
| TEST: GET CONTENT TYPE DESCRIPTORS: RETURNS CONTENT TYPES
132225
\-------------------------------------------------------------------------------------------------------------------------*/

OnTopic/Collections/AttributeValueCollection.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,16 @@ internal void SetValue(string key, string? value, bool? isDirty, bool enforceBus
307307
this[IndexOf(originalAttribute)] = newAttribute;
308308
}
309309

310+
/*------------------------------------------------------------------------------------------------------------------------
311+
| Ignore if null
312+
>-------------------------------------------------------------------------------------------------------------------------
313+
| ###NOTE JJC20200501: Null or empty values are treated as deletions, and are not persisted to the data store. With
314+
| existing values, these are written to ensure that the collection is marked as IsDirty, thus allowing previous values to
315+
| be overwritten. Non-existent values, however, should simply be ignored.
316+
\-----------------------------------------------------------------------------------------------------------------------*/
317+
else if (String.IsNullOrEmpty(value)) {
318+
}
319+
310320
/*------------------------------------------------------------------------------------------------------------------------
311321
| Create new attribute value
312322
\-----------------------------------------------------------------------------------------------------------------------*/

OnTopic/Repositories/TopicRepositoryBase.cs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,16 @@ protected IEnumerable<AttributeValue> GetAttributes(Topic topic, bool? isExtende
502502
attribute = contentType.AttributeDescriptors[key];
503503
}
504504

505-
if (attribute != null && (isExtendedAttribute == null || attribute.IsExtendedAttribute == isExtendedAttribute)) {
505+
//Skip if the value is null or empty; these values are not persisted to storage and should be treated as equivalent to
506+
//non-existent values.
507+
if (String.IsNullOrEmpty(attributeValue.Value)) {
508+
continue;
509+
}
510+
511+
//Add the attribute based on the isExtendedAttribute paramter. Add all parameters if isExtendedAttribute is null. Assume
512+
//an attribute is extended if the corresponding attribute descriptor cannot be located and the value is over 255
513+
//characters.
514+
if (isExtendedAttribute?.Equals(attribute?.IsExtendedAttribute?? attributeValue.Value?.Length > 255)?? true) {
506515
attributes.Add(attributeValue);
507516
}
508517

@@ -538,9 +547,9 @@ protected IEnumerable<AttributeDescriptor> GetUnmatchedAttributes(Topic topic) {
538547
);
539548

540549
/*------------------------------------------------------------------------------------------------------------------------
541-
| Get unmatched attributes
550+
| Get unmatched attribute descriptors
542551
\-----------------------------------------------------------------------------------------------------------------------*/
543-
var attributes = new List<AttributeDescriptor>();
552+
var attributes = new TopicCollection<AttributeDescriptor>();
544553

545554
foreach (var attribute in contentType.AttributeDescriptors) {
546555

@@ -571,6 +580,21 @@ protected IEnumerable<AttributeDescriptor> GetUnmatchedAttributes(Topic topic) {
571580

572581
}
573582

583+
/*------------------------------------------------------------------------------------------------------------------------
584+
| Get arbitrary attributes
585+
>-------------------------------------------------------------------------------------------------------------------------
586+
| ###HACK JJC20200502: Arbitrary attributes are those that don't map back to the scheme. These aren't picked up by the
587+
| AttributeDescriptors check above. This means there's no way to programmatically delete arbitrary (or orphaned)
588+
| attributes. To mitigate this, any null or empty attribute values should be included. By definition, though, arbitrary
589+
| attributes don't have corresponding AttributeDescriptors. To mitigate this, an ad hoc AttributeDescriptor object will be
590+
| created for each empty AttributeDescriptor.
591+
\-----------------------------------------------------------------------------------------------------------------------*/
592+
foreach (var attribute in topic.Attributes.Where(a => String.IsNullOrEmpty(a.Value))) {
593+
if (!attributes.Contains(attribute.Key)) {
594+
attributes.Add((TextAttribute)TopicFactory.Create(attribute.Key, "TextAttribute"));
595+
}
596+
}
597+
574598
/*------------------------------------------------------------------------------------------------------------------------
575599
| Return values
576600
\-----------------------------------------------------------------------------------------------------------------------*/

0 commit comments

Comments
 (0)