Skip to content

Commit d8d2f65

Browse files
committed
Changed the default behavior of script metadata to cache the value, and added a new -> computed metadata prefix to specify uncached values
1 parent ec996a8 commit d8d2f65

File tree

7 files changed

+161
-34
lines changed

7 files changed

+161
-34
lines changed

RELEASE.md

+1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# 1.0.0-beta.71
22

3+
- 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.
34
- Updated JavaScriptEngineSwitcher.Core and JavaScriptEngineSwitcher.Jint.
45
- Updated `highlight.js` used in `Statiq.Highlight` (#269).
56

src/core/Statiq.Common/Meta/CachedDelegateMetadataValue.cs

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using System;
2-
using System.Collections.Concurrent;
32

43
namespace Statiq.Common
54
{
@@ -40,6 +39,8 @@ public CachedDelegateMetadataValue(Func<string, IMetadata, object> value)
4039
/// <param name="metadata">The metadata object requesting the value.</param>
4140
/// <returns>The object to use as the value.</returns>
4241
public override object Get(string key, IMetadata metadata) =>
43-
_cache.GetOrAdd((key, metadata), _ => base.Get(key, metadata));
42+
_cache.GetOrAdd((key, metadata), (x, self) => self.BaseGet(x.Item1, x.Item2), this);
43+
44+
private object BaseGet(string key, IMetadata metadata) => base.Get(key, metadata);
4445
}
4546
}

src/core/Statiq.Common/Meta/IMetadataGetExtensions.cs

