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:
- 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)
- Is
Around Savethe right place to parse and update the associations? - Are my ActiveRecords set up correctly based on the current schema structure? (specifically I'm not sure if I'm using the
polymorphicattribute correctly) - How would I add options/answers to a
Pollinstance, without re-saving the entire content of the Poll (and thus triggering another redundant parsing of the content) while still retaining anOOPapproach to this? (i.e options/answers are created through a public API from thePollmodel)
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.
after_saveinstead ofaround_saveandyield.Pollusing ActiveRecords, not really 'big'. As for theafter_savemethod, I couldn't find a clear piece of documentation as to whether you can rollback inside it.