Skip to content

Commit 1b2d874

Browse files
authored
Cache more than one parsed template for include statements (#785)
1 parent d39f793 commit 1b2d874

7 files changed

+189
-60
lines changed

Fluid.Tests/IncludeStatementTests.cs

+70
Original file line numberDiff line numberDiff line change
@@ -429,5 +429,75 @@ public void IncludeTag_Caches_Template(bool useExtension)
429429
// The previously cached template should be used
430430
Assert.Equal("AAAA", result);
431431
}
432+
433+
[Fact]
434+
public void IncludeTag_Caches_ParsedTemplate()
435+
{
436+
var templates = "abcdefg".Select(x => new string(x, 10)).ToArray();
437+
438+
var fileProvider = new MockFileProvider();
439+
440+
foreach (var t in templates)
441+
{
442+
fileProvider.Add($"{t[0]}.liquid", t);
443+
}
444+
445+
var fileInfos = templates.Select(x => fileProvider.GetFileInfo($"{x[0]}.liquid")).Cast<MockFileInfo>().ToArray();
446+
447+
var options = new TemplateOptions() { FileProvider = fileProvider, MemberAccessStrategy = UnsafeMemberAccessStrategy.Instance };
448+
_parser.TryParse("{%- include file -%}", out var template);
449+
450+
// The first time a template is included it will be read from the file provider
451+
foreach (var f in fileInfos)
452+
{
453+
var filename = f.Name;
454+
455+
Assert.False(f.Accessed);
456+
457+
var context = new TemplateContext(options);
458+
context.SetValue("file", filename);
459+
var result = template.Render(context);
460+
461+
Assert.True(f.Accessed);
462+
}
463+
464+
foreach (var f in fileInfos)
465+
{
466+
f.Accessed = false;
467+
}
468+
469+
// The next time a template is included it should not be accessed from the file provider but cached instead
470+
foreach (var f in fileInfos)
471+
{
472+
var filename = f.Name;
473+
474+
Assert.False(f.Accessed);
475+
476+
var context = new TemplateContext(options);
477+
context.SetValue("file", filename);
478+
var result = template.Render(context);
479+
480+
Assert.False(f.Accessed);
481+
}
482+
483+
foreach (var f in fileInfos)
484+
{
485+
f.LastModified = DateTime.UtcNow;
486+
}
487+
488+
// If the attributes have changed then the template should be reloaded
489+
foreach (var f in fileInfos)
490+
{
491+
var filename = f.Name;
492+
493+
Assert.False(f.Accessed);
494+
495+
var context = new TemplateContext(options);
496+
context.SetValue("file", filename);
497+
var result = template.Render(context);
498+
499+
Assert.True(f.Accessed);
500+
}
501+
}
432502
}
433503
}

Fluid.Tests/Mocks/MockFileInfo.cs

