Skip to content

A tiny library to help pretty-printing objects using Liquid templates. It automatically caches compiled templates, uses a convention-based file template resolution and allows ad-hoc templates to be used seamlessly.

License

Notifications You must be signed in to change notification settings

loudenvier/ObjectLiquefier

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

30 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ObjectLiquefier

A (very) tiny library to help pretty-printing dotnet objects (for logging, auditing, visualization, etc. purposes). It uses Liquid templates powered by the incredible Fluid library.

Installing

The fastest and simplest way to start using the library is from it's nuget package.

NuGet\Install-Package ObjectLiquefier

It targets .NET Standard 2.0 which makes it compatible with all modern (and some legacy) dotnet projects.

"Strongly"-typed Templates

The library uses a convention-based template resolution mechanism which automatically resolves templates on disk based on an object's type name and inheritance hierarchy. When a suitable template is found it is read from disk, compiled and the compiled template is cached using the object's type name as the cache key.

The resolution algorithm uses the Type.FullName to search for a template on disk. It start's with the most qualified name and descends down to the unqualified name. For example, take a Person class declared in the following namespace:

namespace Some.Test
{
    public class Person {
        public int Id { get; set; } 
        public string Name { get; set; }
    }
}

The TemplateResolver will search for a file named some.test.person.liquid, then for test.person.liquid and, finally, simply for person.liquid. Files are searched in the configurable TemplateSettings.TemplateFolder (which defaults to "liquefier").

Note: the .liquid extension is hard-coded and cannot be changed. Templates MUST have this extension.

Template inheritance hierarchy

Template inheritance allows you to provide a single "base template" for a class hierarchy, and avoid the need to create one template for each class in the inheritance tree.

If no suitable template is found for a given Type then the object hierarchy will be traversed backwards, up to the immediate System.Object descendant, checking for a suitable template match along the way. Given the following class hierarchy:

namespace Game {
    public class Vehicle { }
    public class Car : Vehicle { }
    public class Truck : Car { }
}

The template resolution algorithm will search the following templates in this exact precedence order for the Truck class :

game.truck.liquid >> truck.liquid >> game.car.liquid >> car.liquid >> game.vehicle.liquid >> vehicle.liquid

Type name collisions

Templates are not actually strongly-typed since the engine does not care about assembly version, strong names, etc. Only the Type's Full Name is used for template resolution. Since the resolution mechanics do search for fully, partially and unqualified names, two classes with the same name can share the same template. For example:

namespace Test.One {
    public record Person(string Name);
}
namespace Test.Two {
    public class Person {
    	public string FirstName { get; set; }
	public string LastName { get; set; }
	public string Name => $"{LastName}, {FirstName}";
    }
}

Both classes would share a template named person.liquid. If you need different templates for them you should qualify the template name further. For instance, a template named two.person.liquid would only satisfy the second Person class above.

NOTE: while the class and record above could end up using the same template, the compiled template will be cached under different keys regardless as it uses the Type's full name as the cache key. This will result in a separate template compilation for each Type even though the template file is the same.

Usage

The easiest way to liquefy an object is to use Liquefier.LiquefyObject static method which uses the default settings and a singleton Liquefier instance for template compilation, caching, resolution, etc.:

var felipe = new Person { Name = "Felipe Machado", };
var liquefied = Liquefier.LiquefyObject(Felipe);

This will liquefy the instance of the Person class above using a suitable liquid template (if none is available it returns an empty string). It will cache the compiled template, so subsequent calls with instances of the same class will get an already compiled liquid template from the cache.

Liquid templates should be put inside a subfolder, under the current dir, named liquefier (can be configured). Considering the template bellow in the file .\liquefier\person.liquid:

<html>
<body>
	<h1>{{ Name }}</h1>
	<p><em>Birth:</em> {{ Birth | date: "%d/%m/%Y" }}
	<hr>
	<p>{{ Bio }}</p>
</body>
</html>

You can liquefy classes named Person, as shown in the follwing code:

public class Person {
    public string Name { get; set; } = "";
    public DateTime Birth { get; set; }
    public string? Bio { get; set; }
    public int Age => (int)((DateTime.Today - Birth).TotalDays / 365.2425);
}

var liquefied = Liquefier.LiquefyObject(new Person {
    Name = "Felipe Machado",
    Birth = new DateTime(1976, 03, 31),
    Bio = "Felipe was born in Volta Redonda, Rio de Janeiro, Brazil."
});
Console.WriteLine(liquefied);

Which will output this to the Console:

<html>
<body>
	<h1>Felipe Machado</h1>
	<p><em>Birth:</em> 31/03/1976
	<hr>
	<p>Felipe was born in Volta Redonda, Rio de Janeiro, Brazil.</p>
