Escaping ActiveRecord

What is wrong with ActiveRecord?

def self.visible_for user
  groups       = Arel::Table.new(:groups)
  projects     = Arel::Table.new(:projects)
  roles        = Arel::Table.new(:roles)
  user_roles   = Arel::Table.new(:roles_users)
  roles2       = Arel::Table.new(:roles).alias
  user_roles2  = Arel::Table.new(:roles_users).alias

  join_roles = groups.create_on \
    groups[:id].eq(roles[:resource_id])
    .and(roles[:resource_type].eq('Group'))
  outer_join_roles = projects.create_join(roles, join_roles, Arel::Nodes::OuterJoin)

  join_user_roles = roles.create_on(user_roles[:role_id].eq(roles[:id]))
  outer_join_user_roles = projects.create_join(user_roles, join_user_roles, Arel::Nodes::OuterJoin)

  join_projects  = groups.create_on \
    groups[:project_id].eq(projects[:id])
  outer_join_projects = groups.create_join(projects, join_projects, Arel::Nodes::OuterJoin)

  join_roles2 = groups.create_on \
    projects[:id].eq(roles2[:resource_id])
    .and(roles2[:resource_type].eq('Project'))
  outer_join_roles2 = groups.create_join(roles2, join_roles2, Arel::Nodes::OuterJoin)

  join_user_roles2 = roles2.create_on(user_roles2[:role_id].eq(roles2[:id]))
  outer_join_user_roles2 = groups.create_join(user_roles2, join_user_roles2, Arel::Nodes::OuterJoin)

  uniq.
    joins(outer_join_roles).
    joins(outer_join_user_roles).
    joins(outer_join_projects).
    joins(outer_join_roles2).
    joins(outer_join_user_roles2).
    where(user_roles[:user_id].eq(user.id).or(user_roles2[:user_id].eq(user.id)))
end

The "Rails" Way

def self.visible_for user
  sql = <<EOF
    SELECT DISTINCT groups.*
    FROM groups

    LEFT OUTER JOIN projects ON groups.project_id = projects.id

    LEFT OUTER JOIN roles ON groups.id = roles.resource_id
                          AND roles.resource_type = 'Group'
    LEFT OUTER JOIN roles_users ON roles_users.role_id = roles.id

    LEFT OUTER JOIN roles roles_2 ON projects.id = roles_2.resource_id
                                  AND roles_2.resource_type = 'Project'
    LEFT OUTER JOIN roles_users roles_users_2 ON roles_users_2.role_id = roles_2.id

    WHERE groups.deleted_at IS NULL
      AND (roles_users.user_id = ? OR roles_users_2.user_id = ?)
EOF
  where([sql, user.id, user.id])
end

The "Database" Way

"But we might change database one day"

- Did no one, ever...

So start using your database!

module ActiveRecord::SecureToken::ClassMethods
  def generate_unique_secure_token(attribute)
    10.times do |i|
      SecureRandom.hex(12).tap do |token|
        if exists?(attribute => token)
          raise "Couldn't generate a unique token in 10 attempts!" if i == 9
        else
          return token
        end
      end
    end
  end
end

The "Rails" Way

module ActiveRecord::SecureToken::ClassMethods

  def generate_unique_secure_token
    SecureRandom.base58(24)
  end

end

The "Rails" Way

ALTER TABLE my_table ADD COLUMN uid text NOT NULL DEFAULT uuid_generate_v4();
CREATE FUNCTION make_uid() RETURNS text AS $$
DECLARE
    new_uid text;
    done bool;
BEGIN
    done := false;
    WHILE NOT done LOOP
        new_uid := uuid_generate_v4();
        done    := NOT exists(SELECT 1 FROM my_table WHERE uid=new_uid);
    END LOOP;
    RETURN new_uid;
END;
$$ LANGUAGE PLPGSQL VOLATILE;

ALTER TABLE my_table ADD COLUMN uid text NOT NULL DEFAULT make_uid();

