User Editable Liquid Templates in the Database
Posted on September 07, 2009
For quite some time I wanted to create an experiment: having user-editable view templates stored in the database instead of the filesystem.
The Use Case is quite simple: it’s either a theme system or a website where each user can edit his own set of templates to have a customized usage of the application. In the case of a simple theming system, just adding additional view load paths would be enough. But the second case needs scalability and having a potential set of thousands of view sub-folders around never felt well.
One solution is to have every view in a database table. If performance becomes critical, we can always add Memcached to the equation, so it shouldn’t be a problem. Now, there is another problem: we can’t just store the ERB view files directly into the database and make them user-editable. That’s because ERB executes arbitrary Ruby code, which means that the user would have access to the entire machine. Haml and other templating engines have the same feature. One of the few that implements a user-centric, restrictive templating engine is Tobias Lütke’s Liquid, created precisely for the same purpose for his Shopify.com website.
Having that in mind, I just published the dynamic_liquid_templates plugin as an attempt to have just that. In order to get started, you can create a new Rails project and add it as a normal plugin:
1 2 3 4 |
rails demo cd demo ./script/generate dynamic_templates rake db:migrate |
You will have to require Liquid from config/environment.rb
1 |
config.gem "tobi-liquid", :lib => "liquid", :source => "http://gems.github.com" |
And, of course, sudo rake gems:install to have it installed if you still don’t have it. Now, create a normal Rails resource, such as ‘posts’ or whatever. You will have to modify your scaffolded controller so the ‘format.html’ calls look like this:
1 2 3 |
respond_to do |format| format.html { render_with_dynamic_liquid } end |
If you’re inside a nested controller (such as ‘Comment belongs_to Post’), declare a method named ‘parent’, for example:
1 2 3 4 5 6 |
class CommentsController < ApplicationController ... def parent @post ||= Post.find(params[:post_id]) end end |
The plugin will use this method to refer to the proper named routes, such as post_comments_path(parent) or post_comment_path(parent, @comment) and so on. Namespaces will work too, for example:
1 2 3 4 5 6 7 |
class Admin::PostsController < ApplicationController def index @posts = Post.all respond_to do |format| format.html { render_with_dynamic_liquid(:namespace => 'admin') } end end |
There’s probably a better syntax for that, but it works like that too. The other thing is that your models need to be ‘liquified’ in order to be usable from inside a Liquid template. You have to declare a to_liquid method explicitly saying which columns you want exposed:
1 2 3 4 5 6 |
class Post < ActiveRecord::Base ... def to_liquid { 'title' => self.title, 'body' => self.body } end end |
Notice that the hash keys have to be strings, and not symbols. The other thing is that the plugin will append other attributes to this hash automagically, so you can do the following from within your Liquid templates:
1 |
{{ 'Show' | link_to: post.show_path }}
|
It adds ‘show_path’ and ‘edit_path’ to your model instances, so you can use them to create links or form actions. There are other globally assigned variables as well, such as:
- collection – refers to the controller collection (for index action) such as @posts
- object – referes to the controller single object (for non-index actions) such as @post
- collection_path – the named route for the index action, including nested and namespaced versions
- object_path – the named route for the non-index action, including nested and namespaced versions
- parent – if you have the ‘parent’ method in your controller, it’s exposed within Liquid
- parent_path – if you have the ‘parent’ method in your controller, it’s used for named routes
And, of course, your models are properly assigned to Liquid too so, for example, ‘@posts’ is exposed as ‘posts’ to Liquid.
Next Steps
Now, this is just the beginning. You can create a scaffold for the DynamicTemplate model, so you have an editing interface. You can refer to the ‘spec/fixtures’ folder within the plugin for examples on how a Liquid template looks like (it’s less convenient than ERB, I can tell).
Then you can add multi-site support to your website by adding a foreign-key to all your models, a new table such as ‘Site’ and a ‘site_id’ column in all your other models. Then, each Active Record model can have the ‘default_scope’ declared, such as:
1 2 3 4 |
class Post < ActiveRecord::Base default_scope proc { { :conditions => ["site_id", Site.current_site] } } ... end |
But, this is not quite possible today because ‘default_scope’ seems to not be accepting a proc just yet. Let’s hope this feature comes sooner, or we can just create yet another plugin anyway, so no big deal.
Another thing, we can still improve it’s performance by serializing the pre-compiled template instead of the raw text version as explained in this article. I will do it this week, probably.
Then, we need to add caching support. As I am using plain Active Record, simple config.cache_store configuration should do. And you will also want to add other features such as versioning the dynamic_templates table so the user can change his mind later on. For this one, it’s as easy as watching Ryan Bates’ recent vestal_versions screencast.
This is just an experiment, I hope it can be useful to someone.
blog comments powered by Disqus
Archives
- February 12(2)
- December 11(1)
- November 11(4)
- October 11(6)
- September 11(5)
- August 11(1)
- July 11(5)
- May 11(4)
- April 11(11)
- March 11(4)
- February 11(3)
- January 11(4)
- December 10(9)
- November 10(2)
- October 10(10)
- September 10(4)
- August 10(6)
- July 10(14)
- June 10(16)
- May 10(8)
- April 10(14)
- March 10(9)
- February 10(6)
- January 10(14)
- December 09(10)
- November 09(10)
- October 09(7)
- September 09(19)
- August 09(4)
- July 09(12)
- June 09(7)
- May 09(12)
- April 09(11)
- March 09(9)
- February 09(9)
- January 09(12)
- December 08(14)
- November 08(20)
- October 08(15)
- September 08(18)
- August 08(25)
- July 08(13)
- June 08(21)
- May 08(29)
- April 08(27)
- March 08(12)
- February 08(32)
- January 08(31)
- December 07(27)
- November 07(30)
- October 07(25)
- September 07(28)
- August 07(16)
- July 07(15)
- June 07(16)
- May 07(7)
- April 07(13)
- March 07(8)
- February 07(9)
- January 07(24)
- December 06(17)
- November 06(17)
- October 06(15)
- September 06(38)





