Skip to content
Advertisement

How to make generic SQL heredocs?

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.

User contributions licensed under: CC BY-SA
2 People found this is helpful
Advertisement