diff --git a/README.md b/README.md index a5e7346..a0f1b2d 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,24 @@ When links do not have names, exported references are plain link numbers: (2: 1 2) ``` +## Persistent transformation triggers + +Store a query as a trigger with `--always` to apply it after later write operations: + +```bash +clink --db graph.links --always '(((1: 1 1)) ((1: 1 2)))' +clink --db graph.links --auto-create-missing-references '() ((1: 1 1))' --after +``` + +Use `--once` for a trigger that deletes itself after the first successful application, and `--never` to remove matching stored triggers: + +```bash +clink --db graph.links --once '(((1: 1 1)) ((1: 1 2)))' +clink --db graph.links --never '(((1: 1 1)) ((1: 1 2)))' +``` + +Triggers are stored as binary links using the structure `(Always ((Condition ...) (Substitution ...)))` or `(Once ((Condition ...) (Substitution ...)))`. By default they are kept in a companion `.triggers.links` file, such as `graph.triggers.links` for `graph.links`. Use `--triggers-file path/to/triggers.links` to choose a different companion file, `--triggers` to enable trigger evaluation explicitly, or `--embed-triggers` to store trigger links in the main database file. + ## Update single link Update link with index 1 and source 1 and target 1, changing target to 2. @@ -320,6 +338,12 @@ clink '((1: 2 1) (2: 1 2)) ()' --changes --after | `--changes` | bool | `false` | `-c` | Print the changes applied by the query | | `--after` | bool | `false` | `--links`, `-a` | Print the state of the database after applying changes | | `--out` | string | _None_ | `--export`, `--lino-output` | Write the complete database as a LiNo file | +| `--always` | bool | `false` | _None_ | Store the query as an always-on persistent transformation trigger | +| `--once` | bool | `false` | _None_ | Store the query as a one-shot persistent transformation trigger | +| `--never` | bool | `false` | _None_ | Remove stored persistent transformation triggers matching the query | +| `--triggers` | bool | `false` | _None_ | Enable persistent transformation triggers for the command | +| `--triggers-file` | string | `.triggers.links` | _None_ | Path to the persistent transformation trigger links database | +| `--embed-triggers` | bool | `false` | _None_ | Store persistent transformation triggers in the main links database | ## For developers and debugging diff --git a/csharp/.changeset/add-persistent-transformation-triggers.md b/csharp/.changeset/add-persistent-transformation-triggers.md new file mode 100644 index 0000000..e0031a9 --- /dev/null +++ b/csharp/.changeset/add-persistent-transformation-triggers.md @@ -0,0 +1,5 @@ +--- +'Foundation.Data.Doublets.Cli': minor +--- + +Added binary links-backed persistent transformation triggers with `--always`, `--once`, `--never`, `--triggers-file`, and `--embed-triggers`. diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/CliExportIntegrationTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/CliExportIntegrationTests.cs index cc74dea..01e6347 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/CliExportIntegrationTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/CliExportIntegrationTests.cs @@ -112,6 +112,44 @@ await File.WriteAllLinesAsync(inputPath, new[] } } + [Fact] + public async Task AlwaysTriggerOption_StoresTriggerAndAppliesItOnLaterChange() + { + var tempDirectory = CreateTempDirectory(); + + try + { + var dbPath = Path.Combine(tempDirectory, "triggered.links"); + var triggersPath = Path.Combine(tempDirectory, "triggers.links"); + var outputPath = Path.Combine(tempDirectory, "triggered.lino"); + + AssertClinkSucceeded(await RunClinkAsync( + "--db", + dbPath, + "--triggers-file", + triggersPath, + "--always", + "(((1: 1 1)) ((1: 1 2)))")); + + var result = await RunClinkAsync( + "--db", + dbPath, + "--triggers-file", + triggersPath, + "--auto-create-missing-references", + "() ((1: 1 1))", + "--export", + outputPath); + + AssertClinkSucceeded(result); + Assert.Equal(new[] { "(1: 1 2)", "(2: 2 2)" }, File.ReadAllLines(outputPath)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + private static async Task RunClinkAsync(params string[] clinkArguments) { var csharpDirectory = FindCsharpDirectory(); diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/PersistentTransformationDecoratorTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/PersistentTransformationDecoratorTests.cs new file mode 100644 index 0000000..10319dc --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/PersistentTransformationDecoratorTests.cs @@ -0,0 +1,117 @@ +using Platform.Data; +using Platform.Data.Doublets; + +using DoubletLink = Platform.Data.Doublets.Link; + +namespace Foundation.Data.Doublets.Cli.Tests.Tests +{ + public class PersistentTransformationDecoratorTests + { + [Fact] + public void AlwaysTriggerIsStoredInLinksAndAppliedAfterWrite() + { + RunWithPersistentLinks((links, triggerLinks) => + { + links.StoreTrigger(PersistentTransformationKind.Always, "(((1: 1 1)) ((1: 1 2)))"); + + var allTriggerLinks = AllLinks(triggerLinks); + var alwaysId = triggerLinks.GetByName("Always"); + Assert.NotEqual(triggerLinks.Constants.Null, alwaysId); + Assert.Contains(allTriggerLinks, link => link.Source == alwaysId && link.Target != alwaysId); + + Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, new Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.Options + { + Query = "(() ((1: 1 1)))", + AutoCreateMissingReferences = true + }); + + Assert.Contains(AllLinks(links), link => link.Index == 1 && link.Source == 1 && link.Target == 2); + }); + } + + [Fact] + public void OnceTriggerDeletesItselfAfterFirstMatch() + { + RunWithPersistentLinks((links, triggerLinks) => + { + links.StoreTrigger(PersistentTransformationKind.Once, "(((1: 1 1)) ((1: 1 2)))"); + + Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, new Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.Options + { + Query = "(() ((1: 1 1)))", + AutoCreateMissingReferences = true + }); + + Assert.DoesNotContain(links.GetTriggers(), trigger => trigger.Kind == PersistentTransformationKind.Once); + + Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, new Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.Options + { + Query = "(((1: 1 2)) ((1: 1 1)))", + AutoCreateMissingReferences = true + }); + + Assert.Contains(AllLinks(links), link => link.Index == 1 && link.Source == 1 && link.Target == 1); + }); + } + + [Fact] + public void NeverRemovesMatchingStoredTrigger() + { + RunWithPersistentLinks((links, triggerLinks) => + { + links.StoreTrigger(PersistentTransformationKind.Always, "(((1: 1 1)) ((1: 1 2)))"); + + var removed = links.RemoveTriggers("(((1: 1 1)) ((1: 1 2)))"); + + Assert.Equal(1, removed); + Assert.Empty(links.GetTriggers()); + }); + } + + private static void RunWithPersistentLinks(Action> action) + { + var dataFile = Path.GetTempFileName(); + var triggerFile = Path.GetTempFileName(); + NamedTypesDecorator? dataLinks = null; + NamedTypesDecorator? triggerLinks = null; + try + { + dataLinks = new NamedTypesDecorator(dataFile); + triggerLinks = new NamedTypesDecorator(triggerFile); + var links = new PersistentTransformationDecorator(dataLinks, triggerLinks) + { + AutoCreateMissingReferences = true + }; + + action(links, triggerLinks); + } + finally + { + DeleteIfExists(dataFile); + DeleteIfExists(triggerFile); + if (dataLinks is not null) + { + DeleteIfExists(dataLinks.NamedLinksDatabaseFileName); + } + if (triggerLinks is not null) + { + DeleteIfExists(triggerLinks.NamedLinksDatabaseFileName); + } + } + } + + private static List AllLinks(INamedTypesLinks links) + { + var any = links.Constants.Any; + return links.All(new DoubletLink(any, any, any)).Select(link => new DoubletLink(link)).ToList(); + } + + private static void DeleteIfExists(string path) + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + } +} diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/UnicodeStringStorageTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/UnicodeStringStorageTests.cs index 9ff0c1c..254f499 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/UnicodeStringStorageTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/UnicodeStringStorageTests.cs @@ -224,6 +224,20 @@ public void NameIsRemovedWhenExternalReferenceIsDeletedTest() }); } + [Fact] + public void ExternalReferenceCanUseNameThatAlreadyExistsInternallyTest() + { + RunTestWithLinks(links => + { + var storage = new UnicodeStringStorage(links); + var externalRef = links.Create(new uint[] { 0, 0 }); + + storage.NamedLinks.SetNameForExternalReference(externalRef, "Type"); + + Assert.Equal(externalRef, storage.NamedLinks.GetExternalReferenceByName("Type")); + }); + } + // Helper method to create a test environment with a temporary file private static void RunTestWithLinks(Action> testAction) { @@ -242,4 +256,4 @@ private static void RunTestWithLinks(Action> testAction) } } } -} \ No newline at end of file +} diff --git a/csharp/Foundation.Data.Doublets.Cli/NamedLinks.cs b/csharp/Foundation.Data.Doublets.Cli/NamedLinks.cs index 8288268..6761581 100644 --- a/csharp/Foundation.Data.Doublets.Cli/NamedLinks.cs +++ b/csharp/Foundation.Data.Doublets.Cli/NamedLinks.cs @@ -82,15 +82,25 @@ public TLinkAddress GetByName(string name) public TLinkAddress GetExternalReferenceByName(string name) { - var reference = (Hybrid)GetByName(name); - if (reference.IsExternal) + var nameSequence = _createString(name); + var nameLink = _links.SearchOrDefault(_nameType, nameSequence); + if (nameLink.Equals(_links.Constants.Null)) { - return TLinkAddress.CreateTruncating(reference.AbsoluteValue); + return _links.Constants.Null; } - else + + var any = _links.Constants.Any; + var query = new Link(any, any, nameLink); + foreach (var link in _links.All(query)) { - return _links.Constants.Null; + var reference = (Hybrid)_links.GetSource(link); + if (reference.IsExternal) + { + return TLinkAddress.CreateTruncating(reference.AbsoluteValue); + } } + + return _links.Constants.Null; } public void RemoveName(TLinkAddress link) diff --git a/csharp/Foundation.Data.Doublets.Cli/PersistentTransformationDecorator.cs b/csharp/Foundation.Data.Doublets.Cli/PersistentTransformationDecorator.cs new file mode 100644 index 0000000..a868af5 --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli/PersistentTransformationDecorator.cs @@ -0,0 +1,430 @@ +using Link.Foundation.Links.Notation; +using Platform.Data; +using Platform.Data.Doublets; +using Platform.Data.Doublets.Decorators; +using Platform.Delegates; + +using DoubletLink = Platform.Data.Doublets.Link; +using LinoLink = Link.Foundation.Links.Notation.Link; +using QueryProcessor = Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor; + +namespace Foundation.Data.Doublets.Cli; + +public enum PersistentTransformationKind +{ + Once, + Always +} + +public sealed record PersistentTransformation( + uint Root, + PersistentTransformationKind Kind, + string Condition, + string Substitution) +{ + public string Query => $"({Condition} {Substitution})"; +} + +public sealed class PersistentTransformationDecorator : LinksDecoratorBase, INamedTypesLinks +{ + private const string InternalNamePrefix = "__persistent_transformation:"; + + private readonly INamedTypesLinks _namedLinks; + private readonly INamedTypesLinks _triggerLinks; + private readonly bool _trace; + private bool _applyingTriggers; + private bool _suppressTriggers; + + public bool AutoCreateMissingReferences { get; set; } + + public PersistentTransformationDecorator( + INamedTypesLinks links, + INamedTypesLinks triggerLinks, + bool trace = false) + : base(links) + { + _namedLinks = links; + _triggerLinks = triggerLinks; + _trace = trace; + } + + public static string MakeTriggersDatabaseFilename(string databaseFilename) + { + var filenameWithoutExtension = Path.GetFileNameWithoutExtension(databaseFilename); + var directory = Path.GetDirectoryName(databaseFilename); + return Path.Combine(directory ?? string.Empty, $"{filenameWithoutExtension}.triggers.links"); + } + + public uint StoreTrigger(PersistentTransformationKind kind, string query) + { + var parsed = PersistentTransformationQuery.Parse(query); + return WithoutTriggerApplication(() => + { + var schema = EnsureSchema(); + var conditionText = EnsureNamedPoint(_triggerLinks, ConditionTextName(parsed.Condition)); + var substitutionText = EnsureNamedPoint(_triggerLinks, SubstitutionTextName(parsed.Substitution)); + var conditionRecord = _triggerLinks.GetOrCreate(schema.Condition, conditionText); + var substitutionRecord = _triggerLinks.GetOrCreate(schema.Substitution, substitutionText); + var payload = _triggerLinks.GetOrCreate(conditionRecord, substitutionRecord); + var triggerType = kind == PersistentTransformationKind.Always ? schema.Always : schema.Once; + var root = _triggerLinks.GetOrCreate(triggerType, payload); + Trace($"Stored {kind} trigger #{root}: {parsed.Query}"); + return root; + }); + } + + public int RemoveTriggers(string query) + { + var parsed = PersistentTransformationQuery.Parse(query); + return WithoutTriggerApplication(() => + { + var matchingTriggers = GetTriggers() + .Where(trigger => trigger.Condition == parsed.Condition && trigger.Substitution == parsed.Substitution) + .ToList(); + + foreach (var trigger in matchingTriggers) + { + DeleteTriggerRoot(trigger.Root); + } + + return matchingTriggers.Count; + }); + } + + public IReadOnlyList GetTriggers() + { + if (!TryGetSchema(out var schema)) + { + return []; + } + + var linksByIndex = AllLinks(_triggerLinks).ToDictionary(link => link.Index); + var triggers = new List(); + + foreach (var link in linksByIndex.Values.OrderBy(link => link.Index)) + { + var kind = link.Source == schema.Always + ? PersistentTransformationKind.Always + : link.Source == schema.Once + ? PersistentTransformationKind.Once + : (PersistentTransformationKind?)null; + + if (kind is null || !linksByIndex.TryGetValue(link.Target, out var payload)) + { + continue; + } + + if (!linksByIndex.TryGetValue(payload.Source, out var conditionRecord) + || !linksByIndex.TryGetValue(payload.Target, out var substitutionRecord) + || conditionRecord.Source != schema.Condition + || substitutionRecord.Source != schema.Substitution) + { + continue; + } + + var condition = DecodeTextName(_triggerLinks.GetName(conditionRecord.Target), "condition"); + var substitution = DecodeTextName(_triggerLinks.GetName(substitutionRecord.Target), "substitution"); + if (condition is null || substitution is null) + { + continue; + } + + triggers.Add(new PersistentTransformation(link.Index, kind.Value, condition, substitution)); + } + + return triggers; + } + + public override uint Create(IList? substitution, WriteHandler? handler) + { + return RunWriteOperation(() => _links.Create(substitution, handler)); + } + + public override uint Update(IList? restriction, IList? substitution, WriteHandler? handler) + { + return RunWriteOperation(() => _links.Update(restriction, substitution, handler)); + } + + public override uint Delete(IList? restriction, WriteHandler? handler) + { + return RunWriteOperation(() => _links.Delete(restriction, handler)); + } + + public override uint Each(IList? restriction, ReadHandler? handler) + { + return _links.Each(restriction, handler); + } + + public string? GetName(uint link) + { + return _namedLinks.GetName(link); + } + + public uint SetName(uint link, string name) + { + return _namedLinks.SetName(link, name); + } + + public uint GetByName(string name) + { + return _namedLinks.GetByName(name); + } + + public void RemoveName(uint link) + { + _namedLinks.RemoveName(link); + } + + private uint RunWriteOperation(Func operation) + { + var result = operation(); + ApplyTriggersAfterOperation(); + return result; + } + + private void ApplyTriggersAfterOperation() + { + if (_suppressTriggers || _applyingTriggers) + { + return; + } + + var triggers = GetTriggers(); + if (triggers.Count == 0) + { + return; + } + + _applyingTriggers = true; + try + { + foreach (var trigger in triggers) + { + var changes = new List<(DoubletLink Before, DoubletLink After)>(); + QueryProcessor.ProcessQuery(this, new QueryProcessor.Options + { + Query = trigger.Query, + Trace = _trace, + AutoCreateMissingReferences = AutoCreateMissingReferences, + ChangesHandler = (before, after) => + { + changes.Add((new DoubletLink(before), new DoubletLink(after))); + return Constants.Continue; + } + }); + + if (changes.Count > 0 && trigger.Kind == PersistentTransformationKind.Once) + { + DeleteTriggerRoot(trigger.Root); + } + } + } + finally + { + _applyingTriggers = false; + } + } + + private T WithoutTriggerApplication(Func action) + { + var previousSuppressTriggers = _suppressTriggers; + _suppressTriggers = true; + try + { + return action(); + } + finally + { + _suppressTriggers = previousSuppressTriggers; + } + } + + private TriggerSchema EnsureSchema() + { + var type = EnsureNamedPoint(_triggerLinks, "Type"); + var trigger = EnsureNamedPoint(_triggerLinks, "Trigger"); + var once = EnsureNamedPoint(_triggerLinks, "Once"); + var always = EnsureNamedPoint(_triggerLinks, "Always"); + var condition = EnsureNamedPoint(_triggerLinks, "Condition"); + var substitution = EnsureNamedPoint(_triggerLinks, "Substitution"); + + _triggerLinks.GetOrCreate(type, trigger); + _triggerLinks.GetOrCreate(trigger, once); + _triggerLinks.GetOrCreate(trigger, always); + _triggerLinks.GetOrCreate(type, condition); + _triggerLinks.GetOrCreate(type, substitution); + + return new TriggerSchema(type, trigger, once, always, condition, substitution); + } + + private bool TryGetSchema(out TriggerSchema schema) + { + var type = _triggerLinks.GetByName("Type"); + var trigger = _triggerLinks.GetByName("Trigger"); + var once = _triggerLinks.GetByName("Once"); + var always = _triggerLinks.GetByName("Always"); + var condition = _triggerLinks.GetByName("Condition"); + var substitution = _triggerLinks.GetByName("Substitution"); + var @null = _triggerLinks.Constants.Null; + + if (type == @null || trigger == @null || once == @null || always == @null || condition == @null || substitution == @null) + { + schema = default; + return false; + } + + schema = new TriggerSchema(type, trigger, once, always, condition, substitution); + return true; + } + + private void DeleteTriggerRoot(uint root) + { + if (!_triggerLinks.Exists(root)) + { + return; + } + + var rootLink = new DoubletLink(_triggerLinks.GetLink(root)); + _triggerLinks.Delete(rootLink, null); + Trace($"Deleted trigger #{root}"); + } + + private static uint EnsureNamedPoint(INamedTypesLinks links, string name) + { + var existing = links.GetByName(name); + if (existing != links.Constants.Null) + { + return existing; + } + + var id = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); + links.SetName(id, name); + links.Update( + new DoubletLink(id, links.Constants.Null, links.Constants.Null), + new DoubletLink(id, id, id), + null); + return id; + } + + private static List AllLinks(INamedTypesLinks links) + { + var any = links.Constants.Any; + return links.All(new DoubletLink(any, any, any)).Select(link => new DoubletLink(link)).ToList(); + } + + private static string ConditionTextName(string condition) + { + return $"{InternalNamePrefix}condition:{condition}"; + } + + private static string SubstitutionTextName(string substitution) + { + return $"{InternalNamePrefix}substitution:{substitution}"; + } + + private static string? DecodeTextName(string? name, string part) + { + var prefix = $"{InternalNamePrefix}{part}:"; + return name is not null && name.StartsWith(prefix, StringComparison.Ordinal) + ? name[prefix.Length..] + : null; + } + + private void Trace(string message) + { + if (_trace) + { + Console.WriteLine($"[PersistentTransformation] {message}"); + } + } + + private readonly record struct TriggerSchema(uint Type, uint Trigger, uint Once, uint Always, uint Condition, uint Substitution); + + private sealed record PersistentTransformationQuery(string Condition, string Substitution) + { + public string Query => $"({Condition} {Substitution})"; + + public static PersistentTransformationQuery Parse(string query) + { + var parser = new Parser(); + var parsedLinks = parser.Parse(query); + if (parsedLinks.Count == 0) + { + throw new ArgumentException("Persistent transformation query must contain a condition and a substitution.", nameof(query)); + } + + LinoLink condition; + LinoLink substitution; + var outerLink = parsedLinks[0]; + if (outerLink.Values is { Count: >= 2 } outerValues) + { + condition = outerValues[0]; + substitution = outerValues[1]; + } + else if (parsedLinks.Count >= 2) + { + condition = parsedLinks[0]; + substitution = parsedLinks[1]; + } + else + { + throw new ArgumentException("Persistent transformation query must contain a condition and a substitution.", nameof(query)); + } + + return new PersistentTransformationQuery(Format(condition), Format(substitution)); + } + + private static string Format(LinoLink link) + { + if (link.Values is null || link.Values.Count == 0) + { + return string.IsNullOrEmpty(link.Id) ? "()" : EscapeReference(link.Id); + } + + var values = string.Join(" ", link.Values.Select(Format)); + if (string.IsNullOrEmpty(link.Id)) + { + return $"({values})"; + } + + return $"({EscapeReference(link.Id)}: {values})"; + } + + private static string EscapeReference(string reference) + { + if (string.IsNullOrWhiteSpace(reference)) + { + return string.Empty; + } + + var hasSingleQuote = reference.Contains('\''); + var hasDoubleQuote = reference.Contains('"'); + var needsQuoting = reference.Contains(':') + || reference.Contains('(') + || reference.Contains(')') + || reference.Contains(' ') + || reference.Contains('\t') + || reference.Contains('\n') + || reference.Contains('\r') + || hasSingleQuote + || hasDoubleQuote; + + if (hasSingleQuote && hasDoubleQuote) + { + return $"'{reference.Replace("'", "\\'")}'"; + } + + if (hasDoubleQuote) + { + return $"'{reference}'"; + } + + if (hasSingleQuote) + { + return $"\"{reference}\""; + } + + return needsQuoting ? $"'{reference}'" : reference; + } + } +} diff --git a/csharp/Foundation.Data.Doublets.Cli/Program.cs b/csharp/Foundation.Data.Doublets.Cli/Program.cs index c667c2d..dd779e3 100644 --- a/csharp/Foundation.Data.Doublets.Cli/Program.cs +++ b/csharp/Foundation.Data.Doublets.Cli/Program.cs @@ -66,6 +66,41 @@ Description = "Path to write the complete database as a LiNo file" }; +var alwaysOption = new Option("--always") +{ + Description = "Store the query as an always-on persistent transformation trigger", + DefaultValueFactory = _ => false +}; + +var onceOption = new Option("--once") +{ + Description = "Store the query as a persistent transformation trigger that deletes itself after it fires", + DefaultValueFactory = _ => false +}; + +var neverOption = new Option("--never") +{ + Description = "Remove stored persistent transformation triggers matching the query", + DefaultValueFactory = _ => false +}; + +var triggersOption = new Option("--triggers") +{ + Description = "Enable persistent transformation triggers for this command", + DefaultValueFactory = _ => false +}; + +var triggersFileOption = new Option("--triggers-file") +{ + Description = "Path to the persistent transformation trigger links database" +}; + +var embedTriggersOption = new Option("--embed-triggers") +{ + Description = "Store persistent transformation triggers directly in the main links database", + DefaultValueFactory = _ => false +}; + var inputOption = new Option("--in", "--lino-input", "--import") { Description = "Path to read and import a LiNo file into the database" @@ -81,6 +116,12 @@ rootCommand.Options.Add(beforeOption); rootCommand.Options.Add(changesOption); rootCommand.Options.Add(afterOption); +rootCommand.Options.Add(alwaysOption); +rootCommand.Options.Add(onceOption); +rootCommand.Options.Add(neverOption); +rootCommand.Options.Add(triggersOption); +rootCommand.Options.Add(triggersFileOption); +rootCommand.Options.Add(embedTriggersOption); rootCommand.Options.Add(inputOption); rootCommand.Options.Add(outputOption); @@ -96,10 +137,46 @@ var before = parseResult.GetValue(beforeOption); var changes = parseResult.GetValue(changesOption); var after = parseResult.GetValue(afterOption); + var always = parseResult.GetValue(alwaysOption); + var once = parseResult.GetValue(onceOption); + var never = parseResult.GetValue(neverOption); + var triggers = parseResult.GetValue(triggersOption); + var triggersFile = parseResult.GetValue(triggersFileOption); + var embedTriggers = parseResult.GetValue(embedTriggersOption); var inputPath = parseResult.GetValue(inputOption); var outputPath = parseResult.GetValue(outputOption); - var decoratedLinks = new NamedTypesDecorator(db, trace); + var triggerCommandCount = new[] { always, once, never }.Count(value => value); + if (triggerCommandCount > 1) + { + Console.Error.WriteLine("Only one of --always, --once, or --never can be used at a time."); + return 1; + } + + var baseLinks = new NamedTypesDecorator(db, trace); + INamedTypesLinks decoratedLinks = baseLinks; + PersistentTransformationDecorator? persistentLinks = null; + var defaultTriggersFile = PersistentTransformationDecorator.MakeTriggersDatabaseFilename(db); + var effectiveTriggersFile = string.IsNullOrWhiteSpace(triggersFile) ? defaultTriggersFile : triggersFile; + var persistentTransformationsEnabled = always + || once + || never + || triggers + || embedTriggers + || !string.IsNullOrWhiteSpace(triggersFile) + || File.Exists(effectiveTriggersFile); + + if (persistentTransformationsEnabled) + { + var triggerLinks = embedTriggers + ? baseLinks + : new NamedTypesDecorator(effectiveTriggersFile, trace); + persistentLinks = new PersistentTransformationDecorator(baseLinks, triggerLinks, trace) + { + AutoCreateMissingReferences = autoCreateMissingReferences + }; + decoratedLinks = persistentLinks; + } if (before) { @@ -130,6 +207,27 @@ var effectiveQuery = !string.IsNullOrWhiteSpace(queryOptionValue) ? queryOptionValue : queryArgumentValue; + if ((always || once || never) && string.IsNullOrWhiteSpace(effectiveQuery)) + { + Console.Error.WriteLine("--always, --once, and --never require a query."); + return 1; + } + + if (persistentLinks is not null && (always || once)) + { + var kind = always ? PersistentTransformationKind.Always : PersistentTransformationKind.Once; + var trigger = persistentLinks.StoreTrigger(kind, effectiveQuery); + Console.WriteLine($"{kind} persistent transformation trigger stored: {trigger}"); + return TryWriteLinoOutput(decoratedLinks, outputPath) ? 0 : 1; + } + + if (persistentLinks is not null && never) + { + var removed = persistentLinks.RemoveTriggers(effectiveQuery); + Console.WriteLine($"Persistent transformation triggers removed: {removed}"); + return TryWriteLinoOutput(decoratedLinks, outputPath) ? 0 : 1; + } + var changesList = new List<(DoubletLink Before, DoubletLink After)>(); if (!string.IsNullOrWhiteSpace(effectiveQuery)) diff --git a/examples/test_storable_patterns.sh b/examples/test_storable_patterns.sh new file mode 100755 index 0000000..60d842e --- /dev/null +++ b/examples/test_storable_patterns.sh @@ -0,0 +1,38 @@ +#!/bin/bash +set -euo pipefail + +workdir="$(mktemp -d)" +trap 'rm -rf "$workdir"' EXIT + +db="$workdir/storable-patterns.links" +triggers="$workdir/storable-patterns.triggers.links" + +echo "=== Testing persistent transformation triggers ===" + +echo "" +echo "1. Store an always-on trigger in a binary trigger links file" +dotnet run --project csharp/Foundation.Data.Doublets.Cli -- \ + --db "$db" \ + --triggers-file "$triggers" \ + --always \ + '(((1: 1 1)) ((1: 1 2)))' + +echo "" +echo "2. Create a matching link; the trigger updates it" +dotnet run --project csharp/Foundation.Data.Doublets.Cli -- \ + --db "$db" \ + --triggers-file "$triggers" \ + --auto-create-missing-references \ + '() ((1: 1 1))' \ + --after + +echo "" +echo "3. Remove the stored trigger" +dotnet run --project csharp/Foundation.Data.Doublets.Cli -- \ + --db "$db" \ + --triggers-file "$triggers" \ + --never \ + '(((1: 1 1)) ((1: 1 2)))' + +echo "" +echo "=== Test completed ==="