The "Database" Way

Scoped

auto-increment

The "Rails" Way

thread.increment!(:comment_sequence)
comment.scoped_id = thread.comment_sequence

The "Database" Way

ALTER TABLE comments ADD COLUMN sequence_id SERIAL;
CREATE SEQUENCE comments_for_post_1_sequence;

ALTER TABLE comments
ADD COLUMN scoped_id integer NOT NULL DEFAULT nextval('comments_for_post_1_sequence');

ALTER SEQUENCE comments_for_post_1_sequence OWNED BY comments.scoped_id;

Random records

The "Rails" Way

comment = Comment.order("RANDOM()").limit(10)
comment_ids = Comment.pluck(:id).shuffle[0..4]
Comment.where(id: comment_ids)

The "Database" Way

select * from comments
where id in (
  select round(random() * (select count(1) from logs)::integer)::integer as id
  from generate_series(1, 20)
  group by id -- Discard duplicates
)
limit 10

Counter cache

The "Rails" Way

class Comment < ActiveRecord::Base  
  belongs_to :post, counter_cache: true
end

class Post < ActiveRecord::Base  
end

```
CREATE TABLE posts (  
  id              serial PRIMARY KEY,
  comments_count  integer NOT NULL DEFAULT 0
);

CREATE TABLE comments (  
  id       serial PRIMARY KEY,
  post_id  integer NOT NULL REFERENCES posts(id),
);
```

comment = Comment.create(post: post)
comment.update_attribute(:post_id, 42)

The "Database" Way

CREATE TRIGGER update_post_comments_count  
  AFTER DELETE OR INSERT OR UPDATE ON comments
  FOR EACH ROW EXECUTE PROCEDURE counter_cache('posts', 'comments_count', 'post_id');
CREATE FUNCTION increment_counter(table_name text, column_name text, id integer, step integer)  
  RETURNS VOID AS $$
    DECLARE
      table_name text  := quote_ident(table_name);
      column_name text := quote_ident(column_name);
      conditions text  := ' WHERE id = $1';
      updates text     := column_name || '=' || column_name || '+' || step;
    BEGIN
      EXECUTE 'UPDATE ' || table_name || ' SET ' || updates || conditions
      USING id;
    END;
  $$ LANGUAGE plpgsql;

The "Database" Way

CREATE FUNCTION counter_cache()  
  RETURNS trigger AS $$
    DECLARE
      table_name text    := quote_ident(TG_ARGV[0]);
      counter_name text  := quote_ident(TG_ARGV[1]);
      fk_name text       := quote_ident(TG_ARGV[2]);
      fk_changed boolean := false;
      fk_value integer;
      record record;
    BEGIN
      IF TG_OP = 'UPDATE' THEN
        record := NEW;
        EXECUTE 'SELECT ($1).' || fk_name || ' != ' || '($2).' || fk_name
        INTO fk_changed
        USING OLD, NEW;
      END IF;

      IF TG_OP = 'DELETE' OR fk_changed THEN
        record := OLD;
        EXECUTE 'SELECT ($1).' || fk_name INTO fk_value USING record;
        PERFORM increment_counter(table_name, counter_name, fk_value, -1);
      END IF;

      IF TG_OP = 'INSERT' OR fk_changed THEN
        record := NEW;
        EXECUTE 'SELECT ($1).' || fk_name INTO fk_value USING record;
        PERFORM increment_counter(table_name, counter_name, fk_value, 1);
      END IF;

      RETURN record;
    END;
  $$ LANGUAGE plpgsql;

Database Views

You know when you need one

# Highest Scoring Talks in PA by Authors named Parker

Feedback.joins(talk: [:author, { club: :city }] )
        .select("feedbacks.talk_id, avg(feedbacks.score) as overall_score")
        .where("cities.state_abbr = ?", "PA")
        .where("authors.name LIKE '%?%'", "Parker")
        .group("feedbacks.talk_id")
        .order("overall_score desc")
        .limit(10)
