Skip to content

Instantly share code, notes, and snippets.

@gregnavis
Created April 15, 2015 21:44
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save gregnavis/e93c8cdc2a254e356622 to your computer and use it in GitHub Desktop.
Save gregnavis/e93c8cdc2a254e356622 to your computer and use it in GitHub Desktop.
An InsertQuery object that can be used to insert models in bulk.
class InsertQuery
def initialize(model_class, column_names, returning = nil)
@model_class = model_class
@column_names = column_names
@returning = returning
@rows = []
@executed = false
end
def insert(model_attributes)
ensure_not_executed
rows << prepare_model_attributes(model_attributes, Time.zone.now)
end
def prepare_model_attributes(model_attributes, now)
column_names.map do |column_name|
prepare_model_attribute(column_name, model_attributes, now)
end
end
def prepare_model_attribute(column_name, model_attributes, now)
column_name = column_name.to_sym
if %i(created_at updated_at).include?(column_name)
if model_attributes.include?(column_name)
model_attributes[column_name]
else
now
end
else
model_attributes.fetch(column_name, :default)
end
end
def execute
ensure_not_executed
yield(self) if block_given?
result = connection.execute(query).values unless rows.empty?
self.executed = true
result
end
def self.execute(*constructor_args, &block)
new(*constructor_args).execute(&block)
end
private
attr_reader :model_class, :column_names, :rows, :returning
attr_accessor :executed
delegate :connection, to: :model_class
def ensure_not_executed
fail("the query has been already executed") if executed
end
def query
<<-SQL
INSERT INTO
#{model_class.table_name}
(#{column_names.join(', ')})
VALUES #{
rows.map do |row|
"(#{row.map { |datum| quote(datum) }.join(', ')})"
end.join(', ')
}
#{returning_clause}
SQL
end
def quote(datum)
if datum == :default
'default'
else
connection.quote(datum)
end
end
def returning_clause
%(RETURNING #{returning}) if returning
end
end
require 'test_helper'
class InsertQueryTest < ActiveSupport::TestCase
test 'does nothing when no data provided' do
@query.execute
assert_equal(0, Dog.count)
end
test 'can execute only once' do
@query.execute
assert_raise(RuntimeError) { @query.execute }
end
test 'inserts multiple rows and returns the specified columns' do
@query.insert(name: 'Woof', age: 3)
@query.insert(name: 'Waf', age: 1)
result = @query.execute
assert_equal(2, Dog.count)
assert_equal([['Woof'], ['Waf']], result)
end
test 'automatically executes the block supplied to #execute' do
result = InsertQuery.new(Dog, [:name, :age], :name).execute do |query|
query.insert(name: 'Woof', age: 1)
end
assert_equal(1, Dog.count)
assert_equal([['Woof']], result)
end
test 'omitting an attribute uses its default value' do
@query.insert(name: 'Woof')
@query.execute
assert_equal([1], Dog.pluck(:age))
end
test "set created_at and updated_at to now if they are absent" do
# Calls to #beginning_of_minute are required in order to reset the
# microseconds part. Otherwise the assertion below fails yet the result
# of inspecting the arrays is the same (despite the fact that they just
# compared unequal!).
time = 1.hour.ago.beginning_of_minute
now = Time.zone.now.beginning_of_minute
travel_to(now)
@query.insert(name: 'Woof', created_at: time)
@query.insert(name: 'Waf', updated_at: time)
@query.insert(name: 'Wef', created_at: nil, updated_at: nil)
@query.execute
assert_equal([[time, now], [now, time], [nil, nil]].sort, Dog.pluck(:created_at, :updated_at).sort)
end
setup do
Temping.create :dogs do
with_columns do |t|
t.string :name
t.integer :age, default: 1
t.timestamp :created_at
t.timestamp :updated_at
end
end
@query = InsertQuery.new(Dog, [:name, :age, :created_at, :updated_at], :name)
end
teardown do
travel_back
# This is required to make Temping work. Without it the table is created
# before the first test and the model class is defined. However, after the
# test finishes the transaction is rolled back. The table is gone but the
# model is still defined. This makes Temping think the corresponding table
# is defined too. If we undefine the model then Temping correctly recreates
# the model and the table.
Object.send(:remove_const, :Dog)
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment