Skip to content

Commit 695664d

Browse files
committed
Merge branch 'feature/empty-repository-support' into develop
Previously, while new topics could be imported into a repository using the new **OnTopic Data Transfer** library, which is exposed via the **OnTopic Editor**, there was no way to access these features when working with an empty repository. That is because there were a number of validation points that would throw an exception if e.g. the repository didn't return any topics, couldn't retrieve the configuration, couldn't find a content type, &c. As a result, there was no way to import a reference configuration to bootstrap the database. This made it difficult to stand up a new OnTopic database. This commit resolves that by removing a lot of those safeguards. Exceptions are still thrown if a specific topic cannot be loaded, but there's a lot more tolerance when loading the entire topic graph, the configuration, or trying to resolve individual content types upon save. When combined with the recent introduction of support for dynamically loading content type and attribute descriptors from the in-memory topic graph (fb3366e), this allows an application, such as **OnTopic Editor**, to load an empty database, then import a reference JSON file with standard configuration topics, thus bootstrapping an OnTopic repository. As part of this, there's also improved support for how topic references are handled. For example, neither `Topic.DerivedTopic` nor `Topic.Relationships` will persist if the target `Topic.Id` until it's been `Save()`d, thus preventing `-1` being written for references to unsaved topics. But when `Save()` is called, it also checks e.g. `DerivedTopic.Id` and, if it's set, updates the `TopicID` reference. This addresses a bug where meaningless `TopicID` references were persisted as `-1` to the repository—then not necessarily updated once those references were saved. This also introduces a new `TopicNotFoundException` which derives from the existing `TopicRepositoryException` so that when an `ITopicRepository` implementation _does_ encounter a missing topic (graph) that merits an exception, it throws a more specific, meaningful exception. As this still derives from the traditional `TopicRepositoryException`, this shouldn't require any updates to downstream implementations—though this also fixed some scenarios where off-target, non-specific exceptions could be thrown by `ITopicRepository` implementations (e.g., a `NullReferenceException` when a topic couldn't be loaded). Technically, those are breaking changes, but as they're fixing exceptions that should never have been thrown, and which weren't documented, we're treating it as a bug fix.
2 parents dcf4eaf + 3e143f1 commit 695664d

13 files changed

Lines changed: 266 additions & 47 deletions

File tree

GitVersion.yml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
assembly-versioning-scheme: MajorMinorPatch
2-
mode: ContinuousDelivery
3-
branches: {}
2+
mode: ContinuousDeployment
3+
branches:
4+
master:
5+
mode: ContinuousDelivery
46
ignore:
5-
sha: []
7+
sha: []

OnTopic.AspNetCore.Mvc.Tests/TopicRepositoryExtensionsTest.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,5 +65,27 @@ public void Load_ByRoute_ReturnsTopic() {
6565

6666
}
6767

68+
/*==========================================================================================================================
69+
| TEST: LOAD: BY ROUTE: RETURNS ROOT TOPIC
70+
\-------------------------------------------------------------------------------------------------------------------------*/
71+
/// <summary>
72+
/// Establishes route data and ensures that the root topic is correctly identified based on that route.
73+
/// </summary>
74+
[TestMethod]
75+
public void Load_ByRoute_ReturnsRootTopic() {
76+
77+
var routes = new RouteData();
78+
var topic = _topicRepository.Load("Root");
79+
80+
routes.Values.Add("path", "Root/");
81+
82+
var currentTopic = _topicRepository.Load(routes);
83+
84+
Assert.IsNotNull(currentTopic);
85+
Assert.ReferenceEquals(topic, currentTopic);
86+
Assert.AreEqual<string>("Root", currentTopic.Key);
87+
88+
}
89+
6890
} //Class
6991
} //Namespace

OnTopic.Data.Sql.Database/Functions/GetTopicID.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ BEGIN
1919
-- DECLARE AND DEFINE VARIABLES
2020
------------------------------------------------------------------------------------------------------------------------------
2121
DECLARE @TopicID INT
22+
SET @TopicID = -1
2223

2324
------------------------------------------------------------------------------------------------------------------------------
2425
-- GET TOPIC ID BASED ON TOPIC KEY

