diff --git a/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.cs b/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.cs index b2a0633..1b90215 100644 --- a/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.cs +++ b/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.cs @@ -1308,5 +1308,118 @@ private static void AssertChangeExists(List<(DoubletLink, DoubletLink)> changes, { Assert.Contains(changes, change => change.Item1 == linkBefore && change.Item2 == linkAfter); } + + // New tests for link reference validation + + [Fact] + public void CreateLinkWithNonExistentReference_ShouldThrowException() + { + RunTestWithLinks(links => + { + // Act & Assert - should throw exception for referencing non-existent link 10 + var exception = Assert.Throws(() => + { + ProcessQuery(links, "(() ((1: 10 20)))"); + }); + + Assert.Contains("Invalid reference to non-existent link 10", exception.Message); + }); + } + + [Fact] + public void CreateLinkWithValidSelfReference_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act - should succeed because link 1 references itself + ProcessQuery(links, "(() ((1: 1 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + AssertLinkExists(allLinks, 1, 1, 1); + }); + } + + [Fact] + public void CreateMultipleLinksWithCrossReferences_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act - should succeed because both links are created in the same operation + ProcessQuery(links, "(() ((1: 1 2) (2: 2 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 2); + AssertLinkExists(allLinks, 2, 2, 1); + }); + } + + [Fact] + public void CreateLinkReferencingExistingLink_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Arrange - create first link + ProcessQuery(links, "(() ((1: 1 1)))"); + + // Act - should succeed because link 1 exists + ProcessQuery(links, "(() ((2: 2 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 1); + }); + } + + [Fact] + public void UpdateWithNonExistentReference_ShouldThrowException() + { + RunTestWithLinks(links => + { + // Arrange - create initial link + ProcessQuery(links, "(() ((1: 1 1)))"); + + // Act & Assert - should throw exception for referencing non-existent link 99 + var exception = Assert.Throws(() => + { + ProcessQuery(links, "(((1: 1 1)) ((1: 1 99)))"); + }); + + Assert.Contains("Invalid reference to non-existent link 99", exception.Message); + }); + } + + [Fact] + public void CreateLinkWithVariableReferences_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act - should succeed because variables are not validated + ProcessQuery(links, "(() (($link: $source $target)))"); + + // Assert - one link should be created with variables resolved + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + }); + } + + [Fact] + public void CreateLinkWithWildcardReferences_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act - should succeed because wildcards are not validated + ProcessQuery(links, "(() ((1: * *)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + }); + } } } \ No newline at end of file diff --git a/Foundation.Data.Doublets.Cli/AdvancedMixedQueryProcessor.cs b/Foundation.Data.Doublets.Cli/AdvancedMixedQueryProcessor.cs index a51417b..2c3c07c 100644 --- a/Foundation.Data.Doublets.Cli/AdvancedMixedQueryProcessor.cs +++ b/Foundation.Data.Doublets.Cli/AdvancedMixedQueryProcessor.cs @@ -1,3 +1,4 @@ +using System; using Platform.Delegates; using Platform.Data; using Platform.Data.Doublets; @@ -69,6 +70,19 @@ public static void ProcessQuery(NamedLinksDecorator links, Options options if (restrictionLink.Values?.Count == 0 && (substitutionLink.Values?.Count ?? 0) > 0) { TraceIfEnabled(options, "[ProcessQuery] No restriction, but substitution is non-empty => creation scenario."); + + // VALIDATION: Validate that all references in creation scenario are valid + try + { + var emptyRestrictionPatterns = new List(); + ValidateLinksExistOrWillBeCreated(links, emptyRestrictionPatterns, substitutionLink.Values ?? new List(), options); + } + catch (Exception ex) + { + TraceIfEnabled(options, $"[ProcessQuery] Creation validation failed: {ex.Message}"); + throw; + } + foreach (var linkToCreate in substitutionLink.Values ?? new List()) { var createdId = EnsureNestedLinkCreatedRecursively(links, linkToCreate, options); @@ -84,6 +98,17 @@ public static void ProcessQuery(NamedLinksDecorator links, Options options TraceIfEnabled(options, $"[ProcessQuery] Restriction patterns to parse: {restrictionPatterns.Count}"); TraceIfEnabled(options, $"[ProcessQuery] Substitution patterns to parse: {substitutionPatterns.Count}"); + // VALIDATION: Check that all referenced links exist or will be created + try + { + ValidateLinksExistOrWillBeCreated(links, restrictionPatterns, substitutionPatterns, options); + } + catch (Exception ex) + { + TraceIfEnabled(options, $"[ProcessQuery] Validation failed: {ex.Message}"); + throw; + } + var restrictionInternalPatterns = restrictionPatterns .Select(l => CreatePatternFromLino(l)) .ToList(); @@ -1205,5 +1230,132 @@ private static uint CreateCompositeLink( } return compositeLinkId; } + + /// + /// Validates that all link references in the patterns either exist in the database + /// or will be created as part of the current operation. + /// + private static void ValidateLinksExistOrWillBeCreated( + NamedLinksDecorator links, + IList restrictionPatterns, + IList substitutionPatterns, + Options options) + { + TraceIfEnabled(options, "[ValidateLinksExistOrWillBeCreated] Starting validation"); + + // Collect all link IDs that will be created in this operation + var linkIdsToBeCreated = new HashSet(); + CollectLinkIdsFromPatterns(substitutionPatterns, linkIdsToBeCreated, links); + + TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Links to be created: {string.Join(", ", linkIdsToBeCreated)}"); + + // Validate all references in restriction patterns + ValidateReferencesInPatterns(restrictionPatterns, links, linkIdsToBeCreated, "restriction", options); + + // Validate all references in substitution patterns + ValidateReferencesInPatterns(substitutionPatterns, links, linkIdsToBeCreated, "substitution", options); + + TraceIfEnabled(options, "[ValidateLinksExistOrWillBeCreated] Validation completed"); + } + + /// + /// Collects all specific link IDs that will be created from substitution patterns. + /// + private static void CollectLinkIdsFromPatterns(IList patterns, HashSet linkIds, NamedLinksDecorator links) + { + foreach (var pattern in patterns) + { + CollectLinkIdsFromPattern(pattern, linkIds, links); + } + } + + /// + /// Recursively collects link IDs from a single pattern. + /// Only collects IDs that define links (end with ':') + /// + private static void CollectLinkIdsFromPattern(LinoLink pattern, HashSet linkIds, NamedLinksDecorator links, int depth = 0) + { + // Prevent infinite recursion + if (depth > 10) return; + + // Check if this pattern defines a specific link ID (ends with ':') + if (!string.IsNullOrEmpty(pattern.Id) && pattern.Id.EndsWith(":") && !pattern.Id.StartsWith("$") && pattern.Id != "*:") + { + var cleanId = pattern.Id.Replace(":", ""); + if (uint.TryParse(cleanId, out var linkId)) + { + linkIds.Add(linkId); + } + } + + // Recursively check sub-patterns + if (pattern.Values != null) + { + foreach (var subPattern in pattern.Values) + { + CollectLinkIdsFromPattern(subPattern, linkIds, links, depth + 1); + } + } + } + + /// + /// Validates that all references in the given patterns are valid. + /// + private static void ValidateReferencesInPatterns( + IList patterns, + NamedLinksDecorator links, + HashSet linkIdsToBeCreated, + string patternType, + Options options) + { + foreach (var pattern in patterns) + { + ValidateReferencesInPattern(pattern, links, linkIdsToBeCreated, patternType, options); + } + } + + /// + /// Recursively validates references in a single pattern. + /// Only validates references (numeric IDs without ':' that are not variables or wildcards). + /// + private static void ValidateReferencesInPattern( + LinoLink pattern, + NamedLinksDecorator links, + HashSet linkIdsToBeCreated, + string patternType, + Options options, + int depth = 0) + { + // Prevent infinite recursion + if (depth > 10) return; + + // Validate the pattern's own ID if it's a reference (not a definition, variable, or wildcard) + if (!string.IsNullOrEmpty(pattern.Id) && + !pattern.Id.StartsWith("$") && + pattern.Id != "*" && + !pattern.Id.EndsWith(":")) + { + if (uint.TryParse(pattern.Id, out var linkId)) + { + if (!links.Exists(linkId) && !linkIdsToBeCreated.Contains(linkId)) + { + throw new InvalidOperationException( + $"Invalid reference to non-existent link {linkId} in {patternType} pattern. " + + $"Link {linkId} does not exist and will not be created by this operation." + ); + } + TraceIfEnabled(options, $"[ValidateReferencesInPattern] Link {linkId} reference validated in {patternType} pattern"); + } + } + + // Recursively validate sub-patterns + if (pattern.Values != null) + { + foreach (var subPattern in pattern.Values) + { + ValidateReferencesInPattern(subPattern, links, linkIdsToBeCreated, patternType, options, depth + 1); + } + } + } } } \ No newline at end of file