Skip to content

Commit

Permalink
Changed the default behavior of script metadata to cache the value, a…
Browse files Browse the repository at this point in the history
…nd added a new `->` computed metadata prefix to specify uncached values
  • Loading branch information
daveaglick committed Sep 26, 2023
1 parent ec996a8 commit d8d2f65
Show file tree
Hide file tree
Showing 7 changed files with 161 additions and 34 deletions.
1 change: 1 addition & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# 1.0.0-beta.71

- Modified the behavior of computed metadata values to cache the value for a given document when using the `=>` prefix. The previous behavior that evaluates a computed value every time it's accessed can still be used by prefixing with `->` instead. In theory this change shouldn't result in any differences in behavior since documents are immutable in the first place (so caching wouldn't be any different from re-evaluating), but if you have computed metadata values that consider state outside the document (such as something like `DateTime.Now`), you'll need to switch those to use the `->` prefix instead.
- Updated JavaScriptEngineSwitcher.Core and JavaScriptEngineSwitcher.Jint.
- Updated `highlight.js` used in `Statiq.Highlight` (#269).

Expand Down
5 changes: 3 additions & 2 deletions src/core/Statiq.Common/Meta/CachedDelegateMetadataValue.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System;
using System.Collections.Concurrent;

namespace Statiq.Common
{
Expand Down Expand Up @@ -40,6 +39,8 @@ public CachedDelegateMetadataValue(Func<string, IMetadata, object> value)
/// <param name="metadata">The metadata object requesting the value.</param>
/// <returns>The object to use as the value.</returns>
public override object Get(string key, IMetadata metadata) =>
_cache.GetOrAdd((key, metadata), _ => base.Get(key, metadata));
_cache.GetOrAdd((key, metadata), (x, self) => self.BaseGet(x.Item1, x.Item2), this);

private object BaseGet(string key, IMetadata metadata) => base.Get(key, metadata);
}
}
8 changes: 4 additions & 4 deletions src/core/Statiq.Common/Meta/IMetadataGetExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ public static class IMetadataGetExtensions
/// </summary>
/// <remarks>
/// This method will also materialize <see cref="IMetadataValue"/> and
/// evaluate script strings (a key that starts with "=>" will be treated
/// as a script and evaluated).
/// evaluate script strings. A key that starts with "=>" (cached) or "->" (uncached)
/// will be treated as a script and evaluated (without caching regardless of script prefix).
/// </remarks>
/// <param name="metadata">The metadata instance.</param>
/// <typeparam name="TValue">The desired return type.</typeparam>
Expand All @@ -28,8 +28,8 @@ public static bool TryGetValue<TValue>(
{
if (metadata is object && key is object)
{
// Script
if (IScriptHelper.TryGetScriptString(key, out string script))
// Script-based key (we don't care if it's cached in this code path, script keys be evaluated every time from here)
if (IScriptHelper.TryGetScriptString(key, out string script).HasValue)
{
IExecutionContext context = IExecutionContext.Current;
#pragma warning disable VSTHRD002 // Synchronously waiting on tasks or awaiters may cause deadlocks. Use await or JoinableTaskFactory.Run instead.
Expand Down
45 changes: 38 additions & 7 deletions src/core/Statiq.Common/Scripting/IScriptHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,18 @@ namespace Statiq.Common
{
public interface IScriptHelper
{
public const string ScriptStringPrefix = "=>";
/// <summary>
/// The script prefix to use for script strings that cache their
/// values after the first evaluation.
/// </summary>
public const string CachedValueScriptStringPrefix = "=>";

/// <summary>
/// The script prefix to use for script strings that do not
/// cache their values after the first evaluation and re-evaluate
/// the value every time it's fetched.
/// </summary>
public const string UncachedValueScriptStringPrefix = "->";

/// <summary>
/// Compiles, caches, and evaluates a script.
Expand All @@ -31,29 +42,49 @@ public interface IScriptHelper
/// </summary>
/// <param name="str">The candidate string.</param>
/// <param name="script">The trimmed script.</param>
/// <returns><c>true</c> if the candidate string is a script string, <c>false</c> otherwise.</returns>
public static bool TryGetScriptString(string str, out string script)
/// <returns>
/// <c>true</c> if the candidate string is a script string that should be cached,
/// <c>false</c> if the candidate string is a script string that should not be cached,
/// and null otherwise.</returns>
public static bool? TryGetScriptString(string str, out string script)
{
if (TryGetScriptString(str, true, out script))
{
return true;
}

if (TryGetScriptString(str, false, out script))
{
return false;
}

script = null;
return null;
}

private static bool TryGetScriptString(string str, bool cacheValue, out string script)
{
script = null;
string prefix = cacheValue ? CachedValueScriptStringPrefix : UncachedValueScriptStringPrefix;
int c = 0;
int s = 0;
for (; c < str.Length; c++)
{
if (s < ScriptStringPrefix.Length)
if (s < prefix.Length)
{
if (s == 0 && char.IsWhiteSpace(str[c]))
{
continue;
}
if (str[c] == ScriptStringPrefix[s])
if (str[c] == prefix[s])
{
s++;
continue;
}
}
break;
}
if (s == ScriptStringPrefix.Length)
if (s == prefix.Length)
{
script = str[c..];
return true;
Expand All @@ -65,4 +96,4 @@ public static bool TryGetScriptString(string str, out string script)

IEnumerable<string> GetScriptNamespaces();
}
}
}
40 changes: 30 additions & 10 deletions src/core/Statiq.Common/Scripting/ScriptMetadataValue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ public sealed class ScriptMetadataValue : IMetadataValue
private readonly string _key;
private readonly string _originalPrefix;
private readonly string _script;
private readonly ConcurrentCache<(string, IMetadata), object> _cache;
private readonly IExecutionState _executionState;

private ScriptMetadataValue(string key, string originalPrefix, string script, IExecutionState executionState)
private ScriptMetadataValue(
string key, string originalPrefix, string script, bool cacheValue, IExecutionState executionState)
{
_key = key.ThrowIfNull(nameof(key));
_originalPrefix = originalPrefix.ThrowIfNull(nameof(originalPrefix));
_script = script.ThrowIfNull(nameof(script));
_cache = cacheValue ? new ConcurrentCache<(string, IMetadata), object>(false) : null;
_executionState = executionState.ThrowIfNull(nameof(executionState));
}

Expand All @@ -35,23 +38,40 @@ public object Get(string key, IMetadata metadata)
return _originalPrefix + _script;
}

// Evaluate the script
#pragma warning disable VSTHRD002 // Synchronously waiting on tasks or awaiters may cause deadlocks. Use await or JoinableTaskFactory.Run instead.

// Get the cached value if this is a cached script
if (_cache is object)
{
return _cache.GetOrAdd(
(key, metadata),
(x, self) => self._executionState.ScriptHelper.EvaluateAsync(self._script, x.Item2).GetAwaiter().GetResult(),
this);
}

// Otherwise, evaluate the script each time
return _executionState.ScriptHelper.EvaluateAsync(_script, metadata).GetAwaiter().GetResult();

#pragma warning restore VSTHRD002
}

public static bool TryGetScriptMetadataValue(string key, object value, IExecutionState executionState, out ScriptMetadataValue scriptMetadataValue)
public static bool TryGetScriptMetadataValue(
string key, object value, IExecutionState executionState, out ScriptMetadataValue scriptMetadataValue)
{
scriptMetadataValue = default;
if (value is string stringValue && IScriptHelper.TryGetScriptString(stringValue, out string script))
if (value is string stringValue)
{
scriptMetadataValue = new ScriptMetadataValue(
key,
stringValue.Substring(0, stringValue.Length - script.Length),
script,
executionState);
return true;
bool? isScriptStringCached = IScriptHelper.TryGetScriptString(stringValue, out string script);
if (isScriptStringCached.HasValue)
{
scriptMetadataValue = new ScriptMetadataValue(
key,
stringValue.Substring(0, stringValue.Length - script.Length),
script,
isScriptStringCached.Value,
executionState);
return true;
}
}
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,34 @@ public class IScriptHelperTestFixture : BaseFixture
{
public class TryGetScriptStringTests : IScriptHelperTestFixture
{
[TestCase("foo", false, null)]
[TestCase("=foo", false, null)]
[TestCase("foo", null, null)]
[TestCase("=foo", null, null)]
[TestCase("=>foo", true, "foo")]
[TestCase(" =foo", false, null)]
[TestCase("->foo", false, "foo")]
[TestCase(" =foo", null, null)]
[TestCase(" =>foo", true, "foo")]
[TestCase("= >foo", false, null)]
[TestCase(" ->foo", false, "foo")]
[TestCase("= >foo", null, null)]
[TestCase("- >foo", null, null)]
[TestCase("=> foo", true, " foo")]
[TestCase("-> foo", false, " foo")]
[TestCase(" => foo", true, " foo")]
[TestCase("bar=>foo", false, null)]
[TestCase(" -> foo", false, " foo")]
[TestCase("bar=>foo", null, null)]
[TestCase("bar->foo", null, null)]
[TestCase(" => foo ", true, " foo ")]
[TestCase(" = > foo", false, null)]
public void GetsScriptString(string input, bool expected, string expectedScript)
[TestCase(" -> foo ", false, " foo ")]
[TestCase(" = > foo", null, null)]
[TestCase(" - > foo", null, null)]
public void GetsScriptString(string input, bool? expected, string expectedScript)
{
// Given, When
bool result = IScriptHelper.TryGetScriptString(input, out string resultScript);
bool? result = IScriptHelper.TryGetScriptString(input, out string resultScript);

// Then
result.ShouldBe(expected);
resultScript.ShouldBe(expectedScript);
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text;
using System.Threading;
using NUnit.Framework;
using Shouldly;
using Statiq.Common;
Expand All @@ -18,7 +19,30 @@ public class GetTests : ScriptMetadataValueFixture
[TestCase("=> { int x = 1 + 2; return $\"ABC {x} XYZ\"; }")]
[TestCase("=> int x = 1 + 2; return $\"ABC {x} XYZ\";")]
[TestCase(" => $\"ABC {1+2} XYZ\"")]
public void EvaluatesScriptMetadata(string value)
public void EvaluatesCachedScriptMetadata(string value)
{
// Given
TestExecutionContext context = new TestExecutionContext();
context.ScriptHelper = new ScriptHelper(context);
ScriptMetadataValue.TryGetScriptMetadataValue("Foo", value, context, out ScriptMetadataValue scriptMetadataValue);
TestDocument document = new TestDocument
{
{ "Foo", scriptMetadataValue }
};

// When
string result = document.GetString("Foo");

// Then
result.ShouldBe("ABC 3 XYZ");
}

[TestCase("-> $\"ABC {1+2} XYZ\"")]
[TestCase("-> return $\"ABC {1+2} XYZ\";")]
[TestCase("-> { int x = 1 + 2; return $\"ABC {x} XYZ\"; }")]
[TestCase("-> int x = 1 + 2; return $\"ABC {x} XYZ\";")]
[TestCase(" -> $\"ABC {1+2} XYZ\"")]
public void EvaluatesUncachedScriptMetadata(string value)
{
// Given
TestExecutionContext context = new TestExecutionContext();
Expand Down Expand Up @@ -225,6 +249,48 @@ public void DoesNotExcludeForInvalidExclusionValue()
fooResult.ShouldBe(3);
barResult.ShouldBe(7);
}

[Test]
public void ShouldCacheScriptResult()
{
// Given
TestExecutionContext context = new TestExecutionContext();
context.ScriptHelper = new ScriptHelper(context);
ScriptMetadataValue.TryGetScriptMetadataValue("Foo", "=> DateTime.Now.ToString()", context, out ScriptMetadataValue scriptMetadataValue);
TestDocument document = new TestDocument
{
{ "Foo", scriptMetadataValue }
};

// When
string result1 = document.GetString("Foo");
Thread.Sleep(100);
string result2 = document.GetString("Foo");

// Then
result1.ShouldBe(result2);
}

[Test]
public void ShouldNotCacheScriptResult()
{
// Given
TestExecutionContext context = new TestExecutionContext();
context.ScriptHelper = new ScriptHelper(context);
ScriptMetadataValue.TryGetScriptMetadataValue("Foo", "-> DateTime.Now.Ticks.ToString()", context, out ScriptMetadataValue scriptMetadataValue);
TestDocument document = new TestDocument
{
{ "Foo", scriptMetadataValue }
};

// When
string result1 = document.GetString("Foo");
Thread.Sleep(100);
string result2 = document.GetString("Foo");

// Then
result1.ShouldNotBe(result2);
}
}

public class TryGetMetadataValueTests : ScriptMetadataValueFixture
Expand Down Expand Up @@ -319,4 +385,4 @@ public void ReturnsTrueForValidScript(string value)
}
}
}
}
}

0 comments on commit d8d2f65

Please sign in to comment.