OnTopic.Data.Sql/SqlCommandExtensions.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,17 @@ internal static class SqlCommandExtensions {
2727
/// Retrieves the return code from a stored procedure.
2828
/// </summary>
2929
/// <param name="command">The SQL command object.</param>
30+
/// <param name="sqlParameter">The name of the SQL parameter to retrieve as the return code.</param>
3031
internal static int GetReturnCode(this SqlCommand command, string sqlParameter = "ReturnCode") {
3132
Contract.Assume<InvalidOperationException>(
3233
command.Parameters.Contains($"@{sqlParameter}"),
3334
$"The call to the {command.CommandText} stored procedure did not return the expected 'ReturnCode' parameter."
3435
);
3536
var returnCode = command.Parameters[$"@{sqlParameter}"].Value.ToString();
36-
return Int32.Parse(returnCode, CultureInfo.InvariantCulture);
37+
if (Int32.TryParse(returnCode, NumberStyles.Integer, CultureInfo.InvariantCulture, out var returnValue)) {
38+
return returnValue;
39+
}
40+
return -1;
3741
}
3842

3943
/*==========================================================================================================================
@@ -43,9 +47,7 @@ internal static int GetReturnCode(this SqlCommand command, string sqlParameter =
4347
/// Adds a SQL parameter to a command object, additionally setting the specified parameter direction.
4448
/// </summary>
4549
/// <param name="command">The SQL command object.</param>
46-
/// <param name="sqlParameter">The SQL parameter.</param>
47-
/// <param name="paramDirection">The SQL parameter's directional setting (input-only, output-only, etc.).</param>
48-
/// <param name="sqlDbType">The SQL field data type.</param>
50+
/// <param name="sqlParameter">The name of the SQL output parameter.</param>
4951
internal static void AddOutputParameter(this SqlCommand command, string sqlParameter = "ReturnCode") =>
5052
AddParameter(command, sqlParameter, null, SqlDbType.Int, ParameterDirection.ReturnValue);
5153

OnTopic.Data.Sql/SqlTopicRepository.cs

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ public override Topic Load(string? topicKey = null, bool isRecursive = true) {
8080
\-----------------------------------------------------------------------------------------------------------------------*/
8181
var connection = new SqlConnection(_connectionString);
8282
var command = new SqlCommand("GetTopicID", connection);
83-
int topicId;
83+
var topicId = -1;
8484

8585
command.CommandType = CommandType.StoredProcedure;
8686

@@ -117,6 +117,13 @@ public override Topic Load(string? topicKey = null, bool isRecursive = true) {
117117
connection.Close();
118118
}
119119

120+
/*------------------------------------------------------------------------------------------------------------------------
121+
| Validate results
122+
\-----------------------------------------------------------------------------------------------------------------------*/
123+
if (topicId < 0) {
124+
throw new TopicNotFoundException(topicKey);
125+
}
126+
120127
/*------------------------------------------------------------------------------------------------------------------------
121128
| Return topic
122129
\-----------------------------------------------------------------------------------------------------------------------*/
@@ -173,7 +180,12 @@ public override Topic Load(int topicId, bool isRecursive = true) {
173180
| Validate results
174181
\-----------------------------------------------------------------------------------------------------------------------*/
175182
if (topic == null) {
176-
throw new NullReferenceException($"Load() was unable to successfully load the topic with the TopicID '{topicId}'");
183+
if (topicId == -1) {
184+
topic = TopicFactory.Create("Root", "Container");
185+
}
186+
else {
187+
throw new TopicNotFoundException(topicId);
188+
}
177189
}
178190

179191
/*------------------------------------------------------------------------------------------------------------------------
@@ -242,7 +254,7 @@ public override Topic Load(int topicId, DateTime version) {
242254
/*------------------------------------------------------------------------------------------------------------------------
243255
| Return objects
244256
\-----------------------------------------------------------------------------------------------------------------------*/
245-
return topic?? throw new NullReferenceException("The specified Topic version could not be loaded");
257+
return topic?? throw new TopicNotFoundException(topicId);
246258

247259
}
248260

@@ -257,18 +269,6 @@ public override int Save([NotNull]Topic topic, bool isRecursive = false, bool is
257269
\-----------------------------------------------------------------------------------------------------------------------*/
258270
base.Save(topic, isRecursive, isDraft);
259271

260-
/*------------------------------------------------------------------------------------------------------------------------
261-
| Validate content type
262-
\-----------------------------------------------------------------------------------------------------------------------*/
263-
var contentTypes = GetContentTypeDescriptors();
264-
var contentType = topic.Attributes.GetValue("ContentType", "Page")?? "Page";
265-
var contentTypeDescriptor = contentTypes.GetTopic(contentType) as ContentTypeDescriptor;
266-
267-
Contract.Assume(
268-
contentTypeDescriptor,
269-
$"The Topics repository or database does not contain a ContentTypeDescriptor for the {contentType} content type."
270-
);
271-
272272
/*------------------------------------------------------------------------------------------------------------------------
273273
| Establish attribute containers with schema
274274
\-----------------------------------------------------------------------------------------------------------------------*/
@@ -596,7 +596,7 @@ private static string PersistRelations(Topic topic, SqlConnection connection, bo
596596
CommandType = CommandType.StoredProcedure
597597
};
598598

599-
foreach (var targetTopicId in scope.Select<Topic, int>(m => m.Id)) {
599+
foreach (var targetTopicId in scope.Where(t => t.Id > 0).Select<Topic, int>(m => m.Id)) {
600600
var record = targetIds.NewRow();
601601
record["TopicID"] = targetTopicId;
602602
targetIds.Rows.Add(record);

OnTopic.TestDoubles/StubTopicRepository.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ public StubTopicRepository() : base() {
9797
/*------------------------------------------------------------------------------------------------------------------------
9898
| Return objects
9999
\-----------------------------------------------------------------------------------------------------------------------*/
100-
return topic?? throw new NullReferenceException("The specified Topic version could not be loaded");
100+
return topic?? throw new TopicNotFoundException(topicId);
101101

102102
}
103103

OnTopic.Tests/TopicQueryingTest.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,25 @@ public void GetRootTopic_ReturnsRootTopic() {
6464

6565
}
6666

67+
/*==========================================================================================================================
68+
| TEST: GET BY UNIQUE KEY: ROOT KEY: RETURNS ROOT TOPIC
69+
\-------------------------------------------------------------------------------------------------------------------------*/
70+
/// <summary>
71+
/// Given a root <see cref="Topic"/>, returns the root <see cref="Topic"/>.
72+
/// </summary>
73+
[TestMethod]
74+
public void GetByUniqueKey_RootKey_ReturnsRootTopic() {
75+
76+
var parentTopic = TopicFactory.Create("ParentTopic", "Page", 1);
77+
_ = TopicFactory.Create("ChildTopic", "Page", 2, parentTopic);
78+
79+
var foundTopic = parentTopic.GetByUniqueKey("ParentTopic");
80+
81+
Assert.IsNotNull(foundTopic);
82+
Assert.ReferenceEquals(parentTopic, foundTopic);
83+
84+
}
85+
6786
/*==========================================================================================================================
6887
| TEST: GET BY UNIQUE KEY: VALID KEY: RETURNS TOPIC
6988
\-------------------------------------------------------------------------------------------------------------------------*/

OnTopic.Tests/TopicTest.cs

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using OnTopic.Metadata;
1010
using Microsoft.VisualStudio.TestTools.UnitTesting;
1111
using OnTopic.Collections;
12+
using OnTopic.Attributes;
1213

1314
namespace OnTopic.Tests {
1415

@@ -278,21 +279,71 @@ public void LastModified_UpdateValue_ReturnsExpectedValue() {
278279
Assert.AreEqual<DateTime>(lastModified, topic.LastModified);
279280

280281
}
282+
281283
/*==========================================================================================================================
282284
| TEST: DERIVED TOPIC: UPDATE VALUE: RETURNS EXPECTED VALUE
283285
\-------------------------------------------------------------------------------------------------------------------------*/
284286
/// <summary>
285-
/// Sets a derived topic, and ensures it is referenced correctly.
287+
/// Sets a derived topic to a topic entity, then replaces the references with a new topic entity. Ensures that both the
288+
/// derived topic as well as the underlying <see cref="AttributeValue"/> correctly reference the new value.
286289
/// </summary>
287290
[TestMethod]
288291
public void DerivedTopic_UpdateValue_ReturnsExpectedValue() {
289292

293+
var topic = TopicFactory.Create("Topic", "Page");
294+
var firstDerivedTopic = TopicFactory.Create("DerivedTopic", "Page");
295+
var secondDerivedTopic = TopicFactory.Create("DerivedTopic", "Page", 1);
296+
var finalDerivedTopic = TopicFactory.Create("DerivedTopic", "Page", 2);
297+
298+
topic.DerivedTopic = firstDerivedTopic;
299+
topic.DerivedTopic = secondDerivedTopic;
300+
topic.DerivedTopic = finalDerivedTopic;
301+
302+
Assert.ReferenceEquals(topic.DerivedTopic, finalDerivedTopic);
303+
Assert.AreEqual<int>(2, topic.Attributes.GetInteger("TopicID", 0));
304+
305+
}
306+
307+
/*==========================================================================================================================
308+
| TEST: DERIVED TOPIC: UNSAVED VALUE: RETURNS EXPECTED VALUE
309+
\-------------------------------------------------------------------------------------------------------------------------*/
310+
/// <summary>
311+
/// Sets a derived topic to an unsaved topic entity. Ensures that the derived topic is correctly set, but that the <see
312+
/// cref="Topic.Id"/> is not persisted as an underlying <see cref="AttributeValue"/>.
313+
/// </summary>
314+
[TestMethod]
315+
public void DerivedTopic_UnsavedValue_ReturnsExpectedValue() {
316+
290317
var topic = TopicFactory.Create("Topic", "Page");
291318
var derivedTopic = TopicFactory.Create("DerivedTopic", "Page");
292319

293320
topic.DerivedTopic = derivedTopic;
294321

295322
Assert.ReferenceEquals(topic.DerivedTopic, derivedTopic);
323+
Assert.AreEqual<int>(-2, topic.Attributes.GetInteger("TopicID", -2));
324+
325+
}
326+
327+
/*==========================================================================================================================
328+
| TEST: DERIVED TOPIC: RESAVED VALUE: RETURNS EXPECTED VALUE
329+
\-------------------------------------------------------------------------------------------------------------------------*/
330+
/// <summary>
331+
/// Sets a derived topic to an unsaved topic entity, then saves the entity and reestablishes the relationship. Ensures
332+
/// that the derived topic is correctly set, including the <see cref="Topic.Id"/> as an underlying <see
333+
/// cref="AttributeValue"/>.
334+
/// </summary>
335+
[TestMethod]
336+
public void DerivedTopic_ResavedValue_ReturnsExpectedValue() {
337+
338+
var topic = TopicFactory.Create("Topic", "Page");
339+
var derivedTopic = TopicFactory.Create("DerivedTopic", "Page");
340+
341+
topic.DerivedTopic = derivedTopic;
342+
derivedTopic.Id = 5;
343+
topic.DerivedTopic = derivedTopic;
344+
345+
Assert.ReferenceEquals(topic.DerivedTopic, derivedTopic);
346+
Assert.AreEqual<int>(5, topic.Attributes.GetInteger("TopicID", -2));
296347

297348
}
298349

OnTopic/GlobalSuppressions.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,8 @@
33
// Project-level suppressions either have no target or are given
44
// a specific target and scoped to a namespace, type, member, etc.
55

6-
[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1307:Specify StringComparison", Justification = "Invalid overload; known bug in code analysis", Scope = "member", Target = "~P:OnTopic.Metadata.AttributeDescriptor.Configuration")]
7-
[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1307:Specify StringComparison", Justification = "Invalid overload; known bug in code analysis", Scope = "member", Target = "~M:OnTopic.Topic.GetWebPath~System.String")]
6+
using System.Diagnostics.CodeAnalysis;
7+
8+
[assembly: SuppressMessage("Globalization", "CA1307:Specify StringComparison", Justification = "Invalid overload; known bug in code analysis", Scope = "member", Target = "~P:OnTopic.Metadata.AttributeDescriptor.Configuration")]
9+
[assembly: SuppressMessage("Globalization", "CA1307:Specify StringComparison", Justification = "Invalid overload; known bug in code analysis", Scope = "member", Target = "~M:OnTopic.Topic.GetWebPath~System.String")]
10+
[assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "False positive; apparent bug in code analysis", Scope = "member", Target = "~M:OnTopic.Repositories.TopicRepositoryBase.GetContentTypeDescriptors~OnTopic.Metadata.ContentTypeDescriptorCollection")]

OnTopic/Querying/TopicExtensions.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,13 @@ public static Topic GetRootTopic(this Topic topic) {
193193
\-----------------------------------------------------------------------------------------------------------------------*/
194194
var currentTopic = (Topic?)topic.GetRootTopic();
195195

196+
/*------------------------------------------------------------------------------------------------------------------------
197+
| Handle request for root
198+
\-----------------------------------------------------------------------------------------------------------------------*/
199+
if (currentTopic!.Key.Equals(uniqueKey, StringComparison.InvariantCultureIgnoreCase)) {
200+
return currentTopic;
201+
}
202+
196203
/*------------------------------------------------------------------------------------------------------------------------
197204
| Process keys
198205
\-----------------------------------------------------------------------------------------------------------------------*/

0 commit comments

Comments
 (0)