I’m making a mock ORM in Ruby, storing ‘forum’ data.
I have the following tables:
users (id, fname, lname)
, and questions (id, title, body, author_id)
.
a_user.create
and a_question.create
are more or less the same:
# aUser#create def create # adds a user to the database raise "user already in db" if self.id begin QuestionsDB.instance.execute(<<-SQL, self.fname, self.lname) insert into users (fname, lname) values (?, ?); SQL self.id = QuestionsDB.instance.last_insert_row_id return self.id rescue => exception raise "failed to create user: #{exception}" end end
# aQuestion#create def create # add question to db raise "already asked" if self.id raise "title already exists" unless Question.find_by_title(self.title).empty? begin QuestionsDB.instance.execute(<<-SQL, self.title, self.body, self.author_id) insert into questions (title, body, author_id) values (?, ?, ?); SQL self.id = QuestionsDB.instance.last_insert_row_id return self.id rescue => exception raise "failed to create question: #{exception}" end end
I’m writing another class, ModelBase, and I’d like to be able to say something like
ModelBase.create(aUser)
or
aMB = ModelBase.new(~characteristics_from_user_or_question_object~) aMB.create
. Users and Questions do not have an id until after they are added to the database.
The SQL for both of the creates basically takes all the pre-creating attributes and plops them into all columns in the corresponding table, excluding the first ‘id’ column. Is there a way to generalize the instruction “insert all the attributes of this object into the specified table for values (‘all but the first’)”?
Advertisement
Answer
We can implement a very basic ORM with some Ruby meta programming but please keep in mind that this is nothing you should use in production for security reasons. Implementing a fully working ORM is A LOT of work.
We can get the instance variables of a class with instance_variables
.
class User attr_reader :id, :first_name, :last_name def initialize(first_name:, last_name:) @first_name = first_name @last_name = last_name end end puts User.new(first_name: "Elvis", last_name: "Presley").instance_variables # @first_name @last_name
Please note the @
in the beginning which we can remove with a simple gsub
. We also need to reject the @id
attribute. Finally with instance_variable_get
we can get the actual values.
With this information we can now implement a columns
and values
method.
class OrmBase def columns instance_variables_without_id.map do |var_name| var_name.to_s.gsub /^@/, '' end end def values instance_variables_without_id.map do |var_name| instance_variable_get var_name end end def instance_variables_without_id instance_variables.reject { |var_name| var_name == "@id" } end end
Now we need to compute the table name which we can derive from the class name of the object with self.class.name
. If we put everything together it would look like this:
class OrmBase def create return if @id QuestionsDB.instance.execute(to_sql) @id = QuestionsDB.instance.last_insert_row_id self end def to_sql "INSERT INTO #{table_name} (#{columns.join(', ')}) values (#{values.join(', ')});" end private def table_name "#{self.class.name}s".downcase end def columns instance_variables_without_id.map do |var_name| var_name.to_s.gsub(/^@/, '') end end def values instance_variables_without_id.map do |var_name| instance_variable_get(var_name) end end def instance_variables_without_id instance_variables.reject { |var_name| var_name == "@id" } end end class User < OrmBase attr_reader :id, :first_name, :last_name def initialize(first_name:, last_name:) @first_name = first_name @last_name = last_name end end class Question < OrmBase attr_reader :id, :title def initialize(title:) @title = title end end puts User.new(first_name: "Elvis", last_name: "Presley").to_sql # INSERT INTO users (first_name, last_name) values (Elvis, Presley); puts Question.new(title: "What is your favorite song?").to_sql # INSERT INTO questions (title) values (What is your favorite song?);
As mentioned in the beginning, please only use this as part of an exercise as it is not safe (SQL injections), has bugs (e.g. pluralisation of the table name and escaping of values) etc.
Another approach how this could get implemented is to query the database first which column names the table has and then derive the class attributes from it. ActiveRecord for instance does it this way.