+4-4
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ public static class IMetadataGetExtensions
1313
/// </summary>
1414
/// <remarks>
1515
/// This method will also materialize <see cref="IMetadataValue"/> and
16-
/// evaluate script strings (a key that starts with "=>" will be treated
17-
/// as a script and evaluated).
16+
/// evaluate script strings. A key that starts with "=>" (cached) or "->" (uncached)
17+
/// will be treated as a script and evaluated (without caching regardless of script prefix).
1818
/// </remarks>
1919
/// <param name="metadata">The metadata instance.</param>
2020
/// <typeparam name="TValue">The desired return type.</typeparam>
@@ -28,8 +28,8 @@ public static bool TryGetValue<TValue>(
2828
{
2929
if (metadata is object && key is object)
3030
{
31-
// Script
32-
if (IScriptHelper.TryGetScriptString(key, out string script))
31+
// Script-based key (we don't care if it's cached in this code path, script keys be evaluated every time from here)
32+
if (IScriptHelper.TryGetScriptString(key, out string script).HasValue)
3333
{
3434
IExecutionContext context = IExecutionContext.Current;
3535
#pragma warning disable VSTHRD002 // Synchronously waiting on tasks or awaiters may cause deadlocks. Use await or JoinableTaskFactory.Run instead.

src/core/Statiq.Common/Scripting/IScriptHelper.cs

+38-7
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,18 @@ namespace Statiq.Common
88
{
99
public interface IScriptHelper
1010
{
11-
public const string ScriptStringPrefix = "=>";
11+
/// <summary>
12+
/// The script prefix to use for script strings that cache their
13+
/// values after the first evaluation.
14+
/// </summary>
15+
public const string CachedValueScriptStringPrefix = "=>";
16+
17+
/// <summary>
18+
/// The script prefix to use for script strings that do not
19+
/// cache their values after the first evaluation and re-evaluate
20+
/// the value every time it's fetched.
21+
/// </summary>
22+
public const string UncachedValueScriptStringPrefix = "->";
1223

1324
/// <summary>
1425
/// Compiles, caches, and evaluates a script.
@@ -31,29 +42,49 @@ public interface IScriptHelper
3142
/// </summary>
3243
/// <param name="str">The candidate string.</param>
3344
/// <param name="script">The trimmed script.</param>
34-
/// <returns><c>true</c> if the candidate string is a script string, <c>false</c> otherwise.</returns>
35-
public static bool TryGetScriptString(string str, out string script)
45+
/// <returns>
46+
/// <c>true</c> if the candidate string is a script string that should be cached,
47+
/// <c>false</c> if the candidate string is a script string that should not be cached,
48+
/// and null otherwise.</returns>
49+
public static bool? TryGetScriptString(string str, out string script)
50+
{
51+
if (TryGetScriptString(str, true, out script))
52+
{
53+
return true;
54+
}
55+
56+
if (TryGetScriptString(str, false, out script))
57+
{
58+
return false;
59+
}
60+
61+
script = null;
62+
return null;
63+
}
64+
65+
private static bool TryGetScriptString(string str, bool cacheValue, out string script)
3666
{
3767
script = null;
68+
string prefix = cacheValue ? CachedValueScriptStringPrefix : UncachedValueScriptStringPrefix;
3869
int c = 0;
3970
int s = 0;
4071
for (; c < str.Length; c++)
4172
{
42-
if (s < ScriptStringPrefix.Length)
73+
if (s < prefix.Length)
4374
{
4475
if (s == 0 && char.IsWhiteSpace(str[c]))
4576
{
4677
continue;
4778
}
48-
if (str[c] == ScriptStringPrefix[s])
79+
if (str[c] == prefix[s])
4980
{
5081
s++;
5182
continue;
5283
}
5384
}
5485
break;
5586
}
56-
if (s == ScriptStringPrefix.Length)
87+
if (s == prefix.Length)
5788
{
5889
script = str[c..];
5990
return true;
@@ -65,4 +96,4 @@ public static bool TryGetScriptString(string str, out string script)
6596

6697
IEnumerable<string> GetScriptNamespaces();
6798
}
68-
}
99+
}

src/core/Statiq.Common/Scripting/ScriptMetadataValue.cs

+30-10
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,16 @@ public sealed class ScriptMetadataValue : IMetadataValue
1414
private readonly string _key;
1515
private readonly string _originalPrefix;
1616
private readonly string _script;
17+
private readonly ConcurrentCache<(string, IMetadata), object> _cache;
1718
private readonly IExecutionState _executionState;
1819

19-
private ScriptMetadataValue(string key, string originalPrefix, string script, IExecutionState executionState)
20+
private ScriptMetadataValue(
21+
string key, string originalPrefix, string script, bool cacheValue, IExecutionState executionState)
2022
{
2123
_key = key.ThrowIfNull(nameof(key));
2224
_originalPrefix = originalPrefix.ThrowIfNull(nameof(originalPrefix));
2325
_script = script.ThrowIfNull(nameof(script));
26+
_cache = cacheValue ? new ConcurrentCache<(string, IMetadata), object>(false) : null;
2427
_executionState = executionState.ThrowIfNull(nameof(executionState));
2528
}
2629

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

38-
// Evaluate the script
3941
#pragma warning disable VSTHRD002 // Synchronously waiting on tasks or awaiters may cause deadlocks. Use await or JoinableTaskFactory.Run instead.
42+
43+
// Get the cached value if this is a cached script
44+
if (_cache is object)
45+
{
46+
return _cache.GetOrAdd(
47+
(key, metadata),
48+
(x, self) => self._executionState.ScriptHelper.EvaluateAsync(self._script, x.Item2).GetAwaiter().GetResult(),
49+
this);
50+
}
51+
52+
// Otherwise, evaluate the script each time
4053
return _executionState.ScriptHelper.EvaluateAsync(_script, metadata).GetAwaiter().GetResult();
54+
4155
#pragma warning restore VSTHRD002
4256
}
4357

44-
public static bool TryGetScriptMetadataValue(string key, object value, IExecutionState executionState, out ScriptMetadataValue scriptMetadataValue)
58+
public static bool TryGetScriptMetadataValue(
59+
string key, object value, IExecutionState executionState, out ScriptMetadataValue scriptMetadataValue)
4560
{
4661
scriptMetadataValue = default;
47-
if (value is string stringValue && IScriptHelper.TryGetScriptString(stringValue, out string script))
62+
if (value is string stringValue)
4863
{
49-
scriptMetadataValue = new ScriptMetadataValue(
50-
key,
51-
stringValue.Substring(0, stringValue.Length - script.Length),
52-
script,
53-
executionState);
54-
return true;
64+
bool? isScriptStringCached = IScriptHelper.TryGetScriptString(stringValue, out string script);
65+
if (isScriptStringCached.HasValue)
66+
{
67+
scriptMetadataValue = new ScriptMetadataValue(
68+
key,
69+
stringValue.Substring(0, stringValue.Length - script.Length),
70+
script,
71+
isScriptStringCached.Value,
72+
executionState);
73+
return true;
74+
}
5575
}
5676
return false;
5777
}

tests/core/Statiq.Common.Tests/Scripting/IScriptHelperTestFixture.cs

+17-9
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,34 @@ public class IScriptHelperTestFixture : BaseFixture
1212
{
1313
public class TryGetScriptStringTests : IScriptHelperTestFixture
1414
{
15-
[TestCase("foo", false, null)]
16-
[TestCase("=foo", false, null)]
15+
[TestCase("foo", null, null)]
16+
[TestCase("=foo", null, null)]
1717
[TestCase("=>foo", true, "foo")]
18-
[TestCase(" =foo", false, null)]
18+
[TestCase("->foo", false, "foo")]
19+
[TestCase(" =foo", null, null)]
1920
[TestCase(" =>foo", true, "foo")]
20-
[TestCase("= >foo", false, null)]
21+
[TestCase(" ->foo", false, "foo")]
22+
[TestCase("= >foo", null, null)]
23+
[TestCase("- >foo", null, null)]
2124
[TestCase("=> foo", true, " foo")]
25+
[TestCase("-> foo", false, " foo")]
2226
[TestCase(" => foo", true, " foo")]
23-
[TestCase("bar=>foo", false, null)]
27+
[TestCase(" -> foo", false, " foo")]
28+
[TestCase("bar=>foo", null, null)]
29+
[TestCase("bar->foo", null, null)]
2430
[TestCase(" => foo ", true, " foo ")]
25-
[TestCase(" = > foo", false, null)]
26-
public void GetsScriptString(string input, bool expected, string expectedScript)
31+
[TestCase(" -> foo ", false, " foo ")]
32+
[TestCase(" = > foo", null, null)]
33+
[TestCase(" - > foo", null, null)]
34+
public void GetsScriptString(string input, bool? expected, string expectedScript)
2735
{
2836
// Given, When
29-
bool result = IScriptHelper.TryGetScriptString(input, out string resultScript);
37+
bool? result = IScriptHelper.TryGetScriptString(input, out string resultScript);
3038

3139
// Then
3240
result.ShouldBe(expected);
3341
resultScript.ShouldBe(expectedScript);
3442
}
3543
}
3644
}
37-
}
45+
}

tests/core/Statiq.Core.Tests/Scripting/ScriptMetadataValueFixture.cs

+68-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Text;
2+
using System.Threading;
23
using NUnit.Framework;
34
using Shouldly;
45
using Statiq.Common;
@@ -18,7 +19,30 @@ public class GetTests : ScriptMetadataValueFixture
1819
[TestCase("=> { int x = 1 + 2; return $\"ABC {x} XYZ\"; }")]
1920
[TestCase("=> int x = 1 + 2; return $\"ABC {x} XYZ\";")]
2021
[TestCase(" => $\"ABC {1+2} XYZ\"")]
21-
public void EvaluatesScriptMetadata(string value)
22+
public void EvaluatesCachedScriptMetadata(string value)
23+
{
24+
// Given
25+
TestExecutionContext context = new TestExecutionContext();
26+
context.ScriptHelper = new ScriptHelper(context);
27+
ScriptMetadataValue.TryGetScriptMetadataValue("Foo", value, context, out ScriptMetadataValue scriptMetadataValue);
28+
TestDocument document = new TestDocument
29+
{
30+
{ "Foo", scriptMetadataValue }
31+
};
32+
33+
// When
34+
string result = document.GetString("Foo");
35+
36+
// Then
37+
result.ShouldBe("ABC 3 XYZ");
38+
}
39+
40+
[TestCase("-> $\"ABC {1+2} XYZ\"")]
41+
[TestCase("-> return $\"ABC {1+2} XYZ\";")]
42+
[TestCase("-> { int x = 1 + 2; return $\"ABC {x} XYZ\"; }")]
43+
[TestCase("-> int x = 1 + 2; return $\"ABC {x} XYZ\";")]
44+
[TestCase(" -> $\"ABC {1+2} XYZ\"")]
45+
public void EvaluatesUncachedScriptMetadata(string value)
2246
{
2347
// Given
2448
TestExecutionContext context = new TestExecutionContext();
@@ -225,6 +249,48 @@ public void DoesNotExcludeForInvalidExclusionValue()
225249
fooResult.ShouldBe(3);
226250
barResult.ShouldBe(7);
227251
}
252+
253+
[Test]
254+
public void ShouldCacheScriptResult()
255+
{
256+
// Given
257+
TestExecutionContext context = new TestExecutionContext();
258+
context.ScriptHelper = new ScriptHelper(context);
259+
ScriptMetadataValue.TryGetScriptMetadataValue("Foo", "=> DateTime.Now.ToString()", context, out ScriptMetadataValue scriptMetadataValue);
260+
TestDocument document = new TestDocument
261+
{
262+
{ "Foo", scriptMetadataValue }
263+
};
264+
265+
// When
266+
string result1 = document.GetString("Foo");
267+
Thread.Sleep(100);
268+
string result2 = document.GetString("Foo");
269+
270+
// Then
271+
result1.ShouldBe(result2);
272+
}
273+
274+
[Test]
275+
public void ShouldNotCacheScriptResult()
276+
{
277+
// Given
278+
TestExecutionContext context = new TestExecutionContext();
279+
context.ScriptHelper = new ScriptHelper(context);
280+
ScriptMetadataValue.TryGetScriptMetadataValue("Foo", "-> DateTime.Now.Ticks.ToString()", context, out ScriptMetadataValue scriptMetadataValue);
281+
TestDocument document = new TestDocument
282+
{
283+
{ "Foo", scriptMetadataValue }
284+
};
285+
286+
// When
287+
string result1 = document.GetString("Foo");
288+
Thread.Sleep(100);
289+
string result2 = document.GetString("Foo");
290+
291+
// Then
292+
result1.ShouldNotBe(result2);
293+
}
228294
}
229295

230296
public class TryGetMetadataValueTests : ScriptMetadataValueFixture
@@ -319,4 +385,4 @@ public void ReturnsTrueForValidScript(string value)
319385
}
320386
}
321387
}
322-
}
388+
}

0 commit comments

Comments
 (0)