# 665ms
SELECT feedbacks.talk_id, avg(feedbacks.score) as overall_score
FROM "feedbacks"

INNER JOIN "talks"   ON   "talks"."id" = "feedbacks"."talk_id"
INNER JOIN "authors" ON "authors"."id" = "talks"."author_id"
INNER JOIN "clubs"   ON   "clubs"."id" = "talks"."club_id"
INNER JOIN "cities"  ON  "cities"."id" = "clubs"."city_id"

WHERE (cities.state_abbr = 'PA')
AND   (authors.name LIKE '%Parker%')

GROUP BY feedbacks.talk_id
ORDER BY overall_score desc
LIMIT 10;

* https://github.com/droberts84/materialized-view-demo

Create View

class CreateTalkView < ActiveRecord::Migration
  def up
    connection.execute <<-SQL
      CREATE VIEW v_talks_report AS
        SELECT cities.id as city_id,
               cities.name as city_name,
               cities.state_abbr as state_abbr,
               technologies.id as technology_id,
               clubs.id as club_id,
               clubs.name as club_name,
               talks.id as talk_id,
               talks.name as talk_name,
               authors.id as author_id,
               authors.name as author_name,
               feedback_agg.overall_score as overall_score
        FROM (
          SELECT talk_id, avg(score) as overall_score
          FROM feedbacks
          GROUP BY talk_id
        ) as feedback_agg
        INNER JOIN talks ON feedback_agg.talk_id = talks.id
        INNER JOIN authors ON talks.author_id = authors.id
        INNER JOIN clubs ON talks.club_id = clubs.id
        INNER JOIN cities ON clubs.city_id = cities.id
        INNER JOIN technologies ON clubs.technology_id = technologies.id
    SQL
  end

  def down
    connection.execute 'DROP VIEW IF EXISTS v_talks_report'
  end
end

Encapsulate in

ActiveRecord Model

class TalkReport < ActiveRecord::Base
  # Use associations just like any other ActiveRecord object
  belongs_to :author
  belongs_to :talk
  belongs_to :club
  belongs_to :city
  belongs_to :technology

  # take advantage of talks has_many relationship
  delegate :feedbacks, to: :talk

  self.table_name = 'v_talks_report'

  # views cannot be changed since they are virtual
  def readonly
    true
  end
end

And just use it!

# Highest Scoring Talks in PA by Authors named Parker

TalkReport.where(state_abbr: 'PA')
          .where("author_name LIKE '%Parker%'")
          .order(overall_score: :desc)
          .limit(10)

But there's a catch...

SELECT v_talks_report.*
FROM "v_talks_report"

WHERE state_abbr = 'PA'
AND   author_name LIKE '%Parker%'

ORDER BY overall_score desc
LIMIT 10;

-- 665ms

Materialized Views

How it works?

Create it...

class CreateTalkView < ActiveRecord::Migration
  def up
    connection.execute <<-SQL
      CREATE MATERIALIZED VIEW mv_talks_report AS
      # [...]
    end
  end
end

... and use it!

# Highest Scoring Talks in PA by Authors named Parker

TalkReport.where(state_abbr: 'PA')
          .where("author_name LIKE '%Parker%'")
          .order(overall_score: :desc)
          .limit(10)

# 19ms

But there's always a catch

  • PostgreSQL 9.3+
  • Entire Materialized View must be refreshed
  • Migrations are hard!

Quick fixes

class TalkReport < ActiveRecord::Base
  # [...]

  def self.repopulate
    connection.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY #{table_name}")
  end
end
  • insert / update / delete
  • database triggers
  • incremental updates (not yet)

So, should I hate ActiveRecord?

Nope, just think twice what you're doing!

Thanks!

Escaping ActiveRecord

By Bernard Potocki

Escaping ActiveRecord

  • 1,457