</body>
</html>

Default Settings

You can change the library's default settings by assigning a new Func<LiquefierSettings> to Liquefier.DefaultSettings:

Liquefier.DefaultSettings = () => new LiquefierSettings {
    TemplateFolder = "another\\folder",
    // new liquefier instances will use "./another/folder" as the liquid source template directory
};

New Liquefier instances will automatically use the new default settings. The default liquefier instance used by Liquefier.LiquefyObject() will also use the new defaults if it was NEVER used prior to changing the settings (e.g.: you didn't call Liquefier.LiquefyObject() nor read the Liquefier.Instance property, as both would have instantiated the default Liquefier before the change).

Instance Settings

If you want to use a liquefier with custom settings without changing the defaults, you can configure the settings by passing an Action<LiquefierSettings> in it's constructor:

var liquefier = new Liquefier(cfg => {
    cfg.TemplateFolder = "data\\templates";
    cfg.ParserOptions = new Fluid.FluidParserOptions { AllowFunctions = false };
    cfg.TemplateOptions.MaxSteps = 567;
    cfg.TemplateOptions.MaxRecursion = 2;
});

The LiquefierSettings passed onto the configuration Action will reflect the current default settings, so you can change only what is needed.

Ad-hoc Templates

Given a simple class (or record) you can pretty-print it using an ad-hoc liquid template with minimal code:

const string template = "Hello {{What}}";
var helloWorld = Liquefier.LiquefyObject(new { What = "World" }, template);
Console.WriteLine(helloWorld);

Which yields Hello World in the Console output.

The following unit test asserts the correctness of a more complex liquid template.

const string personTemplate =
    """
    Name: {{ Name }}
    Birth: {{ Birth | date: "%d/%m/%Y" }}
    """;

const string felipeLiquefied =
    """
    Name: Felipe
    Birth: 31/03/1976
    """;

[Fact]
public void LiquefyCanAcceptAdHocTemplate() {
    var liquefier = new Liquefier();
    var liquefied = liquefier.Liquefy(new { Name = "Felipe", Birth = new DateTime(1976, 03, 31) }, personTemplate);
    Assert.Equal(felipeLiquefied, liquefied);
}

Ad-hoc template caching

Like strongly-typed templates, ad-hoc templates are compiled at first use and the compiled template is cached by a 128-bit cache key.

To generate the 128-bit cache key the library uses Jon Hanna's donet implementation (Spookily Sharp) of Bob Jenkins’ SpookyHash version 2. While collisions are possible they are very improbable so the library does nothing at all to prevent it as 1 in 16 quintillion chances is low enough for me.

Pretty-printing properties inside templates

The library ships with a {% liquefy [expression] %} expression tag which allows you to pretty-print objects from within a liquid template. It will run under the same FluidParser and TemplateContext instances that the current liquid template is being executed on, and will use all the same caching and template resolution rules for whichever object the expression resolves to (if no template is found, it will output the value for the expression using the internals of the Fluid library).

For example, given the following class structure:

public class Person {
    public string Name { get; set; } = "";
    public DateTime Birth { get; set; }
}

public class Parent : Person {
    public Child FirstBorn { get; set; } = new();
}

public class Child : Person { }

And the following templates:

  • {template_folder}\person.liquid:
    Name: {{ Name }}
    Birth: {{ Birth | date: "%d/%m/%Y" }}
  • {template_folder}\parent.liquid:
    Name: {{ Name }}
    Birth: {{ Birth | date: "%d/%m/%Y" }}
    FirstBorn:
    {% liquefy FirstBorn %}

Given the following code:

var liquefier = new Liquefier();
var parent = new Parent {
    Name = "Felipe",
    Birth = new DateTime(1976, 3, 31),
    FirstBorn = new Child {
        Name = "Bernardo",
        Birth = new DateTime(2014, 10, 10)
    }
};
var liquefied = liquefier.Liquefy(parent);
Console.WriteLine(liquefied);

We'll get this output in the console:

Name: Felipe
Birth: 31/03/1976
FirstBorn:
Name: Bernardo
Birth: 10/10/2014

The engine will use the template at parent.liquid for the parent object since it's an object of type Parent. Whithin the template, the liquefy tag is executed and it's expression resolves to parent.FirstBorn, which is of type Child. The template resolution will then use the person.liquid template, since there's no child.liquid template to use and Person is an ancestor of Child (see Template inheritance hierarchy).

About

A tiny library to help pretty-printing objects using Liquid templates. It automatically caches compiled templates, uses a convention-based file template resolution and allows ad-hoc templates to be used seamlessly.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages