From d5f36e8ba2504b3f9656df8b8a4b6c97eeceeb8b Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 10 Sep 2025 17:21:27 +0300 Subject: [PATCH 1/4] Initial commit with task details for issue #15 Adding CLAUDE.md with task information for AI processing. This file will be removed when the task is complete. Issue: https://github.com/link-foundation/link-cli/issues/15 --- CLAUDE.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d45f186 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +Issue to solve: https://github.com/link-foundation/link-cli/issues/15 +Your prepared branch: issue-15-d0c58a82 +Your prepared working directory: /tmp/gh-issue-solver-1757514084913 + +Proceed. \ No newline at end of file From f914d71af8f4763802106c4b2aa6ac9ecf57e2c3 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 10 Sep 2025 17:21:43 +0300 Subject: [PATCH 2/4] Remove CLAUDE.md - PR created successfully --- CLAUDE.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index d45f186..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,5 +0,0 @@ -Issue to solve: https://github.com/link-foundation/link-cli/issues/15 -Your prepared branch: issue-15-d0c58a82 -Your prepared working directory: /tmp/gh-issue-solver-1757514084913 - -Proceed. \ No newline at end of file From 3fe7f362084a4e13f9c157d4dce7d315f2bbddad Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 10 Sep 2025 17:35:56 +0300 Subject: [PATCH 3/4] Implement link reference validation to prevent references to non-existent links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add validation to ensure link references either exist or will be created in the current operation - Allow references to links that will become available after the operation (e.g., mutual references) - Prevent invalid references to links that don't exist and won't be created - Add comprehensive test coverage for the validation functionality - Includes proper handling of variables, wildcards, and definitions vs references Fixes #15 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../AdvancedMixedQueryProcessor.cs | 113 +++++++++++++ .../AdvancedMixedQueryProcessor.cs | 152 ++++++++++++++++++ 2 files changed, 265 insertions(+) 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 From 81a519152417c6c23241e08409f9142cdc4b88b1 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 30 Apr 2026 07:35:00 +0000 Subject: [PATCH 4/4] feat: validate missing link references --- README.md | 1 + .../.changeset/validate-missing-references.md | 5 + .../AdvancedMixedQueryProcessor.cs | 102 +++++- .../AdvancedMixedQueryProcessor.cs | 317 ++++++++++++---- .../Foundation.Data.Doublets.Cli/Program.cs | 9 + ...0430_072128_validate_missing_references.md | 5 + rust/src/cli.rs | 12 + rust/src/lib.rs | 5 +- rust/src/link_reference_validator.rs | 343 ++++++++++++++++++ rust/src/link_storage.rs | 9 + rust/src/main.rs | 3 +- rust/src/query_options.rs | 14 + rust/src/query_processor.rs | 79 +++- rust/tests/cli_arguments_tests.rs | 4 + .../query_processor_csharp_parity_tests.rs | 21 +- rust/tests/query_processor_tests.rs | 152 +++++++- 16 files changed, 952 insertions(+), 129 deletions(-) create mode 100644 csharp/.changeset/validate-missing-references.md create mode 100644 rust/changelog.d/20260430_072128_validate_missing_references.md create mode 100644 rust/src/link_reference_validator.rs create mode 100644 rust/src/query_options.rs diff --git a/README.md b/README.md index 218eeb4..662e1e6 100644 --- a/README.md +++ b/README.md @@ -301,6 +301,7 @@ clink '((1: 2 1) (2: 1 2)) ()' --changes --after | `--query` | string | _None_ | `--apply`, `--do`, `-q` | LiNo query for CRUD operation | | `query` (positional) | string | _None_ | _N/A_ | LiNo query for CRUD operation (provided as the first positional argument) | | `--trace` | bool | `false` | `-t` | Enable trace (verbose output) | +| `--auto-create-missing-references` | bool | `false` | _None_ | Create missing numeric and named references as self-referential point links | | `--structure` | uint? | _None_ | `-s` | ID of the link to format its structure | | `--before` | bool | `false` | `-b` | Print the state of the database before applying changes | | `--changes` | bool | `false` | `-c` | Print the changes applied by the query | diff --git a/csharp/.changeset/validate-missing-references.md b/csharp/.changeset/validate-missing-references.md new file mode 100644 index 0000000..42f97f8 --- /dev/null +++ b/csharp/.changeset/validate-missing-references.md @@ -0,0 +1,5 @@ +--- +'Foundation.Data.Doublets.Cli': minor +--- + +Added strict validation for missing numeric and named link references, plus `--auto-create-missing-references` to create missing references as self-referential point links. diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.cs index 41b68b3..ef351b1 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.cs @@ -374,15 +374,16 @@ public void NoExactMatch2LevelNestedLinksTest() RunTestWithLinks(links => { // Arrange - ProcessQuery(links, "() ((1: 1 1))"); + ProcessQueryStrict(links, "() ((1: 1 1) (2: 2 2))"); // Act - ProcessQuery(links, "((1: (1: 1 1) (1: 2 1))) ()"); + ProcessQueryStrict(links, "((1: (1: 1 1) (1: 2 1))) ()"); // Assert var allLinks = GetAllLinks(links); - Assert.Single(allLinks); + Assert.Equal(2, allLinks.Count); AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); }); } @@ -1544,6 +1545,28 @@ private static List GetAllLinks(NamedLinksDecorator links) return allLinks; } + private static void ProcessQuery(NamedLinksDecorator links, string query) + { + ProcessQuery(links, new Options { Query = query }); + } + + private static void ProcessQuery(NamedLinksDecorator links, Options options) + { + options.AutoCreateMissingReferences = true; + Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, options); + } + + private static void ProcessQueryStrict(NamedLinksDecorator links, string query) + { + ProcessQueryStrict(links, new Options { Query = query }); + } + + private static void ProcessQueryStrict(NamedLinksDecorator links, Options options) + { + options.AutoCreateMissingReferences = false; + Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, options); + } + private static void AssertLinkExists(List allLinks, uint index, uint source, uint target) { var link = new DoubletLink(index, source, target); @@ -1565,10 +1588,11 @@ public void CreateLinkWithNonExistentReference_ShouldThrowException() // Act & Assert - should throw exception for referencing non-existent link 10 var exception = Assert.Throws(() => { - ProcessQuery(links, "(() ((1: 10 20)))"); + ProcessQueryStrict(links, "(() ((1: 10 20)))"); }); - Assert.Contains("Invalid reference to non-existent link 10", exception.Message); + Assert.Contains("Invalid reference to non-existent link '10'", exception.Message); + Assert.Contains("--auto-create-missing-references", exception.Message); }); } @@ -1578,7 +1602,7 @@ public void CreateLinkWithValidSelfReference_ShouldSucceed() RunTestWithLinks(links => { // Act - should succeed because link 1 references itself - ProcessQuery(links, "(() ((1: 1 1)))"); + ProcessQueryStrict(links, "(() ((1: 1 1)))"); // Assert var allLinks = GetAllLinks(links); @@ -1593,7 +1617,7 @@ 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)))"); + ProcessQueryStrict(links, "(() ((1: 1 2) (2: 2 1)))"); // Assert var allLinks = GetAllLinks(links); @@ -1609,10 +1633,10 @@ public void CreateLinkReferencingExistingLink_ShouldSucceed() RunTestWithLinks(links => { // Arrange - create first link - ProcessQuery(links, "(() ((1: 1 1)))"); + ProcessQueryStrict(links, "(() ((1: 1 1)))"); // Act - should succeed because link 1 exists - ProcessQuery(links, "(() ((2: 2 1)))"); + ProcessQueryStrict(links, "(() ((2: 2 1)))"); // Assert var allLinks = GetAllLinks(links); @@ -1628,15 +1652,63 @@ public void UpdateWithNonExistentReference_ShouldThrowException() RunTestWithLinks(links => { // Arrange - create initial link - ProcessQuery(links, "(() ((1: 1 1)))"); + ProcessQueryStrict(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)))"); + ProcessQueryStrict(links, "(((1: 1 1)) ((1: 1 99)))"); }); - Assert.Contains("Invalid reference to non-existent link 99", exception.Message); + Assert.Contains("Invalid reference to non-existent link '99'", exception.Message); + }); + } + + [Fact] + public void CreateNamedLinkWithMissingNamedReferences_ShouldThrowException() + { + RunTestWithLinks(links => + { + var exception = Assert.Throws(() => + { + ProcessQueryStrict(links, "(() ((child: father mother)))"); + }); + + Assert.Contains("Invalid reference to non-existent link 'father'", exception.Message); + Assert.Contains("--auto-create-missing-references", exception.Message); + }); + } + + [Fact] + public void CreateLinkWithAutoCreateMissingNumericReferences_ShouldCreatePointLinks() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((20: 10 20)))"); + + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 10, 10, 10); + AssertLinkExists(allLinks, 20, 10, 20); + }); + } + + [Fact] + public void CreateNamedLinkWithAutoCreateMissingNamedReferences_ShouldCreatePointLinks() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((child: father mother)))"); + + var fatherId = links.GetByName("father"); + var motherId = links.GetByName("mother"); + var childId = links.GetByName("child"); + + var allLinks = GetAllLinks(links); + Assert.Equal(3, allLinks.Count); + AssertLinkExists(allLinks, fatherId, fatherId, fatherId); + AssertLinkExists(allLinks, motherId, motherId, motherId); + AssertLinkExists(allLinks, childId, fatherId, motherId); }); } @@ -1646,7 +1718,7 @@ public void CreateLinkWithVariableReferences_ShouldSucceed() RunTestWithLinks(links => { // Act - should succeed because variables are not validated - ProcessQuery(links, "(() (($link: $source $target)))"); + ProcessQueryStrict(links, "(() (($link: $source $target)))"); // Assert - one link should be created with variables resolved var allLinks = GetAllLinks(links); @@ -1660,7 +1732,7 @@ public void CreateLinkWithWildcardReferences_ShouldSucceed() RunTestWithLinks(links => { // Act - should succeed because wildcards are not validated - ProcessQuery(links, "(() ((1: * *)))"); + ProcessQueryStrict(links, "(() ((1: * *)))"); // Assert var allLinks = GetAllLinks(links); @@ -1668,4 +1740,4 @@ public void CreateLinkWithWildcardReferences_ShouldSucceed() }); } } -} \ No newline at end of file +} diff --git a/csharp/Foundation.Data.Doublets.Cli/AdvancedMixedQueryProcessor.cs b/csharp/Foundation.Data.Doublets.Cli/AdvancedMixedQueryProcessor.cs index 2c3c07c..f8f1141 100644 --- a/csharp/Foundation.Data.Doublets.Cli/AdvancedMixedQueryProcessor.cs +++ b/csharp/Foundation.Data.Doublets.Cli/AdvancedMixedQueryProcessor.cs @@ -20,6 +20,11 @@ public class Options /// public bool Trace { get; set; } = false; + /// + /// Creates missing numeric and named references as self-referential point links instead of failing validation. + /// + public bool AutoCreateMissingReferences { get; set; } = false; + public static implicit operator Options(string query) => new Options { Query = query }; } @@ -1165,6 +1170,11 @@ private static uint ResolveLeaf(LinoLink pattern, NamedLinksDecorator link TraceIfEnabled(options, "[EnsureNestedLinkCreatedRecursively] Leaf with '*' => returning ANY."); return anyConstant; } + if (pattern.Id.StartsWith("$")) + { + TraceIfEnabled(options, "[EnsureNestedLinkCreatedRecursively] Variable leaf => returning ANY."); + return anyConstant; + } if (uint.TryParse(pattern.Id, out uint parsedNumber)) { TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Leaf parse => returning {parsedNumber}."); @@ -1224,138 +1234,289 @@ private static uint CreateCompositeLink( var compositeLinkId = EnsureLinkCreated(links, compositeLinkDefinition, options); TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Created or ensured composite link => Index={compositeIndex}, Source={sourceLinkId}, Target={targetLinkId} => Actual ID={compositeLinkId}"); // Assign the name for non-numeric identifiers - if (!string.IsNullOrEmpty(literalIdentifier) && !IsNumericOrStar(literalIdentifier)) + if (!string.IsNullOrEmpty(literalIdentifier) && !IsNumericOrStar(literalIdentifier) && !literalIdentifier.StartsWith("$")) { links.SetName(compositeLinkId, literalIdentifier); } 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, + 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)}"); + var plan = BuildLinkReferencePlan(links, substitutionPatterns); + + TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Numeric links to be created: {string.Join(", ", plan.NumericIdsToBeCreated.OrderBy(id => id))}"); + TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Named links to be created: {string.Join(", ", plan.NamesToBeCreated.OrderBy(name => name, StringComparer.Ordinal))}"); - // Validate all references in restriction patterns - ValidateReferencesInPatterns(restrictionPatterns, links, linkIdsToBeCreated, "restriction", options); - - // Validate all references in substitution patterns - ValidateReferencesInPatterns(substitutionPatterns, links, linkIdsToBeCreated, "substitution", options); + CollectMissingReferences(restrictionPatterns, links, plan, false, "restriction", options); + CollectMissingReferences(substitutionPatterns, links, plan, true, "substitution", options); + + if (plan.MissingReferences.Count > 0) + { + if (!options.AutoCreateMissingReferences) + { + var missing = plan.MissingReferences[0]; + throw new InvalidOperationException( + $"Invalid reference to non-existent link '{missing.Identifier}' in {missing.PatternType} pattern. " + + $"Link '{missing.Identifier}' does not exist and will not be created by this operation. " + + "Use --auto-create-missing-references to create missing references as point links." + ); + } + + AutoCreateMissingReferences(links, plan.MissingReferences, 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) + private sealed class LinkReferencePlan { - foreach (var pattern in patterns) + public HashSet NumericIdsToBeCreated { get; } = new(); + public HashSet NamesToBeCreated { get; } = new(StringComparer.Ordinal); + public List MissingReferences { get; } = new(); + private readonly HashSet _missingReferenceKeys = new(StringComparer.Ordinal); + + public void AddMissingReference(MissingLinkReference reference) { - CollectLinkIdsFromPattern(pattern, linkIds, links); + if (_missingReferenceKeys.Add(reference.Key)) + { + MissingReferences.Add(reference); + } } } - /// - /// 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) + private sealed class MissingLinkReference + { + public required string Identifier { get; init; } + public required string PatternType { get; init; } + public required uint? NumericId { get; init; } + public string Key => NumericId.HasValue ? $"id:{NumericId.Value}" : $"name:{Identifier}"; + } + + private static LinkReferencePlan BuildLinkReferencePlan(NamedLinksDecorator links, IList substitutionPatterns) + { + var plan = new LinkReferencePlan(); + var reservedNumericIds = new HashSet(); + + foreach (var pattern in substitutionPatterns) + { + CollectExplicitDefinitions(pattern, plan, reservedNumericIds); + } + + foreach (var pattern in substitutionPatterns) + { + CollectImplicitDefinitions(pattern, links, plan, reservedNumericIds); + } + + return plan; + } + + private static void CollectExplicitDefinitions(LinoLink pattern, LinkReferencePlan plan, HashSet reservedNumericIds) { - // Prevent infinite recursion - if (depth > 10) return; + if (IsComposite(pattern) && TryGetConcreteIdentifier(pattern.Id, out var identifier)) + { + if (uint.TryParse(identifier, out var linkId)) + { + plan.NumericIdsToBeCreated.Add(linkId); + reservedNumericIds.Add(linkId); + } + else + { + plan.NamesToBeCreated.Add(identifier); + } + } - // Check if this pattern defines a specific link ID (ends with ':') - if (!string.IsNullOrEmpty(pattern.Id) && pattern.Id.EndsWith(":") && !pattern.Id.StartsWith("$") && pattern.Id != "*:") + if (pattern.Values != null) { - var cleanId = pattern.Id.Replace(":", ""); - if (uint.TryParse(cleanId, out var linkId)) + foreach (var subPattern in pattern.Values) { - linkIds.Add(linkId); + CollectExplicitDefinitions(subPattern, plan, reservedNumericIds); } } + } - // Recursively check sub-patterns + private static void CollectImplicitDefinitions( + LinoLink pattern, + NamedLinksDecorator links, + LinkReferencePlan plan, + HashSet reservedNumericIds) + { if (pattern.Values != null) { foreach (var subPattern in pattern.Values) { - CollectLinkIdsFromPattern(subPattern, linkIds, links, depth + 1); + CollectImplicitDefinitions(subPattern, links, plan, reservedNumericIds); } } + + if (IsComposite(pattern) && !TryGetConcreteIdentifier(pattern.Id, out var _ignoredIdentifier)) + { + var nextId = GetNextAvailableLinkId(links, reservedNumericIds); + reservedNumericIds.Add(nextId); + plan.NumericIdsToBeCreated.Add(nextId); + } } - /// - /// Validates that all references in the given patterns are valid. - /// - private static void ValidateReferencesInPatterns( - IList patterns, - NamedLinksDecorator links, - HashSet linkIdsToBeCreated, - string patternType, + private static uint GetNextAvailableLinkId(NamedLinksDecorator links, HashSet reservedNumericIds) + { + uint nextId = 1; + while (links.Exists(nextId) || reservedNumericIds.Contains(nextId)) + { + nextId++; + } + return nextId; + } + + private static void CollectMissingReferences( + IList patterns, + NamedLinksDecorator links, + LinkReferencePlan plan, + bool isSubstitution, + string patternType, Options options) { foreach (var pattern in patterns) { - ValidateReferencesInPattern(pattern, links, linkIdsToBeCreated, patternType, options); + CollectMissingReferences(pattern, links, plan, isSubstitution, 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) + private static void CollectMissingReferences( + LinoLink pattern, + NamedLinksDecorator links, + LinkReferencePlan plan, + bool isSubstitution, + string patternType, + Options options) { - // Prevent infinite recursion - if (depth > 10) return; + var patternIdIsDefinition = isSubstitution && IsComposite(pattern) && TryGetConcreteIdentifier(pattern.Id, out var _ignoredIdentifier); + + if (!patternIdIsDefinition && TryGetConcreteIdentifier(pattern.Id, out var identifier)) + { + ValidateReferenceIdentifier(identifier, links, plan, patternType, options); + } - // 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 (pattern.Values != null) { - if (uint.TryParse(pattern.Id, out var linkId)) + foreach (var subPattern in pattern.Values) { - if (!links.Exists(linkId) && !linkIdsToBeCreated.Contains(linkId)) + CollectMissingReferences(subPattern, links, plan, isSubstitution, patternType, options); + } + } + } + + private static void ValidateReferenceIdentifier( + string identifier, + NamedLinksDecorator links, + LinkReferencePlan plan, + string patternType, + Options options) + { + if (uint.TryParse(identifier, out var linkId)) + { + if (!links.Exists(linkId) && !plan.NumericIdsToBeCreated.Contains(linkId)) + { + plan.AddMissingReference(new MissingLinkReference { - 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"); + Identifier = identifier, + PatternType = patternType, + NumericId = linkId + }); + return; } + TraceIfEnabled(options, $"[ValidateReferencesInPattern] Link {linkId} reference validated in {patternType} pattern"); + return; } - // Recursively validate sub-patterns - if (pattern.Values != null) + if (links.GetByName(identifier) == links.Constants.Null && !plan.NamesToBeCreated.Contains(identifier)) { - foreach (var subPattern in pattern.Values) + plan.AddMissingReference(new MissingLinkReference + { + Identifier = identifier, + PatternType = patternType, + NumericId = null + }); + return; + } + + TraceIfEnabled(options, $"[ValidateReferencesInPattern] Named link '{identifier}' reference validated in {patternType} pattern"); + } + + private static void AutoCreateMissingReferences( + NamedLinksDecorator links, + IList missingReferences, + Options options) + { + foreach (var missing in missingReferences.Where(reference => reference.NumericId.HasValue).OrderBy(reference => reference.NumericId!.Value)) + { + var linkId = missing.NumericId!.Value; + if (links.Exists(linkId)) + { + continue; + } + + TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Auto-creating missing numeric reference {linkId} as point link."); + LinksExtensions.EnsureCreated(links, linkId); + links.Update( + new DoubletLink(linkId, links.Constants.Null, links.Constants.Null), + new DoubletLink(linkId, linkId, linkId), + (beforeState, afterState) => + options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue + ); + } + + foreach (var missing in missingReferences.Where(reference => !reference.NumericId.HasValue).OrderBy(reference => reference.Identifier, StringComparer.Ordinal)) + { + if (links.GetByName(missing.Identifier) != links.Constants.Null) { - ValidateReferencesInPattern(subPattern, links, linkIdsToBeCreated, patternType, options, depth + 1); + continue; } + + TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Auto-creating missing named reference '{missing.Identifier}' as point link."); + EnsureNamedPointLink(links, missing.Identifier, options); } } + + private static void EnsureNamedPointLink(NamedLinksDecorator links, string name, Options options) + { + if (links.GetByName(name) != links.Constants.Null) + { + return; + } + + var newId = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); + links.SetName(newId, name); + links.Update( + new DoubletLink(newId, links.Constants.Null, links.Constants.Null), + new DoubletLink(newId, newId, newId), + (beforeState, afterState) => + options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue + ); + } + + private static bool IsComposite(LinoLink pattern) => pattern.Values?.Count == 2; + + private static bool TryGetConcreteIdentifier(string? id, out string identifier) + { + identifier = string.Empty; + if (string.IsNullOrWhiteSpace(id)) + { + return false; + } + + identifier = id.TrimEnd(':'); + if (identifier.Length == 0 || identifier == "*" || identifier.StartsWith("$")) + { + return false; + } + + return true; + } } -} \ No newline at end of file +} diff --git a/csharp/Foundation.Data.Doublets.Cli/Program.cs b/csharp/Foundation.Data.Doublets.Cli/Program.cs index 6c5c73d..b34f9b2 100644 --- a/csharp/Foundation.Data.Doublets.Cli/Program.cs +++ b/csharp/Foundation.Data.Doublets.Cli/Program.cs @@ -41,6 +41,12 @@ ); traceOption.AddAlias("-t"); +var autoCreateMissingReferencesOption = new Option( + name: "--auto-create-missing-references", + description: "Create missing numeric and named references as self-referential point links", + getDefaultValue: () => false +); + var structureOption = new Option( name: "--structure", description: "ID of the link to format its structure" @@ -81,6 +87,7 @@ queryOption, queryArgument, traceOption, + autoCreateMissingReferencesOption, structureOption, beforeOption, changesOption, @@ -95,6 +102,7 @@ var queryOptionValue = context.ParseResult.GetValueForOption(queryOption) ?? ""; var queryArgumentValue = context.ParseResult.GetValueForArgument(queryArgument) ?? ""; var trace = context.ParseResult.GetValueForOption(traceOption); + var autoCreateMissingReferences = context.ParseResult.GetValueForOption(autoCreateMissingReferencesOption); var structure = context.ParseResult.GetValueForOption(structureOption); var before = context.ParseResult.GetValueForOption(beforeOption); var changes = context.ParseResult.GetValueForOption(changesOption); @@ -137,6 +145,7 @@ { Query = effectiveQuery, Trace = trace, + AutoCreateMissingReferences = autoCreateMissingReferences, ChangesHandler = (beforeLink, afterLink) => { changesList.Add((new DoubletLink(beforeLink), new DoubletLink(afterLink))); diff --git a/rust/changelog.d/20260430_072128_validate_missing_references.md b/rust/changelog.d/20260430_072128_validate_missing_references.md new file mode 100644 index 0000000..2e60df2 --- /dev/null +++ b/rust/changelog.d/20260430_072128_validate_missing_references.md @@ -0,0 +1,5 @@ +--- +bump: minor +--- + +Added strict validation for missing numeric and named link references, plus `--auto-create-missing-references` to create missing references as self-referential point links. diff --git a/rust/src/cli.rs b/rust/src/cli.rs index b2f8111..be27e3e 100644 --- a/rust/src/cli.rs +++ b/rust/src/cli.rs @@ -12,6 +12,7 @@ pub struct Cli { pub query: Option, pub query_arg: Option, pub trace: bool, + pub auto_create_missing_references: bool, pub structure: Option, pub before: bool, pub changes: bool, @@ -26,6 +27,7 @@ impl Default for Cli { query: None, query_arg: None, trace: false, + auto_create_missing_references: false, structure: None, before: false, changes: false, @@ -78,6 +80,11 @@ impl Cli { cli.trace = parse_bool("--trace", value)?; continue; } + if let Some(value) = inline_value(&arg, &["--auto-create-missing-references"]) { + cli.auto_create_missing_references = + parse_bool("--auto-create-missing-references", value)?; + continue; + } if let Some(value) = inline_value(&arg, &["--before"]) { cli.before = parse_bool("--before", value)?; continue; @@ -107,6 +114,9 @@ impl Cli { "-t" | "--trace" => { cli.trace = next_bool_value(&mut args, true)?; } + "--auto-create-missing-references" => { + cli.auto_create_missing_references = next_bool_value(&mut args, true)?; + } "-s" | "--structure" => { let value = next_value(&mut args, &arg)?; cli.structure = Some(parse_link_id(&arg, &value)?); @@ -158,6 +168,8 @@ impl Cli { " LiNo query for CRUD operation\n", " -t, --trace\n", " Enable trace (verbose output)\n", + " --auto-create-missing-references\n", + " Create missing numeric and named references as self-referential point links\n", " -s, --structure \n", " ID of the link to format its structure\n", " -b, --before\n", diff --git a/rust/src/lib.rs b/rust/src/lib.rs index c1560e4..b71cde6 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -19,11 +19,13 @@ pub mod cli; mod error; mod hybrid_reference; mod link; +mod link_reference_validator; mod link_storage; mod lino_link; mod named_links; mod parser; mod pinned_types; +mod query_options; mod query_processor; pub mod sequences; mod unicode_string_storage; @@ -38,5 +40,6 @@ pub use lino_link::LinoLink; pub use named_links::NamedLinks; pub use parser::Parser; pub use pinned_types::PinnedTypes; -pub use query_processor::{QueryOptions, QueryProcessor}; +pub use query_options::QueryOptions; +pub use query_processor::QueryProcessor; pub use unicode_string_storage::UnicodeStringStorage; diff --git a/rust/src/link_reference_validator.rs b/rust/src/link_reference_validator.rs new file mode 100644 index 0000000..9fe13fc --- /dev/null +++ b/rust/src/link_reference_validator.rs @@ -0,0 +1,343 @@ +use anyhow::Result; +use std::collections::HashSet; + +use crate::error::LinkError; +use crate::link::Link; +use crate::link_storage::LinkStorage; +use crate::lino_link::LinoLink; + +pub(crate) struct LinkReferenceValidator { + trace: bool, + auto_create_missing_references: bool, +} + +#[derive(Debug, Default)] +struct LinkReferencePlan { + numeric_ids_to_be_created: HashSet, + names_to_be_created: HashSet, + missing_references: Vec, + missing_reference_keys: HashSet, +} + +impl LinkReferencePlan { + fn add_missing_reference(&mut self, reference: MissingLinkReference) { + let key = reference.key(); + if self.missing_reference_keys.insert(key) { + self.missing_references.push(reference); + } + } +} + +#[derive(Debug, Clone)] +struct MissingLinkReference { + identifier: String, + pattern_type: &'static str, + numeric_id: Option, +} + +impl MissingLinkReference { + fn key(&self) -> String { + self.numeric_id + .map(|id| format!("id:{id}")) + .unwrap_or_else(|| format!("name:{}", self.identifier)) + } +} + +impl LinkReferenceValidator { + pub(crate) fn new(trace: bool, auto_create_missing_references: bool) -> Self { + Self { + trace, + auto_create_missing_references, + } + } + + pub(crate) fn validate_links_exist_or_will_be_created( + &self, + storage: &mut LinkStorage, + restriction_patterns: &[LinoLink], + substitution_patterns: &[LinoLink], + ) -> Result> { + self.trace_msg("[ValidateLinksExistOrWillBeCreated] Starting validation"); + + let mut plan = self.build_link_reference_plan(storage, substitution_patterns); + self.trace_msg(&format!( + "[ValidateLinksExistOrWillBeCreated] Numeric links to be created: {:?}", + plan.numeric_ids_to_be_created + )); + self.trace_msg(&format!( + "[ValidateLinksExistOrWillBeCreated] Named links to be created: {:?}", + plan.names_to_be_created + )); + + self.collect_missing_references( + storage, + &mut plan, + restriction_patterns, + false, + "restriction", + ); + self.collect_missing_references( + storage, + &mut plan, + substitution_patterns, + true, + "substitution", + ); + + if plan.missing_references.is_empty() { + self.trace_msg("[ValidateLinksExistOrWillBeCreated] Validation completed"); + return Ok(vec![]); + } + + if !self.auto_create_missing_references { + let missing = &plan.missing_references[0]; + return Err(LinkError::QueryError(format!( + "Invalid reference to non-existent link '{}' in {} pattern. Link '{}' does not exist and will not be created by this operation. Use --auto-create-missing-references to create missing references as point links.", + missing.identifier, missing.pattern_type, missing.identifier + )) + .into()); + } + + let created = self.auto_create_missing_references(storage, &plan.missing_references)?; + self.trace_msg("[ValidateLinksExistOrWillBeCreated] Validation completed"); + Ok(created) + } + + fn build_link_reference_plan( + &self, + storage: &LinkStorage, + substitution_patterns: &[LinoLink], + ) -> LinkReferencePlan { + let mut plan = LinkReferencePlan::default(); + let mut reserved_numeric_ids = HashSet::new(); + + for pattern in substitution_patterns { + self.collect_explicit_definitions(pattern, &mut plan, &mut reserved_numeric_ids); + } + + for pattern in substitution_patterns { + self.collect_implicit_definitions( + storage, + pattern, + &mut plan, + &mut reserved_numeric_ids, + ); + } + + plan + } + + fn collect_explicit_definitions( + &self, + pattern: &LinoLink, + plan: &mut LinkReferencePlan, + reserved_numeric_ids: &mut HashSet, + ) { + if Self::is_composite_lino(pattern) { + if let Some(identifier) = Self::concrete_identifier(pattern.id.as_deref()) { + if let Ok(link_id) = identifier.parse::() { + plan.numeric_ids_to_be_created.insert(link_id); + reserved_numeric_ids.insert(link_id); + } else { + plan.names_to_be_created.insert(identifier); + } + } + } + + if let Some(values) = &pattern.values { + for sub_pattern in values { + self.collect_explicit_definitions(sub_pattern, plan, reserved_numeric_ids); + } + } + } + + fn collect_implicit_definitions( + &self, + storage: &LinkStorage, + pattern: &LinoLink, + plan: &mut LinkReferencePlan, + reserved_numeric_ids: &mut HashSet, + ) { + if let Some(values) = &pattern.values { + for sub_pattern in values { + self.collect_implicit_definitions(storage, sub_pattern, plan, reserved_numeric_ids); + } + } + + if Self::is_composite_lino(pattern) + && Self::concrete_identifier(pattern.id.as_deref()).is_none() + { + let next_id = Self::next_available_link_id(storage, reserved_numeric_ids); + reserved_numeric_ids.insert(next_id); + plan.numeric_ids_to_be_created.insert(next_id); + } + } + + fn next_available_link_id(storage: &LinkStorage, reserved_numeric_ids: &HashSet) -> u32 { + let mut next_id = 1; + while storage.exists(next_id) || reserved_numeric_ids.contains(&next_id) { + next_id += 1; + } + next_id + } + + fn collect_missing_references( + &self, + storage: &LinkStorage, + plan: &mut LinkReferencePlan, + patterns: &[LinoLink], + is_substitution: bool, + pattern_type: &'static str, + ) { + for pattern in patterns { + self.collect_missing_references_in_pattern( + storage, + plan, + pattern, + is_substitution, + pattern_type, + ); + } + } + + fn collect_missing_references_in_pattern( + &self, + storage: &LinkStorage, + plan: &mut LinkReferencePlan, + pattern: &LinoLink, + is_substitution: bool, + pattern_type: &'static str, + ) { + let pattern_id_is_definition = is_substitution + && Self::is_composite_lino(pattern) + && Self::concrete_identifier(pattern.id.as_deref()).is_some(); + + if !pattern_id_is_definition { + if let Some(identifier) = Self::concrete_identifier(pattern.id.as_deref()) { + self.validate_reference_identifier(storage, plan, &identifier, pattern_type); + } + } + + if let Some(values) = &pattern.values { + for sub_pattern in values { + self.collect_missing_references_in_pattern( + storage, + plan, + sub_pattern, + is_substitution, + pattern_type, + ); + } + } + } + + fn validate_reference_identifier( + &self, + storage: &LinkStorage, + plan: &mut LinkReferencePlan, + identifier: &str, + pattern_type: &'static str, + ) { + if let Ok(link_id) = identifier.parse::() { + if !storage.exists(link_id) && !plan.numeric_ids_to_be_created.contains(&link_id) { + plan.add_missing_reference(MissingLinkReference { + identifier: identifier.to_string(), + pattern_type, + numeric_id: Some(link_id), + }); + return; + } + self.trace_msg(&format!( + "[ValidateReferencesInPattern] Link {link_id} reference validated in {pattern_type} pattern" + )); + return; + } + + if storage.get_by_name(identifier).is_none() + && !plan.names_to_be_created.contains(identifier) + { + plan.add_missing_reference(MissingLinkReference { + identifier: identifier.to_string(), + pattern_type, + numeric_id: None, + }); + return; + } + + self.trace_msg(&format!( + "[ValidateReferencesInPattern] Named link '{identifier}' reference validated in {pattern_type} pattern" + )); + } + + fn auto_create_missing_references( + &self, + storage: &mut LinkStorage, + missing_references: &[MissingLinkReference], + ) -> Result> { + let mut created = Vec::new(); + let mut numeric_references = missing_references + .iter() + .filter_map(|reference| reference.numeric_id) + .collect::>(); + numeric_references.sort_unstable(); + numeric_references.dedup(); + + for link_id in numeric_references { + if storage.exists(link_id) { + continue; + } + + self.trace_msg(&format!( + "[ValidateLinksExistOrWillBeCreated] Auto-creating missing numeric reference {link_id} as point link." + )); + storage.ensure_created(link_id); + storage.update(link_id, link_id, link_id)?; + if let Some(link) = storage.get(link_id) { + created.push(*link); + } + } + + let mut named_references = missing_references + .iter() + .filter(|reference| reference.numeric_id.is_none()) + .map(|reference| reference.identifier.clone()) + .collect::>(); + named_references.sort(); + named_references.dedup(); + + for name in named_references { + if storage.get_by_name(&name).is_some() { + continue; + } + + self.trace_msg(&format!( + "[ValidateLinksExistOrWillBeCreated] Auto-creating missing named reference '{name}' as point link." + )); + let link_id = storage.get_or_create_named(&name); + if let Some(link) = storage.get(link_id) { + created.push(*link); + } + } + + Ok(created) + } + + fn is_composite_lino(lino_link: &LinoLink) -> bool { + lino_link.values_count() == 2 + } + + fn concrete_identifier(id: Option<&str>) -> Option { + let identifier = id?.trim_end_matches(':'); + if identifier.is_empty() || identifier == "*" || identifier.starts_with('$') { + None + } else { + Some(identifier.to_string()) + } + } + + fn trace_msg(&self, msg: &str) { + if self.trace { + eprintln!("{}", msg); + } + } +} diff --git a/rust/src/link_storage.rs b/rust/src/link_storage.rs index 189ea24..f40c094 100644 --- a/rust/src/link_storage.rs +++ b/rust/src/link_storage.rs @@ -163,6 +163,15 @@ impl LinkStorage { return id; } + if self.next_id > id { + let link = Link::new(id, 0, 0); + self.links.insert(id, link); + if self.trace { + eprintln!("[TRACE] Ensured link: ({} 0 0)", id); + } + return id; + } + // Create placeholder links up to the requested ID while self.next_id <= id { let placeholder_id = self.next_id; diff --git a/rust/src/main.rs b/rust/src/main.rs index d3ef112..39a59aa 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -47,7 +47,8 @@ fn main() -> Result<()> { // Process query if provided if let Some(query) = effective_query { if !query.is_empty() { - let processor = QueryProcessor::new(cli.trace); + let processor = QueryProcessor::new(cli.trace) + .with_auto_create_missing_references(cli.auto_create_missing_references); changes_list = processor.process_query(&mut storage, query)?; } } diff --git a/rust/src/query_options.rs b/rust/src/query_options.rs new file mode 100644 index 0000000..7d2ea11 --- /dev/null +++ b/rust/src/query_options.rs @@ -0,0 +1,14 @@ +/// Options for query processing +pub struct QueryOptions { + pub query: String, + pub trace: bool, +} + +impl QueryOptions { + pub fn new(query: &str, trace: bool) -> Self { + Self { + query: query.to_string(), + trace, + } + } +} diff --git a/rust/src/query_processor.rs b/rust/src/query_processor.rs index 14abe43..4272bce 100644 --- a/rust/src/query_processor.rs +++ b/rust/src/query_processor.rs @@ -9,29 +9,16 @@ use std::collections::HashMap; use crate::changes_simplifier::simplify_changes; use crate::error::LinkError; use crate::link::Link; +use crate::link_reference_validator::LinkReferenceValidator; use crate::link_storage::LinkStorage; use crate::lino_link::LinoLink; use crate::parser::Parser; -/// Options for query processing -pub struct QueryOptions { - pub query: String, - pub trace: bool, -} - -impl QueryOptions { - pub fn new(query: &str, trace: bool) -> Self { - Self { - query: query.to_string(), - trace, - } - } -} - /// QueryProcessor handles LiNo query parsing and execution /// Corresponds to AdvancedMixedQueryProcessor in C# pub struct QueryProcessor { trace: bool, + auto_create_missing_references: bool, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -81,7 +68,18 @@ impl ResolvedLink { impl QueryProcessor { /// Creates a new QueryProcessor pub fn new(trace: bool) -> Self { - Self { trace } + Self { + trace, + auto_create_missing_references: false, + } + } + + pub fn with_auto_create_missing_references( + mut self, + auto_create_missing_references: bool, + ) -> Self { + self.auto_create_missing_references = auto_create_missing_references; + self } /// Processes a LiNo query and returns the list of changes @@ -149,6 +147,12 @@ impl QueryProcessor { "[ProcessQuery] No restriction, but substitution is non-empty => creation scenario.", ); if let Some(values) = &substitution_link.values { + changes_list.extend( + self.validate_links_exist_or_will_be_created(storage, &[], values)? + .into_iter() + .map(|link| (None, Some(link))), + ); + for link_to_create in values { let created_id = self.ensure_link_created(storage, link_to_create)?; self.trace_msg(&format!( @@ -169,6 +173,13 @@ impl QueryProcessor { self.trace_msg( "[ProcessQuery] Restriction non-empty, substitution empty => deletion scenario.", ); + let restriction_values = restriction_link.values.as_deref().unwrap_or(&[]); + changes_list.extend( + self.validate_links_exist_or_will_be_created(storage, restriction_values, &[])? + .into_iter() + .map(|link| (None, Some(link))), + ); + let restriction_patterns = self.patterns_from_lino(restriction_link); let mut links_to_delete = Vec::new(); for pattern in &restriction_patterns { @@ -195,11 +206,25 @@ impl QueryProcessor { let restriction_patterns = self.patterns_from_lino(restriction_link); let substitution_patterns = self.patterns_from_lino(substitution_link); + let restriction_values = restriction_link.values.as_deref().unwrap_or(&[]); + let substitution_values = substitution_link.values.as_deref().unwrap_or(&[]); + changes_list.extend( + self.validate_links_exist_or_will_be_created( + storage, + restriction_values, + substitution_values, + )? + .into_iter() + .map(|link| (None, Some(link))), + ); let solutions = self.find_all_solutions(storage, &restriction_patterns); if solutions.is_empty() { self.trace_msg("[ProcessQuery] No solutions found => returning."); - return Ok(vec![]); + if !changes_list.is_empty() { + storage.save()?; + } + return Ok(changes_list); } let all_solutions_no_operation = solutions.iter().all(|solution| { @@ -243,6 +268,20 @@ impl QueryProcessor { Ok(simplified) } + fn validate_links_exist_or_will_be_created( + &self, + storage: &mut LinkStorage, + restriction_patterns: &[LinoLink], + substitution_patterns: &[LinoLink], + ) -> Result> { + LinkReferenceValidator::new(self.trace, self.auto_create_missing_references) + .validate_links_exist_or_will_be_created( + storage, + restriction_patterns, + substitution_patterns, + ) + } + fn patterns_from_lino(&self, lino_link: &LinoLink) -> Vec { let mut patterns = lino_link .values @@ -864,6 +903,10 @@ impl QueryProcessor { // Handle leaf nodes (names or numbers) if !lino_link.has_values() { if let Some(ref id) = lino_link.id { + if id == "*" || Self::is_variable(id) { + return Ok(u32::MAX); + } + // Check if it's a number if let Ok(num) = id.parse::() { return Ok(num); @@ -890,6 +933,8 @@ impl QueryProcessor { storage.ensure_created(num); storage.update(num, source_id, target_id)?; num + } else if id == "*" || Self::is_variable(id) { + storage.get_or_create(source_id, target_id) } else { // Named link let existing = storage.get_by_name(id); diff --git a/rust/tests/cli_arguments_tests.rs b/rust/tests/cli_arguments_tests.rs index f65f0c2..2b392cd 100644 --- a/rust/tests/cli_arguments_tests.rs +++ b/rust/tests/cli_arguments_tests.rs @@ -23,6 +23,7 @@ fn parses_csharp_option_aliases_without_direct_clap_dependency() { "-b", "-c", "-t", + "--auto-create-missing-references", "-s", "42", ]); @@ -33,6 +34,7 @@ fn parses_csharp_option_aliases_without_direct_clap_dependency() { assert!(cli.before); assert!(cli.changes); assert!(cli.trace); + assert!(cli.auto_create_missing_references); assert_eq!(cli.structure, Some(42)); assert_eq!(cli.lino_output.as_deref(), Some("dump.lino")); } @@ -52,6 +54,7 @@ fn parses_inline_alias_values_and_boolean_values() { "--data=db.bin", "--do=(5 6)", "--trace=false", + "--auto-create-missing-references=true", "--before=true", "--changes=on", "--after=0", @@ -61,6 +64,7 @@ fn parses_inline_alias_values_and_boolean_values() { assert_eq!(cli.db, "db.bin"); assert_eq!(cli.query.as_deref(), Some("(5 6)")); assert!(!cli.trace); + assert!(cli.auto_create_missing_references); assert!(cli.before); assert!(cli.changes); assert!(!cli.after); diff --git a/rust/tests/query_processor_csharp_parity_tests.rs b/rust/tests/query_processor_csharp_parity_tests.rs index 4186565..eb31988 100644 --- a/rust/tests/query_processor_csharp_parity_tests.rs +++ b/rust/tests/query_processor_csharp_parity_tests.rs @@ -8,7 +8,7 @@ fn with_storage(test: impl FnOnce(&mut LinkStorage, &QueryProcessor) -> Result<( let temp_file = NamedTempFile::new()?; let db_path = temp_file.path().to_str().unwrap(); let mut storage = LinkStorage::new(db_path, false)?; - let processor = QueryProcessor::new(false); + let processor = QueryProcessor::new(false).with_auto_create_missing_references(true); test(&mut storage, &processor) } @@ -69,12 +69,12 @@ fn test_create_deep_nested_numeric_links_matches_csharp() -> Result<()> { #[test] fn test_delete_by_source_target_pattern_matches_csharp() -> Result<()> { with_storage(|storage, processor| { - processor.process_query(storage, "(() ((1 2)))")?; - processor.process_query(storage, "(() ((2 2)))")?; + processor.process_query(storage, "(() ((1: 1 1) (2: 2 2) (3: 1 2)))")?; processor.process_query(storage, "(((1 2)) ())")?; - assert_eq!(storage.all().len(), 1); + assert_eq!(storage.all().len(), 2); + assert_link_exists(storage, 1, 1, 1); assert_link_exists(storage, 2, 2, 2); Ok(()) }) @@ -177,14 +177,19 @@ fn test_delete_by_names_keeps_leaf_names_matches_csharp() -> Result<()> { } #[test] -fn test_unknown_named_restriction_matches_nothing() -> Result<()> { +fn test_unknown_named_restriction_fails_without_auto_create() -> Result<()> { with_storage(|storage, processor| { processor.process_query(storage, "(() ((known: left right)))")?; - let changes = processor.process_query(storage, "(((unknown: left right)) ())")?; + let strict_processor = QueryProcessor::new(false); + let error = strict_processor + .process_query(storage, "(((unknown: left right)) ())") + .expect_err("unknown named restriction should fail validation"); - assert!(changes.is_empty()); - assert_eq!(storage.all().len(), 3); + assert!(error.to_string().contains("unknown")); + assert!(error + .to_string() + .contains("--auto-create-missing-references")); assert!(storage.get_by_name("known").is_some()); assert!(storage.get_by_name("unknown").is_none()); Ok(()) diff --git a/rust/tests/query_processor_tests.rs b/rust/tests/query_processor_tests.rs index 5bc8800..ce834d4 100644 --- a/rust/tests/query_processor_tests.rs +++ b/rust/tests/query_processor_tests.rs @@ -4,13 +4,17 @@ use anyhow::Result; use link_cli::{LinkStorage, QueryProcessor}; use tempfile::NamedTempFile; +fn auto_processor() -> QueryProcessor { + QueryProcessor::new(false).with_auto_create_missing_references(true) +} + #[test] fn test_query_processor_create() -> Result<()> { let temp_file = NamedTempFile::new()?; let db_path = temp_file.path().to_str().unwrap(); let mut storage = LinkStorage::new(db_path, false)?; - let processor = QueryProcessor::new(false); + let processor = auto_processor(); // Create a simple link: (() ((1 2))) let changes = processor.process_query(&mut storage, "(()((1 2)))")?; @@ -28,7 +32,7 @@ fn test_query_processor_empty() -> Result<()> { let db_path = temp_file.path().to_str().unwrap(); let mut storage = LinkStorage::new(db_path, false)?; - let processor = QueryProcessor::new(false); + let processor = auto_processor(); let changes = processor.process_query(&mut storage, "")?; assert!(changes.is_empty()); @@ -36,6 +40,136 @@ fn test_query_processor_empty() -> Result<()> { Ok(()) } +#[test] +fn test_missing_numeric_reference_fails_without_auto_create() -> Result<()> { + let temp_file = NamedTempFile::new()?; + let db_path = temp_file.path().to_str().unwrap(); + + let mut storage = LinkStorage::new(db_path, false)?; + let processor = QueryProcessor::new(false); + + let error = processor + .process_query(&mut storage, "(() ((1: 10 20)))") + .expect_err("missing numeric references should fail validation"); + + assert!(error.to_string().contains("10")); + assert!(error + .to_string() + .contains("--auto-create-missing-references")); + + Ok(()) +} + +#[test] +fn test_future_numeric_references_succeed_without_auto_create() -> Result<()> { + let temp_file = NamedTempFile::new()?; + let db_path = temp_file.path().to_str().unwrap(); + + let mut storage = LinkStorage::new(db_path, false)?; + let processor = QueryProcessor::new(false); + + processor.process_query(&mut storage, "(() ((1: 1 2) (2: 2 1)))")?; + + assert_eq!(storage.get(1).unwrap().source, 1); + assert_eq!(storage.get(1).unwrap().target, 2); + assert_eq!(storage.get(2).unwrap().source, 2); + assert_eq!(storage.get(2).unwrap().target, 1); + + Ok(()) +} + +#[test] +fn test_auto_create_missing_numeric_reference_creates_point_link() -> Result<()> { + let temp_file = NamedTempFile::new()?; + let db_path = temp_file.path().to_str().unwrap(); + + let mut storage = LinkStorage::new(db_path, false)?; + let processor = auto_processor(); + + processor.process_query(&mut storage, "(() ((20: 10 20)))")?; + + let point = storage + .get(10) + .expect("missing numeric reference should be created"); + assert_eq!(point.source, 10); + assert_eq!(point.target, 10); + let link = storage.get(20).expect("defined link should be created"); + assert_eq!(link.source, 10); + assert_eq!(link.target, 20); + + Ok(()) +} + +#[test] +fn test_auto_create_missing_numeric_reference_fills_existing_gap() -> Result<()> { + let temp_file = NamedTempFile::new()?; + let db_path = temp_file.path().to_str().unwrap(); + + let mut storage = LinkStorage::new(db_path, false)?; + let processor = auto_processor(); + + processor.process_query(&mut storage, "(() ((3: 3 3)))")?; + processor.process_query(&mut storage, "(() ((4: 1 4)))")?; + + let point = storage + .get(1) + .expect("missing lower numeric reference should be created"); + assert_eq!(point.source, 1); + assert_eq!(point.target, 1); + let link = storage.get(4).expect("defined link should be created"); + assert_eq!(link.source, 1); + assert_eq!(link.target, 4); + + Ok(()) +} + +#[test] +fn test_missing_named_reference_fails_without_auto_create() -> Result<()> { + let temp_file = NamedTempFile::new()?; + let db_path = temp_file.path().to_str().unwrap(); + + let mut storage = LinkStorage::new(db_path, false)?; + let processor = QueryProcessor::new(false); + + let error = processor + .process_query(&mut storage, "(() ((child: father mother)))") + .expect_err("missing named references should fail validation"); + + assert!(error.to_string().contains("father")); + assert!(error + .to_string() + .contains("--auto-create-missing-references")); + + Ok(()) +} + +#[test] +fn test_auto_create_missing_named_references_creates_point_links() -> Result<()> { + let temp_file = NamedTempFile::new()?; + let db_path = temp_file.path().to_str().unwrap(); + + let mut storage = LinkStorage::new(db_path, false)?; + let processor = auto_processor(); + + processor.process_query(&mut storage, "(() ((child: father mother)))")?; + + let father_id = storage.get_by_name("father").expect("father should exist"); + let mother_id = storage.get_by_name("mother").expect("mother should exist"); + let child_id = storage.get_by_name("child").expect("child should exist"); + + let father = storage.get(father_id).unwrap(); + assert_eq!(father.source, father_id); + assert_eq!(father.target, father_id); + let mother = storage.get(mother_id).unwrap(); + assert_eq!(mother.source, mother_id); + assert_eq!(mother.target, mother_id); + let child = storage.get(child_id).unwrap(); + assert_eq!(child.source, father_id); + assert_eq!(child.target, mother_id); + + Ok(()) +} + // ============================================ // Link Deduplication Tests (Issue #65) // ============================================ @@ -49,7 +183,7 @@ fn test_deduplicate_duplicate_pair_with_named_links() -> Result<()> { let db_path = temp_file.path().to_str().unwrap(); let mut storage = LinkStorage::new(db_path, false)?; - let processor = QueryProcessor::new(false); + let processor = auto_processor(); processor.process_query(&mut storage, "(() (((m a) (m a))))")?; @@ -96,7 +230,7 @@ fn test_deduplicate_duplicate_pair_with_numeric_links() -> Result<()> { let db_path = temp_file.path().to_str().unwrap(); let mut storage = LinkStorage::new(db_path, false)?; - let processor = QueryProcessor::new(false); + let processor = auto_processor(); processor.process_query(&mut storage, "(() (((1 2) (1 2))))")?; @@ -126,7 +260,7 @@ fn test_deduplicate_triple_duplicate_pair() -> Result<()> { let db_path = temp_file.path().to_str().unwrap(); let mut storage = LinkStorage::new(db_path, false)?; - let processor = QueryProcessor::new(false); + let processor = auto_processor(); processor.process_query(&mut storage, "(() (((a b) ((a b) (a b)))))")?; @@ -171,7 +305,7 @@ fn test_deduplicate_with_different_pairs() -> Result<()> { let db_path = temp_file.path().to_str().unwrap(); let mut storage = LinkStorage::new(db_path, false)?; - let processor = QueryProcessor::new(false); + let processor = auto_processor(); processor.process_query(&mut storage, "(() (((a b) (b a))))")?; @@ -215,7 +349,7 @@ fn test_deduplicate_nested_duplicates() -> Result<()> { let db_path = temp_file.path().to_str().unwrap(); let mut storage = LinkStorage::new(db_path, false)?; - let processor = QueryProcessor::new(false); + let processor = auto_processor(); processor.process_query(&mut storage, "(() ((((x y) (x y)) ((x y) (x y)))))")?; @@ -258,7 +392,7 @@ fn test_deduplicate_named_links_multiple_queries() -> Result<()> { let db_path = temp_file.path().to_str().unwrap(); let mut storage = LinkStorage::new(db_path, false)?; - let processor = QueryProcessor::new(false); + let processor = auto_processor(); // First create named links processor.process_query(&mut storage, "(() ((p: p p)))")?; @@ -304,7 +438,7 @@ fn test_deduplicate_mixed_named_and_numeric() -> Result<()> { let db_path = temp_file.path().to_str().unwrap(); let mut storage = LinkStorage::new(db_path, false)?; - let processor = QueryProcessor::new(false); + let processor = auto_processor(); // First query creates (m a) processor.process_query(&mut storage, "(() ((m a)))")?;