Reputation: 5609
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:
Around Save
the right place to parse and update the associations?polymorphic
attribute correctly)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
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