PostgreSQL Materialized Views in Rails
Materialized view is a object that contains the query’s results. Unlike database table it doesn’t support INSERT
/UPDATE
/DELETE
opertaions. Since all this operations unsupported to update materialized view you need to call refresh view opertaion. In PostgreSQL materialized views support was introduced in version 9.3.
From PostgreSQL documentation you can see how to create materialized view. So, you need query that you want to materialize and… that all.
Let’s assume you have next structure: books
, authors
and feedbacks
. Each author
has many books
, each book
has many feedbacks
# author.rb
class Author < ActiveRecord::Base
has_many :books
has_many :feedbacks, through: :books
end
# book.rb
class Book < ActiveRecord::Base
belongs_to :author
has_many :feedbacks
end
# feedback.rb
class Feedback < ActiveRecord::Base
belongs_to :book
end
And let’s assume you need to we need to show top authors by their feedbacks. We have Rails ActiveRecord
and it seems easy
Author.joins(:feedbacks, :books)
.group("authors.name")
.order("sum(feedbacks.mark) DESC")
.sum("feedbacks.mark")
# {"Alexander Pushkin"=>360, "Mikhail Lermontov"=>330, "Nikolai Gogol"=>180}
But three INNER JOINS
, isn’t too much? Let’s use our materialized views! Starting from migration:
class CreateAuthorsFeedbacks < ActiveRecord::Migration
def self.up
query = %Q{
CREATE MATERIALIZED VIEW authors_feedbacks AS
#{Author.joins(:feedbacks, :books)
.select("sum(feedbacks.mark)", "authors.name", "authors.id as author_id")
.group("authors.name", "authors.id")
.order("sum(feedbacks.mark) DESC")
.to_sql};
}
execute query
add_index :authors_feedbacks, :author_id, unique: true
end
def self.down
execute <<-SQL
DROP MATERIALIZED VIEW IF EXISTS authors_feedbacks;
SQL
end
end
To be honest, I’m not big fun of using Rails to_sql
method and so on and prefer to write pure SQL for such migrations. But it’s sample and we will keep it so.
Each selected column will be materialized view
column, that’s why we used as
for authors.id
, in our table “authors.id” will be stored in “author_id” column. Unlike simple views, we can index any materialized view column, additionaly, we will make it index unique. As I said before, to actualize data in view we need to call refresh view. In pure PostgreSQL
it will be:
REFRESH MATERIALIZED VIEW authors_feedbacks
But in PostgreSQL
9.4 we can do it concurrently! It will refresh the materialized view without locking out concurrent selects on the materialized view, but… You need uniq index on materialized view for this. Wait a minute, we alredy have one!
REFRESH MATERIALIZED VIEW CONCURRENTLY authors_feedbacks
As I can see, concurrently refreshing much more quick if your index very simple. With complex index it can be even slower then unconcurrent refreshing. As it behave like table we can even use it as ActiveRecord
model.
# authors_feedback.rb
class AuthorsFeedback < ActiveRecord::Base
include ReadOnlyModel
belongs_to :author
# Refresh materialized view by reaggregating data from each connected table
def self.refresh_view
connection = ActiveRecord::Base.connection
connection.execute("REFRESH MATERIALIZED VIEW #{table_name}")
end
# Concurrently refresh materialized view by reaggregating data from each
# connected table
def self.refresh_view_concurrently
connection = ActiveRecord::Base.connection
connection.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY #{table_name}")
end
end
As I said before, materialized view doesn’t support any editing operations, for this we will use concern ReadOnlyModel
module ReadOnlyModel
extend ActiveSupport::Concern
included do
# Required to block update_attribute and update_column
attr_readonly(*column_names)
end
def readonly?
# Does not block destroy or delete
true
end
def destroy
raise ActiveRecord::ReadOnlyRecord
end
def delete
raise ActiveRecord::ReadOnlyRecord
end
end
And we need to add association for authors
class Author < ActiveRecord::Base
has_many :books
has_many :feedbacks, through: :books
has_one :authors_feedback
end
And that’s all, just use it.
Author.last.authors_feedback
# => #<AuthorsFeedback sum: 180, name: "Nikolai Gogol", author_id: 3>
AuthorsFeedback.all
#=> #<ActiveRecord::Relation [
#<AuthorsFeedback sum: 360, name: "Alexander Pushkin", author_id: 1>,
#<AuthorsFeedback sum: 330, name: "Mikhail Lermontov", author_id: 2>,
#<AuthorsFeedback sum: 180, name: "Nikolai Gogol", author_id: 3>
#]>
P.S. Don’t forget to chnage your dump format, schema.rb
can’t store materialized view structure, so, you need to store all this in SQL. Change in application.rb
config.active_record.schema_format = :sql