+9-6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System;
1+
using System;
22
using System.IO;
33
using System.Text;
44
using Microsoft.Extensions.FileProviders;
@@ -7,31 +7,34 @@ namespace Fluid.Tests.Mocks
77
{
88
public class MockFileInfo : IFileInfo
99
{
10-
public static readonly MockFileInfo Null = new MockFileInfo("", "") { _exists = false };
11-
12-
private bool _exists = true;
10+
public static readonly MockFileInfo Null = new MockFileInfo("", "") { Exists = false };
1311

1412
public MockFileInfo(string name, string content)
1513
{
1614
Name = name;
1715
Content = content;
16+
Exists = true;
1817
}
1918

2019
public string Content { get; set; }
21-
public bool Exists => _exists;
20+
21+
public bool Exists { get; set; }
2222

2323
public bool IsDirectory => false;
2424

25-
public DateTimeOffset LastModified => DateTimeOffset.MinValue;
25+
public DateTimeOffset LastModified { get; set; } = DateTimeOffset.MinValue;
2626

2727
public long Length => -1;
2828

2929
public string Name { get; }
3030

3131
public string PhysicalPath => null;
3232

33+
public bool Accessed { get; set; }
34+
3335
public Stream CreateReadStream()
3436
{
37+
Accessed = true;
3538
var data = Encoding.UTF8.GetBytes(Content);
3639
return new MemoryStream(data);
3740
}

Fluid/Ast/IncludeStatement.cs

+18-26
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using Fluid.Values;
2-
using System.Diagnostics;
32
using System.Text.Encodings.Web;
43

54
namespace Fluid.Ast
@@ -10,11 +9,6 @@ public sealed class IncludeStatement : Statement
109
{
1110
public const string ViewExtension = ".liquid";
1211

13-
// Since include statements will rarely vary the filename they render, we cache the most
14-
// recent file only.
15-
16-
private volatile CachedTemplate _cachedTemplate;
17-
1812
public IncludeStatement(FluidParser parser, Expression path, Expression with = null, Expression @for = null, string alias = null, IReadOnlyList<AssignStatement> assignStatements = null)
1913
{
2014
Parser = parser;
@@ -43,19 +37,19 @@ public override async ValueTask<Completion> WriteToAsync(TextWriter writer, Text
4337
relativePath += ViewExtension;
4438
}
4539

46-
var cachedTemplate = _cachedTemplate;
40+
var fileProvider = context.Options.FileProvider;
4741

48-
if (cachedTemplate == null || !string.Equals(cachedTemplate.Name, System.IO.Path.GetFileNameWithoutExtension(relativePath), StringComparison.Ordinal))
49-
{
50-
var fileProvider = context.Options.FileProvider;
42+
// The file info is requested again to ensure the file hasn't changed and is still existing.
5143

52-
var fileInfo = fileProvider.GetFileInfo(relativePath);
44+
var fileInfo = fileProvider.GetFileInfo(relativePath);
5345

54-
if (fileInfo == null || !fileInfo.Exists)
55-
{
56-
throw new FileNotFoundException(relativePath);
57-
}
46+
if (fileInfo == null || !fileInfo.Exists || fileInfo.IsDirectory)
47+
{
48+
throw new FileNotFoundException(relativePath);
49+
}
5850

51+
if (context.Options.TemplateCache == null || !context.Options.TemplateCache.TryGetTemplate(fileInfo, out var template))
52+
{
5953
var content = "";
6054

6155
using (var stream = fileInfo.CreateReadStream())
@@ -64,17 +58,15 @@ public override async ValueTask<Completion> WriteToAsync(TextWriter writer, Text
6458
content = await streamReader.ReadToEndAsync();
6559
}
6660

67-
if (!Parser.TryParse(content, out var template, out var errors))
61+
if (!Parser.TryParse(content, out template, out var errors))
6862
{
6963
throw new ParseException(errors);
7064
}
7165

72-
var identifier = System.IO.Path.GetFileNameWithoutExtension(relativePath);
73-
74-
_cachedTemplate = cachedTemplate = new CachedTemplate(template, identifier);
66+
context.Options.TemplateCache?.SetTemplate(fileInfo, template);
7567
}
7668

77-
Debug.Assert(cachedTemplate != null);
69+
var identifier = System.IO.Path.GetFileNameWithoutExtension(relativePath);
7870

7971
context.EnterChildScope();
8072

@@ -84,9 +76,9 @@ public override async ValueTask<Completion> WriteToAsync(TextWriter writer, Text
8476
{
8577
var with = await With.EvaluateAsync(context);
8678

87-
context.SetValue(Alias ?? _cachedTemplate.Name, with);
79+
context.SetValue(Alias ?? identifier, with);
8880

89-
await cachedTemplate.Template.RenderAsync(writer, encoder, context);
81+
await template.RenderAsync(writer, encoder, context);
9082
}
9183
else if (AssignStatements.Count > 0)
9284
{
@@ -96,7 +88,7 @@ public override async ValueTask<Completion> WriteToAsync(TextWriter writer, Text
9688
await AssignStatements[i].WriteToAsync(writer, encoder, context);
9789
}
9890

99-
await cachedTemplate.Template.RenderAsync(writer, encoder, context);
91+
await template.RenderAsync(writer, encoder, context);
10092
}
10193
else if (For != null)
10294
{
@@ -116,7 +108,7 @@ public override async ValueTask<Completion> WriteToAsync(TextWriter writer, Text
116108

117109
var item = list[i];
118110

119-
context.SetValue(Alias ?? _cachedTemplate.Name, item);
111+
context.SetValue(Alias ?? identifier, item);
120112

121113
// Set helper variables
122114
forloop.Index = i + 1;
@@ -126,7 +118,7 @@ public override async ValueTask<Completion> WriteToAsync(TextWriter writer, Text
126118
forloop.First = i == 0;
127119
forloop.Last = i == length - 1;
128120

129-
await _cachedTemplate.Template.RenderAsync(writer, encoder, context);
121+
await template.RenderAsync(writer, encoder, context);
130122

131123
// Restore the forloop property after every statement in case it replaced it,
132124
// for instance if it contains a nested for loop
@@ -141,7 +133,7 @@ public override async ValueTask<Completion> WriteToAsync(TextWriter writer, Text
141133
else
142134
{
143135
// no with, for or assignments, e.g. {% include 'products' %}
144-
await cachedTemplate.Template.RenderAsync(writer, encoder, context);
136+
await template.RenderAsync(writer, encoder, context);
145137
}
146138
}
147139
finally

Fluid/Ast/RenderStatement.cs

+18-28
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using Fluid.Values;
2-
using System.Diagnostics;
32
using System.Text.Encodings.Web;
43

54
namespace Fluid.Ast
@@ -13,11 +12,6 @@ public sealed class RenderStatement : Statement
1312
{
1413
public const string ViewExtension = ".liquid";
1514

16-
// Since include statements will rarely vary the filename they render, we cache the most
17-
// recent file only.
18-
19-
private volatile CachedTemplate _cachedTemplate;
20-
2115
public RenderStatement(FluidParser parser, string path, Expression with = null, Expression @for = null, string alias = null, IReadOnlyList<AssignStatement> assignStatements = null)
2216
{
2317
Parser = parser;
@@ -46,19 +40,17 @@ public override async ValueTask<Completion> WriteToAsync(TextWriter writer, Text
4640
relativePath += ViewExtension;
4741
}
4842

49-
var cachedTemplate = _cachedTemplate;
50-
51-
if (cachedTemplate == null || !string.Equals(cachedTemplate.Name, System.IO.Path.GetFileNameWithoutExtension(relativePath), StringComparison.Ordinal))
52-
{
53-
var fileProvider = context.Options.FileProvider;
43+
var fileProvider = context.Options.FileProvider;
5444

55-
var fileInfo = fileProvider.GetFileInfo(relativePath);
45+
var fileInfo = fileProvider.GetFileInfo(relativePath);
5646

57-
if (fileInfo == null || !fileInfo.Exists)
58-
{
59-
throw new FileNotFoundException(relativePath);
60-
}
47+
if (fileInfo == null || !fileInfo.Exists || fileInfo.IsDirectory)
48+
{
49+
throw new FileNotFoundException(relativePath);
50+
}
6151

52+
if (context.Options.TemplateCache == null || !context.Options.TemplateCache.TryGetTemplate(fileInfo, out var template))
53+
{
6254
var content = "";
6355

6456
using (var stream = fileInfo.CreateReadStream())
@@ -67,21 +59,19 @@ public override async ValueTask<Completion> WriteToAsync(TextWriter writer, Text
6759
content = await streamReader.ReadToEndAsync();
6860
}
6961

70-
if (!Parser.TryParse(content, out var template, out var errors))
62+
if (!Parser.TryParse(content, out template, out var errors))
7163
{
7264
throw new ParseException(errors);
7365
}
7466

75-
var identifier = System.IO.Path.GetFileNameWithoutExtension(relativePath);
76-
77-
_cachedTemplate = cachedTemplate = new CachedTemplate(template, identifier);
67+
context.Options.TemplateCache?.SetTemplate(fileInfo, template);
7868
}
7969

70+
var identifier = System.IO.Path.GetFileNameWithoutExtension(relativePath);
71+
8072
context.EnterChildScope();
8173
var previousScope = context.LocalScope;
8274

83-
Debug.Assert(cachedTemplate != null);
84-
8575
try
8676
{
8777
if (With != null)
@@ -91,8 +81,8 @@ public override async ValueTask<Completion> WriteToAsync(TextWriter writer, Text
9181
context.LocalScope = new Scope(context.RootScope);
9282
previousScope.CopyTo(context.LocalScope);
9383

94-
context.SetValue(Alias ?? cachedTemplate.Name, with);
95-
await cachedTemplate.Template.RenderAsync(writer, encoder, context);
84+
context.SetValue(Alias ?? identifier, with);
85+
await template.RenderAsync(writer, encoder, context);
9686
}
9787
else if (AssignStatements.Count > 0)
9888
{
@@ -105,7 +95,7 @@ public override async ValueTask<Completion> WriteToAsync(TextWriter writer, Text
10595
context.LocalScope = new Scope(context.RootScope);
10696
previousScope.CopyTo(context.LocalScope);
10797

108-
await cachedTemplate.Template.RenderAsync(writer, encoder, context);
98+
await template.RenderAsync(writer, encoder, context);
10999
}
110100
else if (For != null)
111101
{
@@ -128,7 +118,7 @@ public override async ValueTask<Completion> WriteToAsync(TextWriter writer, Text
128118

129119
var item = list[i];
130120

131-
context.SetValue(Alias ?? cachedTemplate.Name, item);
121+
context.SetValue(Alias ?? identifier, item);
132122

133123
// Set helper variables
134124
forloop.Index = i + 1;
@@ -138,7 +128,7 @@ public override async ValueTask<Completion> WriteToAsync(TextWriter writer, Text
138128
forloop.First = i == 0;
139129
forloop.Last = i == length - 1;
140130

141-
await cachedTemplate.Template.RenderAsync(writer, encoder, context);
131+
await template.RenderAsync(writer, encoder, context);
142132

143133
// Restore the forloop property after every statement in case it replaced it,
144134
// for instance if it contains a nested for loop
@@ -155,7 +145,7 @@ public override async ValueTask<Completion> WriteToAsync(TextWriter writer, Text
155145
context.LocalScope = new Scope(context.RootScope);
156146
previousScope.CopyTo(context.LocalScope);
157147

158-
await cachedTemplate.Template.RenderAsync(writer, encoder, context);
148+
await template.RenderAsync(writer, encoder, context);
159149
}
160150
}
161151
finally

0 commit comments

Comments
 (0)