Skip to content

Commit d64d242

Browse files
committed
Merge branch 'bugfix/extended-attributes-mismatch' into develop
Currently, if an `AttributeDescriptor`'s `IsExtendedAttribute` value changes, the configuration value changes, but the actual storage location of _existing_ attributes isn't updated. That's because the `AttributeDescriptor` is primarily used by the `ITopicRepository.Save()` method as well as the **OnTopic Editor**, but doesn't have the ability to _trigger_ broad-sweeping `ITopicRepository.Save()` operations. This means that the storage location of an attribute may not be persisted until the next time that `AttributeValue` is saved. This can cause a problem, however, as previously, only attributes marked as `IsDirty` would be persisted as indexed attributes. As such, if `IsExtendedAttribute` was changed to _`true`_, then the value would be correctly removed from the indexed attributes storage and added to the extended attributes storage. But if the opposite were the case, it would be successfully removed from extended storage—but not added indexed storage. Ooops! In practice, this is probably a rare scenario. We default to storing attributes as indexed. And we only store them as `IsExtendedAttribute` if their value exceeds the maximum character limit for an indexed attribute (currently 255 characters). However, in the rare case that this happens, the consequences would be pretty significant. And because this data loss wouldn't occur until any one topic was saved, it may be difficult to track down and account for the cause. Unfortunately, despite being an edge case, the solution to this is fairly complicated. This bugfix includes the addition of an `IsExtendedAttribute` property to `AttributeValue`, a new `isExtendedAttribute` parameter on `AttributeValueCollection.SetValue()`, a new `IsExtendedAttributeMismatch()` helper in `TopicRepositoryBase`, and a new `excludeLastModified` parameter to the `TopicRepositoryBase.GetAttributes()` method in order to allow returning `IsExtendedAttributeMismatch()`ed when processing indexed attributes without interfering with the logic for byline (`LastModifiedBy`) and dateline (`LastModified`) processing. Whew! That said, despite the additional _complexity_, this introduces minimal _overhead_ in terms of loading or even saving data. And it prevents a potentially confusing bug that would be difficult and frustrating to recover from if it were tripped. (And, in fact, it has already been tripped a few times—though only with configuration metadata that wasn't critical to the operation of the site.)
2 parents 695664d + 3002d98 commit d64d242

8 files changed

Lines changed: 229 additions & 49 deletions

File tree

OnTopic.Data.Sql/SqlDataReaderExtensions.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ private static void SetIndexedAttributes(this SqlDataReader reader, Dictionary<i
203203
/*------------------------------------------------------------------------------------------------------------------------
204204
| Set attribute value
205205
\-----------------------------------------------------------------------------------------------------------------------*/
206-
current.Attributes.SetValue(attributeKey, attributeValue, false, version);
206+
current.Attributes.SetValue(attributeKey, attributeValue, false, version, false);
207207

208208
}
209209

@@ -274,7 +274,7 @@ private static void SetExtendedAttributes(this SqlDataReader reader, Dictionary<
274274
| Set attribute value
275275
\---------------------------------------------------------------------------------------------------------------------*/
276276
if (String.IsNullOrEmpty(attributeValue)) continue;
277-
current.Attributes.SetValue(attributeKey, attributeValue, false, version);
277+
current.Attributes.SetValue(attributeKey, attributeValue, false, version, true);
278278

279279
} while (xmlReader.Name == "attribute");
280280

