Jason Larke
Jason Larke

Reputation: 5609

Structuring using rails ActiveRecord

I'm virtually a beginner at both Ruby and the Rails framework, which was why I've decided to ask for help before I do something that breaks the conventions of the framework.

I have a fairly solid OO programming background, and I'm pretty comfortable with beginner->intermediate level SQL queries. However, I've been having trouble wrapping my head around the ActiveRecord class that Rails supplies. My immediate instinct is to just scrap the ActiveRecord class entirely, and write out my own SQL queries by hand and wrap them up in models. However, I know ActiveRecords are a fairly integral part of the Rails framework, and avoiding them will just cause me pain in the future.

The following is my MySQL schema as it stands (I'll write a Rails Migration later on). I'll try to keep this question as concise as possible, but I may have to go into a little background to explain why I've modeled the schema as I have. I'm not overly attached to it, so if people have better ideas on the structure that'd be great.

-- Users table is a minimalized version of what it probably will be, but contains all pertinent information
CREATE TABLE IF NOT EXISTS users (
    id          INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    name        VARCHAR(20) UNIQUE NOT NULL
) Engine=InnoDB;

CREATE TABLE IF NOT EXISTS hashtags (
    id          INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    tag         VARCHAR(30) UNIQUE NOT NULL
) Engine=InnoDB;

CREATE TABLE IF NOT EXISTS content_mentions (
    content_id  INT UNSIGNED NOT NULL,
    user_id     INT UNSIGNED NOT NULL, 
    INDEX(content_id),
    FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
) Engine=InnoDB;

CREATE TABLE IF NOT EXISTS content_hashtags (
    content_id  INT UNSIGNED NOT NULL,
    hashtag_id  INT UNSIGNED NOT NULL,
    INDEX(content_id),
    FOREIGN KEY(hashtag_id) REFERENCES hashtags(id) ON DELETE CASCADE
) Engine=InnoDB;

CREATE TABLE IF NOT EXISTS content_comments (
    id          INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    user_id     INT UNSIGNED NOT NULL,
    content_id  INT UNSIGNED NOT NULL,
    text_body   VARCHAR(1000) NOT NULL,
    date_created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
    INDEX(content_id)
) Engine=InnoDB;

CREATE TABLE IF NOT EXISTS polls (
    id          INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    user_id     INT UNSIGNED NOT NULL,
    question    VARCHAR(100) NOT NULL,
    text_body   VARCHAR(1000) NOT NULL,
    date_created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
) Engine=InnoDB;

CREATE TABLE IF NOT EXISTS poll_options (
    id          INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    poll_id     INT UNSIGNED NOT NULL,
    content     VARCHAR(150) NOT NULL,
    active      VARCHAR(1) NOT NULL DEFAULT 'Y',
    FOREIGN KEY(poll_id) REFERENCES polls(id) ON DELETE CASCADE
) Engine=InnoDB;

CREATE TABLE IF NOT EXISTS poll_answers (
    poll_option_id  INT UNSIGNED NOT NULL,
    user_id     INT UNSIGNED NOT NULL,
    FOREIGN KEY(poll_option_id) REFERENCES poll_options(id) ON DELETE CASCADE,
    FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
    PRIMARY KEY(poll_option_id,user_id)
) Engine=InnoDB;

As the schema would indicate, this is a really basic web-poll application. Each poll has multiple options and each option can have multiple answers by different users. Now, the odd part perhaps looking at it is the content_* tables. The best way I can explain this is probably by describing it as an abstract table. I've never really done anything like that before, usually the relationships are between two or more explicit tables and I'll add the foreign keys as required. In this case, however, I may end up with multiple different types of content, all of which require hashtagging/mentioning/commenting. I don't know in advance what table the content_id refers to (the code will deal with the data it receives appropriately) so I've just indexed the column for now. I need to adjust the content_* tables to add a type column at some stage as once more than one content table is present, there may be duplicate content_id entries if both tables use an auto-incrementing primary key, I think that's a bit out of the scope of the question though.

On to the structuring of the ActiveRecord classes. The first part is handling the parsing of mentions/hashtags. I wrote an abstract Content class to handle the "abstract" side of the tables. It goes something like this (some of the parsing has been removed for brevity).

class Content < ActiveRecord::Base
    self.abstract_class = true;

    # relationships
    belongs_to :user

    has_many :content_mentions;
    has_many :content_hashtags;
    has_many :mentions, { :through => :content_mentions, :source => :user, :as => :content };
    has_many :hashtags, { :through => :content_hashtags, :as => :content };

    # available columns (in the abstract side of things)
    attr_accessible :text_body, :date_created;

    # database hooks
    around_save :around_save_hook

    # parsing
    ENTITY_PATTERN = /removed_for_brevity/iox;

    def render_html()
        # parsing of the text_body field for hashtags and mentions and replacing them with HTML
        # goes in here, but unrelated to the data so removed.
    end

protected

    # Is this the best way to do this?
    def around_save_hook()
        # save the main record first (so we have a content_id to pass to the join tables)
        yield

        # parse the content and build associations, raise a rollback if anything fails
        text_body.scan(ENTITY_PATTERN) do |boundary,token,value|
            m = $~;

            if m[:token] == '@'
                # mention
                unless mentions.where(:name => m[:value]).first
                    mention = User::where(:name => m[:value]).first;
                    next unless mention;

                    raise ActiveRecord::Rollback unless content_mentions.create({ :content_id => id, :user_id => mention.id });
                end
            else
                # hashtag
                unless hashtags.where(:tag => m[:value]).first
                    hashtag = Hashtag.where(:tag => m[:value]).first;

                    unless hashtag
                        hashtag = Hashtag.new({ :tag => m[:value] });
                        raise ActiveRecord::Rollback unless hashtag.save();
                    end

                    raise ActiveRecord::Rollback unless content_hashtags.create({ :content_id => id, :hashtag_id => hashtag.id });
                end
            end
        end
    end 
end

The main problem I have here is with the around_save_hook, is this the best place to parse and save the associations? How would I do it so that if the text_body was updated and some of the hashtags/mentions removed from the original, these changes would be reflected in the content_* associations, rather than just the new hashtags/mentions added without checking for removals?

The rest of the ActiveRecord classes are defined as follows:

class Poll < Content
    has_many :poll_options;
    has_many :poll_answers, { :through => :poll_options }

    attr_accessible :user_id, :question;
    validates :text_body, :presence => true, :length => { :maximum => 1000 };
end

class PollOption < ActiveRecord::Base
    belongs_to :poll;
    has_many :poll_answers;

    attr_accessible :content, :active, :poll_id;
end

class PollAnswer < ActiveRecord::Base
    belongs_to :poll_option;
    belongs_to :user;

    attr_accessible :user_id, :poll_option_id;
end

class User < ActiveRecord::Base
    attr_accessible :name;

    validates :name, :presence => true, :length => { :maximum => 20 };
end

class Hashtag < ActiveRecord::Base
    attr_accessible :tag;

        validates :tag, :presence => true, :length => { :maximum => 30 };
end

# Join table for content->users
class ContentMention < ActiveRecord::Base
    belongs_to :user;
    belongs_to :content, { :polymorphic => true };

    attr_accessible :content_id, :user_id;
end

# Join table for content->hashtags
class ContentHashtag < ActiveRecord::Base
    belongs_to :hashtag;
    belongs_to :content, { :polymorphic => true };

    attr_accessible :content_id, :hashtag_id;
end

So I guess my questions are as follows:

  1. Is the schema itself corrent (i.e is it grossly inefficient and poorly designed to use with rails? (if so, suggestions on how to correct it would be fantastic)
  2. Is Around Save the right place to parse and update the associations?
  3. Are my ActiveRecords set up correctly based on the current schema structure? (specifically I'm not sure if I'm using the polymorphic attribute correctly)
  4. How would I add options/answers to a Poll instance, without re-saving the entire content of the Poll (and thus triggering another redundant parsing of the content) while still retaining an OOP approach to this? (i.e options/answers are created through a public API from the Poll model)

It'd be really great if someone who's really comfortable with Rails, Ruby and ActiveRecord could run me up a quick copy of how they would implement the bare-bones of this. As I said I've never used the ActiveRecord class before so I'm not even sure how many raw SQL queries this simple code will be triggering on a single save() call already.

Upvotes: 1

Views: 383

Answers (1)

Harish Shetty
Harish Shetty

Reputation: 64363

Here is a two part railscast covering aspects of implementing a poll/survey application. It covers most of your model related doubts.

http://railscasts.com/episodes/196-nested-model-form-part-1

http://railscasts.com/episodes/197-nested-model-form-part-2

I would create the dependent objects during assignment by over-riding the setter for text_body.

Eg:

def text_body=(val)
  write_attribute(:text_body, val).tap do |v|
    append_new_tags_and_mentions
  end
end

def append_new_tags_and_mentions
  tag_list, mention_list = extract_tags_and_mentions
  new_mentions = mention_list - mentions.where(name => mention_list).pluck(:name)    
  mentions.concat(*new_mentions) if new_mentions.present?   
  new_tags = tag_list - hashtags.where(tag => tag_list).pluck(:tag)
  hashtags.concat(*new_tags) if new_tags.present?
end

def extract_tags_and_mentions
  tag_list = []
  mention_list = []
  text_body.scan(ENTITY_PATTERN) do |boundary, token, value|
    if token == "@"
      mention_list << value
    else
      tag_list << value
    end
  end
  [tag_list, mention_list]
end

Add a validator to check the dependencies.

General guideline I wish I knew before I started programming in rails after working in Java/C++/SQL for a long time.

  • Do not hand code the table generation SQL

  • Use db:create rake tasks for creating tables

  • Rails does not support foregin keys. You can enforce it through validators.

  • Do not use semicolons to terminate the line. One of the joys of ruby is you don't have terminate lines.

  • Do not use explicit hashes for DSL API parameters.

    Use this idiom

    belongs_to :content, :polymorphic => true
    

    Instead of:

    belongs_to :content, { :polymorphic => true };
    
  • Use modules instead of inheritance for code re-usage.

  • Use each instead of for

  • Learn map, reduce (i.e. inject) functions on an array.

Upvotes: 2

Related Questions