OnTopic.Data.Sql/SqlTopicRepository.cs

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -289,22 +289,26 @@ public override int Save([NotNull]Topic topic, bool isRecursive = false, bool is
289289
/*------------------------------------------------------------------------------------------------------------------------
290290
| Add indexed attributes that are dirty
291291
\-----------------------------------------------------------------------------------------------------------------------*/
292-
//Exclude indexed attributes if the only dirty attributes are `LastModified` and/or `LastModifiedBy`, as these are
293-
//automatically generated and don't represent genuine changes to the topic.
294-
if (topic.Attributes.IsDirty(true)) {
295292

296-
var indexedAttributes = GetAttributes(topic, false, true);
293+
//Only include `LastModified` and/or `LastModifiedBy` if other attributes in the collection are `IsDirty`, as these are
294+
//automatically generated and don't represent genuine changes to the topic.
295+
var excludeLastModified = !topic.Attributes.IsDirty(excludeLastModified: true);
297296

298-
foreach (var attributeValue in indexedAttributes) {
297+
var indexedAttributes = GetAttributes(
298+
topic : topic,
299+
isExtendedAttribute : false,
300+
isDirty : true,
301+
excludeLastModified : excludeLastModified
302+
);
299303

300-
var record = attributes.NewRow();
301-
record["AttributeKey"] = attributeValue.Key;
302-
record["AttributeValue"]= attributeValue.Value;
303-
attributeValue.IsDirty = false;
304+
foreach (var attributeValue in indexedAttributes) {
304305

305-
attributes.Rows.Add(record);
306+
var record = attributes.NewRow();
307+
record["AttributeKey"] = attributeValue.Key;
308+
record["AttributeValue"]= attributeValue.Value;
309+
attributeValue.IsDirty = false;
306310

307-
}
311+
attributes.Rows.Add(record);
308312

309313
}
310314

@@ -313,7 +317,7 @@ public override int Save([NotNull]Topic topic, bool isRecursive = false, bool is
313317
\-----------------------------------------------------------------------------------------------------------------------*/
314318
extendedAttributes.Append("<attributes>");
315319

316-
foreach (var attributeValue in GetAttributes(topic, true, null)) {
320+
foreach (var attributeValue in GetAttributes(topic, isExtendedAttribute:true)) {
317321

318322
extendedAttributes.Append(
319323
"<attribute key=\"" + attributeValue.Key + "\"><![CDATA[" + attributeValue.Value + "]]></attribute>"

OnTopic.TestDoubles/StubTopicRepository.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,12 @@ public override void Move(Topic topic, Topic target, Topic? sibling) {
168168
| METHOD: GET ATTRIBUTES (PROXY)
169169
\-------------------------------------------------------------------------------------------------------------------------*/
170170
/// <inheritdoc cref="TopicRepositoryBase.GetAttributes(Topic, Boolean?, Boolean?)" />
171-
public IEnumerable<AttributeValue> GetAttributesProxy(Topic topic, bool? isExtendedAttribute, bool? isDirty = null) =>
172-
base.GetAttributes(topic, isExtendedAttribute, isDirty);
171+
public IEnumerable<AttributeValue> GetAttributesProxy(
172+
Topic topic,
173+
bool? isExtendedAttribute,
174+
bool? isDirty = null,
175+
bool excludeLastModified = false
176+
) => base.GetAttributes(topic, isExtendedAttribute, isDirty, excludeLastModified);
173177

174178
/*==========================================================================================================================
175179
| METHOD: GET UNMATCHED ATTRIBUTES (PROXY)

OnTopic.Tests/TopicRepositoryBaseTest.cs

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -129,13 +129,80 @@ public void GetAttributes_ExtendedAttributes_ReturnsExtendedAttributes() {
129129

130130
}
131131

132+
/*==========================================================================================================================
133+
| TEST: GET ATTRIBUTES: EXTENDED ATTRIBUTE MISMATCH: RETURNS EXTENDED ATTRIBUTES
134+
\-------------------------------------------------------------------------------------------------------------------------*/
135+
/// <summary>
136+
/// Retrieves a list of attributes from a topic, filtering by <see cref="AttributeValue.IsDirty"/>. Expects an <see
137+
/// cref="AttributeValue"/> to be returned even if it's <i>not</i> <see cref="AttributeValue.IsDirty"/> <i>but</i> its
138+
/// <see cref="AttributeValue.IsExtendedAttribute"/> disagrees with <see cref="AttributeDescriptor.IsExtendedAttribute"/>.
139+
/// </summary>
140+
[TestMethod]
141+
public void GetAttributes_ExtendedAttributeMismatch_ReturnsExtendedAttributes() {
142+
143+
var topic = TopicFactory.Create("Test", "ContentTypes");
144+
145+
topic.Attributes.SetValue("Title", "Title", isDirty:false, isExtendedAttribute:false);
146+
147+
var attributes = _topicRepository.GetAttributesProxy(topic, true, true);
148+
149+
//Expect Title, even though it isn't IsDirty
150+
Assert.AreEqual<int>(1, attributes.Count());
151+
152+
}
153+
154+
/*==========================================================================================================================
155+
| TEST: GET ATTRIBUTES: EXTENDED ATTRIBUTE MISMATCH: RETURNS NOTHING
156+
\-------------------------------------------------------------------------------------------------------------------------*/
157+
/// <summary>
158+
/// Retrieves a list of attributes from a topic, filtering by <see cref="AttributeValue.IsDirty"/>. Expects the <see
159+
/// cref="AttributeValue"/> to <i>not</i> be returned even though its <see cref="AttributeValue.IsExtendedAttribute"/>
160+
/// disagrees with <see cref="AttributeDescriptor.IsExtendedAttribute"/>, since it won't match the <see
161+
/// cref="TopicRepositoryBase.GetAttributes(Topic, Boolean?, Boolean?, Boolean)"/>'s <c>isExtendedAttribute</c> call.
162+
/// </summary>
163+
[TestMethod]
164+
public void GetAttributes_ExtendedAttributeMismatch_ReturnsNothing() {
165+
166+
var topic = TopicFactory.Create("Test", "ContentTypes");
167+
168+
topic.Attributes.SetValue("Title", "Title", isDirty: false, isExtendedAttribute: false);
169+
170+
var attributes = _topicRepository.GetAttributesProxy(topic, false, true);
171+
172+
//Expect Key and ContentType, but not Title
173+
Assert.AreEqual<int>(2, attributes.Count());
174+
175+
}
176+
177+
/*==========================================================================================================================
178+
| TEST: GET ATTRIBUTES: EXCLUDE LAST MODIFIED: RETURNS OTHER ATTRIBUTES
179+
\-------------------------------------------------------------------------------------------------------------------------*/
180+
/// <summary>
181+
/// Retrieves a list of attributes from a topic, filtering by <c>excludeLastModified</c>. Confirms that <see
182+
/// cref="AttributeValue"/>s are not returned which start with <c>LastModified</c>.
183+
/// </summary>
184+
[TestMethod]
185+
public void GetAttributes_ExcludeLastModified_ReturnsOtherAttributes() {
186+
187+
var topic = TopicFactory.Create("Test", "ContentTypes");
188+
189+
topic.Attributes.SetDateTime("LastModified", DateTime.Now);
190+
topic.Attributes.SetValue("LastModifiedBy", "Unit Tests");
191+
192+
var attributes = _topicRepository.GetAttributesProxy(topic, null, excludeLastModified: true);
193+
194+
//Expected to return Key and ContentType, butnot LastModified or LastModifiedBy
195+
Assert.AreEqual<int>(2, attributes.Count());
196+
197+
}
198+
132199
/*==========================================================================================================================
133200
| TEST: GET ATTRIBUTES: ARBITRARY ATTRIBUTE WITH SHORT VALUE: RETURNS AS INDEXED ATTRIBUTE
134201
\-------------------------------------------------------------------------------------------------------------------------*/
135202
/// <summary>
136203
/// Sets an arbitrary (unmatched) attribute on a <see cref="Topic"/> with a value shorter than 255 characters, then
137204
/// ensures that it is returned as an an <i>indexed</i> <see cref="AttributeValue"/> when calling <see
138-
/// cref="TopicRepositoryBase.GetAttributes(Topic, Boolean?, Boolean?)"/>.
205+
/// cref="TopicRepositoryBase.GetAttributes(Topic, Boolean?, Boolean?, Boolean)"/>.
139206
/// </summary>
140207
[TestMethod]
141208
public void GetAttributes_ArbitraryAttributeWithShortValue_ReturnsAsIndexedAttributes() {
@@ -156,7 +223,7 @@ public void GetAttributes_ArbitraryAttributeWithShortValue_ReturnsAsIndexedAttri
156223
/// <summary>
157224
/// Sets an arbitrary (unmatched) attribute on a <see cref="Topic"/> with a value longer than 255 characters, then
158225
/// ensures that it is returned as an an <see cref="AttributeDescriptor.IsExtendedAttribute"/> when calling <see
159-
/// cref="TopicRepositoryBase.GetAttributes(Topic, Boolean?, Boolean?)"/>.
226+
/// cref="TopicRepositoryBase.GetAttributes(Topic, Boolean?, Boolean?, Boolean)"/>.
160227
/// </summary>
161228
[TestMethod]
162229
public void GetAttributes_ArbitraryAttributeWithLongValue_ReturnsAsExtendedAttributes() {
@@ -297,7 +364,7 @@ public void GetContentTypeDescriptor_GetInvalidContentType_ReturnsNull() {
297364
| TEST: SAVE: CONTENT TYPE DESCRIPTOR: UPDATES CONTENT TYPE CACHE
298365
\-------------------------------------------------------------------------------------------------------------------------*/
299366
/// <summary>
300-
/// Loads the <see cref="TopicRepositoryBase.GetAttributes(Topic, Boolean?, Boolean?)"/>, then saves a new <see
367+
/// Loads the <see cref="TopicRepositoryBase.GetAttributes(Topic, Boolean?, Boolean?, Boolean)"/>, then saves a new <see
301368
/// cref="ContentTypeDescriptor"/> via <see cref="TopicRepositoryBase.Save(Topic, Boolean, Boolean)"/>, and ensures that
302369
/// it is immediately reflected in the <see cref="TopicRepositoryBase"/> cache of <see cref="ContentTypeDescriptor"/>s.
303370
/// </summary>
@@ -317,9 +384,9 @@ public void Save_ContentTypeDescriptor_UpdatesContentTypeCache() {
317384
| TEST: DELETE: CONTENT TYPE DESCRIPTOR: UPDATES CONTENT TYPE CACHE
318385
\-------------------------------------------------------------------------------------------------------------------------*/
319386
/// <summary>
320-
/// Loads the <see cref="TopicRepositoryBase.GetAttributes(Topic, Boolean?, Boolean?)"/>, then deletes one of the <see
321-
/// cref="ContentTypeDescriptor"/>s via <see cref="TopicRepositoryBase.Delete(Topic, Boolean)"/>, and ensures that it is
322-
/// immediately reflected in the <see cref="TopicRepositoryBase"/> cache of <see cref="ContentTypeDescriptor"/>s.
387+
/// Loads the <see cref="TopicRepositoryBase.GetAttributes(Topic, Boolean?, Boolean?, Boolean)"/>, then deletes one of the
388+
/// <see cref="ContentTypeDescriptor"/>s via <see cref="TopicRepositoryBase.Delete(Topic, Boolean)"/>, and ensures that it
389+
/// is immediately reflected in the <see cref="TopicRepositoryBase"/> cache of <see cref="ContentTypeDescriptor"/>s.
323390
/// </summary>
324391
[TestMethod]
325392
public void Delete_ContentTypeDescriptor_UpdatesContentTypeCache() {
@@ -337,8 +404,8 @@ public void Delete_ContentTypeDescriptor_UpdatesContentTypeCache() {
337404
| TEST: MOVE: CONTENT TYPE DESCRIPTOR: UPDATES CONTENT TYPE CACHE
338405
\-------------------------------------------------------------------------------------------------------------------------*/
339406
/// <summary>
340-
/// Loads the <see cref="TopicRepositoryBase.GetAttributes(Topic, Boolean?, Boolean?)"/>, then moves one of the <see
341-
/// cref="ContentTypeDescriptor"/>s via <see cref="TopicRepositoryBase.Move(Topic, Topic)"/>, and ensures that it is
407+
/// Loads the <see cref="TopicRepositoryBase.GetAttributes(Topic, Boolean?, Boolean?, Boolean)"/>, then moves one of the
408+
/// <see cref="ContentTypeDescriptor"/>s via <see cref="TopicRepositoryBase.Move(Topic, Topic)"/>, and ensures that it is
342409
/// immediately reflected in the <see cref="TopicRepositoryBase"/> cache of <see cref="ContentTypeDescriptor"/>s.
343410
/// </summary>
344411
[TestMethod]

OnTopic/Attributes/AttributeValue.cs

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ namespace OnTopic.Attributes {
3333
/// <para>
3434
/// This class is immutable: once it is constructed, the values cannot be changed. To change a value, callers must either
3535
/// create a new instance of the <see cref="AttributeValue"/> class or, preferably, call the
36-
/// <see cref="Topic.Attributes"/>'s <see cref="AttributeValueCollection.SetValue(String, String, Boolean?, DateTime?)"/>
36+
/// <see cref="Topic.Attributes"/>'s <see cref="AttributeValueCollection.SetValue(String, String, Boolean?, DateTime?,
37+
/// Boolean?)"/>
3738
/// method.
3839
/// </para>
3940
/// </remarks>
@@ -98,6 +99,7 @@ public AttributeValue(string key, string? value, bool isDirty = true) {
9899
/// populating the topic graph from a persistent data store as a means of indicating the current version for each
99100
/// attribute. This is used when e.g. importing values to determine if the existing value is newer than the source value.
100101
/// </param>
102+
/// <param name="isExtendedAttribute">Determines if the attribute originated from an extended attributes data store.</param>
101103
/// <requires
102104
/// description="The key must be specified for the key/value pair." exception="T:System.ArgumentNullException">
103105
/// !String.IsNullOrWhiteSpace(key)
@@ -107,14 +109,16 @@ internal AttributeValue(
107109
string? value,
108110
bool isDirty,
109111
bool enforceBusinessLogic,
110-
DateTime? lastModified = null
112+
DateTime? lastModified = null,
113+
bool? isExtendedAttribute = null
111114
): this(
112115
key,
113116
value,
114117
isDirty
115118
) {
116119
EnforceBusinessLogic = enforceBusinessLogic;
117120
LastModified = lastModified?? DateTime.Now;
121+
IsExtendedAttribute = isExtendedAttribute;
118122
}
119123

120124
/*==========================================================================================================================
@@ -165,13 +169,14 @@ internal AttributeValue(
165169
/// </summary>
166170
/// <remarks>
167171
/// By default, when a user attempts to update an attribute's value by calling <see
168-
/// cref="AttributeValueCollection.SetValue(String, String, Boolean?, DateTime?)"/>, or when an <see cref="AttributeValue"
169-
/// /> is added to the <see cref="AttributeValueCollection"/>, the <see cref="AttributeValueCollection"/> will
170-
/// automatically attempt to call any corresponding setters on <see cref="Topic"/> (or a derived instance) to ensure that
171-
/// the business logic is enforced. To avoid an infinite loop, however, this is disabled when properties on <see
172-
/// cref="Topic"/> call <see cref="Topic.SetAttributeValue(String, String, Boolean?)"/>. When that happens, the
173-
/// <see cref="EnforceBusinessLogic"/> value is set to false to communicate to the <see cref="AttributeValueCollection"/>
174-
/// that it should not call the local property. This value is only intended for internal use.
172+
/// cref="AttributeValueCollection.SetValue(String, String, Boolean?, DateTime?, Boolean?)"/>, or when an <see
173+
/// cref="AttributeValue"/> is added to the <see cref="AttributeValueCollection"/>, the <see
174+
/// cref="AttributeValueCollection"/> will automatically attempt to call any corresponding setters on <see cref="Topic"/>
175+
/// (or a derived instance) to ensure that the business logic is enforced. To avoid an infinite loop, however, this is
176+
/// disabled when properties on <see cref="Topic"/> call <see cref="Topic.SetAttributeValue(String, String, Boolean?)"/>.
177+
/// When that happens, the <see cref="EnforceBusinessLogic"/> value is set to false to communicate to the <see
178+
/// cref="AttributeValueCollection"/> that it should not call the local property. This value is only intended for internal
179+
/// use.
175180
/// </remarks>
176181
/// <requires description="The value from the getter must be specified." exception="T:System.ArgumentNullException">
177182
/// !String.IsNullOrWhiteSpace(value)
@@ -191,5 +196,36 @@ internal AttributeValue(
191196
/// </summary>
192197
public DateTime LastModified { get; internal set; } = DateTime.Now;
193198

199+
/*==========================================================================================================================
200+
| PROPERTY: IS EXTENDED ATTRIBUTE
201+
\-------------------------------------------------------------------------------------------------------------------------*/
202+
/// <summary>
203+
/// Determines if this attribute originated from a data store as an extended attribute.
204+
/// </summary>
205+
/// <remarks>
206+
/// <para>
207+
/// How an attribute is stored in the underlying repository doesn't impact how the attribute is treated as part of the
208+
/// object model. By tracking this, however, OnTopic is able to evaluate configuration mismatches during <see
209+
/// cref="ITopicRepository.Save"/>. This allows the <see cref="ITopicRepository"/> to effective handle scenarios where
210+
/// the configuration for an <see cref="AttributeDescriptor"/> has changed prior to the last time a <see cref="Topic"/>
211+
/// was saved, and thus change the location where it is stored.
212+
/// </para>
213+
/// <para>
214+
/// This is important because, otherwise, <see cref="ITopicRepository"/> implementations rely primarily on <see
215+
/// cref="IsDirty"/> to determine if a value should be saved. If an attribute's value hasn't changed, but the location
216+
/// it should be stored has, that could potentially result in the attribute being deleted, as the attribute won't show
217+
/// up for when <see cref="TopicRepositoryBase.GetAttributes"/> is called with <c>isDirty</c> set to <c>true</c> and
218+
/// <c>isExtendedAttribute</c> is set to either <c>true</c> or <c>false</c>. By introducing <see
219+
/// cref="IsExtendedAttribute"/>, the <see cref="TopicRepositoryBase"/> is able to detect conflicts between the
220+
/// configuration and the underlying data store, and ensure data is stored appropriately.
221+
/// </para>
222+
/// <para>
223+
/// The <see cref="IsExtendedAttribute"/> property maps to the <see cref="AttributeDescriptor.IsExtendedAttribute"/>
224+
/// property. The former describes where the data was <i>actually</i> stored, whereas the latter describes where the
225+
/// data <i>should</i> be stored.
226+
/// </para>
227+
/// </remarks>
228+
public bool? IsExtendedAttribute { get; internal set; }
229+
194230
} //Class
195231
} //Namespace

0 commit comments

Comments
 (0)